mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* fix(comment): restore autopilot @mention delegation authority (MUL-4857) A schedule/webhook autopilot run is unattributed by design (no top-of-chain human originator, MUL-4302). Since MUL-3963 the A2A invoke gate (canInvokeAgent) keys on that originator, so a mid-run @agent/@squad delegation on an autopilot-created issue fails closed for the DEFAULT private agent (and member-scoped public_to agents): the mention renders but no run is enqueued. The SAME autopilot's first dispatch is admitted via the autopilot creator (autopilotAdmitInvoke -> canCreatorInvokeAgent), so first-dispatch and mid-run delegation disagreed. Align them: when an unattributed agent/system-authored comment on an autopilot-origin issue reaches computeCommentAgentTriggers with no originator, fall back to the autopilot creator as the effective invoking user for the gate. The gate still runs (no unrestricted agent-to-agent bypass); it is authorization only -- the enqueued task's originator/attribution stays unattributed. Scoped to autopilot-origin issues so other unattributed chains stay fail-closed. Adds a DB-backed regression test covering: creator-owns-target admits, a non-autopilot unattributed run stays denied, and a creator without invoke rights stays denied. Co-authored-by: multica-agent <github@multica.ai> * fix(comment): bind autopilot @mention authority to verified task lineage (MUL-4857) Address the review's confused-deputy finding on the P0 fix. The first cut keyed the invoke-gate fallback on issue provenance + an empty originator alone (invokeAuthorityForAutopilotIssue took only the issue), so any unattributed run could borrow a stranger autopilot creator's rights merely by commenting on that autopilot's issue — and the fallback also leaked past explicit @mention into the plain-comment squad-leader path and system actors. Rework it so the autopilot-creator authority is granted ONLY when the SPEAKING task's lineage is verified against this issue: - resolve the authority separately (new AutopilotDelegationAuthorityUserID on commentTriggerComputeOptions), never by overwriting OriginatorUserID; the gate reads it through opts.effectiveInvoker() only when no human originator resolved, so attribution stays untouched; - resolve from a server-trusted speaking task — X-Task-ID on create/preview, comment.source_task_id on edit/reconcile — via autopilotDelegationAuthority, which admits only when author == task agent AND task.issue_id == this issue AND the issue is autopilot-origin, then keys on the member autopilot creator; - do NOT key on autopilot_run_id: in create_issue mode (the reported case) the leader task is enqueued through the ordinary issue-assignment path and has no autopilot_run_id — the task.issue_id == issue binding is what proves the run is part of this autopilot's work while rejecting foreign-issue runs. Tests: replace the provenance-only regression with lineage-bound coverage — verified-lineage-admits, creator-without-rights-denied, non-autopilot-denied, missing-source-task-denied, cross-issue-source-task-denied, author!=task-agent- denied — plus an end-to-end CreateComment path asserting the private worker is enqueued and the delegated run stays unattributed. Verified the fallback is load-bearing (positive + e2e fail with it disabled) and the full internal/handler package passes. Skill docs (multica-mentioning) updated to the lineage-bound contract and new helper names. Co-authored-by: multica-agent <github@multica.ai> * fix(comment): make autopilot @mention authority consistent across defer/edit (MUL-4857) Second review round (Elon) surfaced two must-fixes on top of the lineage binding. 1. Busy-target completion reconcile lost the authority. A delegation to a target that is already running is deferred to that target's completion reconcile (reconcileCommentsOnCompletion). That path recomputed triggers with only the (empty) originator, so an unattributed autopilot delegation's follow-up was gate-denied again and silently dropped. It now restores the delegation authority from comment.source_task_id, so the follow-up fires once the target frees up — still unattributed. 2. Edit could borrow the old authoring run's authority, and preview != save. The edit preview keyed authority on the current request task while save keyed it on the comment's original source_task_id, so an agent editing its old autopilot comment from a task on an UNRELATED issue would fail-closed in preview but reuse the old autopilot creator's authority on save (cross-issue confused-deputy, and a preview/side-effect divergence). Fix: treat source_task_id as the persisted per-action authority lineage and re-stamp it on edit to the CURRENT editing task, issue-scoped exactly like CreateComment. A cross-issue edit re-stamps it to NULL, so preview, save, AND the deferred reconcile all fail closed identically. UpdateComment query gains a source_task_id param (sqlc regen). Also locks the review-accepted behavior that effectiveInvoker() carries the autopilot-creator authority into the plain assigned-squad-leader wake (a worker's result comment on the autopilot issue can still wake the private leader). Tests: reconcile-restores-authority (owns -> one unattributed follow-up; no rights -> none); edit re-stamp (same-issue keeps authority and triggers; cross-issue clears source_task_id and fails closed); worker-result wakes private squad leader. Verified both fixes are load-bearing (each negative control reproduces the exact regression Elon described), full internal/handler + internal/service packages pass, gofmt/vet clean. Skill docs (multica-mentioning) updated. Co-authored-by: multica-agent <github@multica.ai> * fix(comment): clear stale task lineage on non-author comment edits (MUL-4857) An admin editing an autopilot Agent's comment previously preserved the comment's original source_task_id. The immediate save is judged on the admin's member identity and correctly fails closed, but the deferred completion-reconcile routes the comment under its original agent author and resolved the delegation authority from the stale source_task_id, resurrecting the autopilot creator's invoke authority once the busy target freed up — an admin (manage rights) could thereby trigger another owner's private agent (invoke rights). Now a content edit re-derives lineage from the edit action: only the agent author editing its own comment re-stamps source_task_id to the current editing task; every other editor (member/admin, or any non-author) clears it, so preview, save, and reconcile all fail closed. Adds a regression covering the admin-edit + busy-target path and syncs the multica-mentioning skill docs. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
692 lines
31 KiB
Go
692 lines
31 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// autopilotDelegationFixture builds the MUL-4857 create_issue scenario: a
|
|
// member-created autopilot creates an issue, and its dispatched leader agent runs
|
|
// a task ON that issue and authors an @mention delegation comment whose
|
|
// source_task_id points back at that leader task. The authoring run is
|
|
// UNATTRIBUTED (originator NULL) exactly as a schedule/webhook autopilot run is.
|
|
//
|
|
// This is the shape the authority fallback must recognise — but ONLY through the
|
|
// verified lineage of the speaking task (author == task agent, task.issue_id ==
|
|
// this issue), never from the issue's autopilot provenance alone. The fields are
|
|
// exposed so negative cases can rewrite the comment's source_task_id to a foreign
|
|
// task and prove the fallback then fails closed.
|
|
type autopilotDelegationFixture struct {
|
|
Issue db.Issue
|
|
LeaderAgentID string // the autopilot-dispatched agent authoring the comment
|
|
LeaderTaskID string // its running task on this issue (comment.source_task_id)
|
|
Comment db.Comment
|
|
AutopilotID string
|
|
RuntimeID string
|
|
}
|
|
|
|
func newAutopilotDelegationFixture(t *testing.T, targetAgentID, autopilotCreatorUserID, originType string) autopilotDelegationFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
runtimeID := handlerTestRuntimeID(t)
|
|
|
|
// The seeded workspace agent stands in for the autopilot-dispatched leader
|
|
// that authors the delegation comment (distinct from the mentioned target).
|
|
var leaderID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1
|
|
`, testWorkspaceID).Scan(&leaderID); err != nil {
|
|
t.Fatalf("load seeded agent: %v", err)
|
|
}
|
|
|
|
// A member-created autopilot; assignee is the target agent (any valid agent
|
|
// satisfies the assignee reference).
|
|
var autopilotID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id)
|
|
VALUES ($1, 'MUL-4857 delegation', $2, 'create_issue', 'member', $3) RETURNING id
|
|
`, testWorkspaceID, targetAgentID, autopilotCreatorUserID).Scan(&autopilotID); err != nil {
|
|
t.Fatalf("create autopilot: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) })
|
|
|
|
// Next per-workspace issue number (default 0 would trip uq_issue_workspace_number).
|
|
var number int
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
|
|
WHERE id = $1 RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&number); err != nil {
|
|
t.Fatalf("next issue number: %v", err)
|
|
}
|
|
|
|
// The issue mirrors an autopilot-created issue (creator is the dispatched
|
|
// leader agent; provenance is origin_type=autopilot + origin_id). When
|
|
// originType is not "autopilot" the issue carries no origin, so no creator
|
|
// can be recovered even from a perfectly-lineaged task.
|
|
var originTypeArg, originIDArg any
|
|
if originType == "autopilot" {
|
|
originTypeArg = "autopilot"
|
|
originIDArg = autopilotID
|
|
}
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id, number, origin_type, origin_id)
|
|
VALUES ($1, 'agent', $2, 'MUL-4857 delegation issue', 'agent', $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, leaderID, number, originTypeArg, originIDArg).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// The leader's dispatch task, running ON this issue. In create_issue mode the
|
|
// leader task is enqueued through the ordinary issue-assignment path, so it
|
|
// carries NO autopilot_run_id — the lineage that matters is agent + issue.
|
|
// Unattributed (originator NULL) like a schedule/webhook autopilot run.
|
|
var leaderTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
|
|
VALUES ($1, $2, $3, 'running', 0) RETURNING id
|
|
`, leaderID, runtimeID, issueID).Scan(&leaderTaskID); err != nil {
|
|
t.Fatalf("create leader task: %v", err)
|
|
}
|
|
|
|
// The delegation comment: authored by the leader agent, mentioning the target,
|
|
// with source_task_id pointing back at the leader's running task (the lineage
|
|
// the reconcile/edit path reads).
|
|
var commentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content, source_task_id)
|
|
VALUES ($1, $2, 'agent', $3, $4, $5) RETURNING id
|
|
`, testWorkspaceID, issueID, leaderID, "[@Worker](mention://agent/"+targetAgentID+") please take this", leaderTaskID).Scan(&commentID); err != nil {
|
|
t.Fatalf("create comment: %v", err)
|
|
}
|
|
|
|
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
|
|
if err != nil {
|
|
t.Fatalf("load issue: %v", err)
|
|
}
|
|
comment, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(commentID))
|
|
if err != nil {
|
|
t.Fatalf("load comment: %v", err)
|
|
}
|
|
return autopilotDelegationFixture{
|
|
Issue: issue,
|
|
LeaderAgentID: leaderID,
|
|
LeaderTaskID: leaderTaskID,
|
|
Comment: comment,
|
|
AutopilotID: autopilotID,
|
|
RuntimeID: runtimeID,
|
|
}
|
|
}
|
|
|
|
// setCommentSourceTask rewrites the fixture comment's source_task_id and reloads
|
|
// the row, so a test can point the lineage at a foreign task (or clear it).
|
|
func setCommentSourceTask(t *testing.T, fx *autopilotDelegationFixture, sourceTaskID any) {
|
|
t.Helper()
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`UPDATE comment SET source_task_id = $1 WHERE id = $2`, sourceTaskID, uuidToString(fx.Comment.ID)); err != nil {
|
|
t.Fatalf("rewrite comment source_task_id: %v", err)
|
|
}
|
|
c, err := testHandler.Queries.GetComment(context.Background(), fx.Comment.ID)
|
|
if err != nil {
|
|
t.Fatalf("reload comment: %v", err)
|
|
}
|
|
fx.Comment = c
|
|
}
|
|
|
|
// seedTaskOnIssue inserts a running task for the given agent on the given issue
|
|
// and returns its id, for building foreign-lineage negative cases.
|
|
func seedTaskOnIssue(t *testing.T, agentID, issueID, runtimeID string) string {
|
|
t.Helper()
|
|
var taskID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
|
|
VALUES ($1, $2, $3, 'running', 0) RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("seed task on issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
return taskID
|
|
}
|
|
|
|
// TestAutopilotDelegationAuthority_LineageBinding is the MUL-4857 fix, guarded by
|
|
// the review's confused-deputy finding: an unattributed autopilot run may borrow
|
|
// its autopilot creator's invoke rights to delegate mid-chain, but ONLY when the
|
|
// speaking task's lineage is verified against THIS issue — never from the issue's
|
|
// autopilot provenance plus an empty originator alone.
|
|
func TestAutopilotDelegationAuthority_LineageBinding(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// agentID: private (default) agent owned by ownerID. plainMemberID: unrelated.
|
|
agentID, ownerID, plainMemberID := privateAgentTestFixture(t)
|
|
|
|
// authorityFor resolves the delegation authority the reconcile/edit path uses,
|
|
// straight from the persisted comment's source_task_id lineage.
|
|
authorityFor := func(fx autopilotDelegationFixture) string {
|
|
return testHandler.autopilotDelegationAuthorityFromComment(ctx, fx.Issue, fx.Comment)
|
|
}
|
|
// mentionTriggersTarget wires that resolved authority into the trigger compute
|
|
// exactly as the live paths do, and reports whether the private target fires.
|
|
mentionTriggersTarget := func(fx autopilotDelegationFixture) bool {
|
|
triggers, _ := testHandler.computeCommentAgentTriggers(
|
|
ctx, fx.Issue, fx.Comment.Content, nil, "agent", fx.LeaderAgentID,
|
|
commentTriggerComputeOptions{
|
|
ExcludeTriggerCommentID: fx.Comment.ID,
|
|
AutopilotDelegationAuthorityUserID: authorityFor(fx),
|
|
},
|
|
)
|
|
for _, tr := range triggers {
|
|
if uuidToString(tr.Agent.ID) == agentID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
t.Run("verified lineage + creator owns target -> triggers", func(t *testing.T) {
|
|
fx := newAutopilotDelegationFixture(t, agentID, ownerID, "autopilot")
|
|
if got := authorityFor(fx); got != ownerID {
|
|
t.Fatalf("delegation authority = %q, want autopilot creator %q", got, ownerID)
|
|
}
|
|
if !mentionTriggersTarget(fx) {
|
|
t.Fatal("expected the private agent to be triggered via the lineage-verified autopilot-creator authority")
|
|
}
|
|
})
|
|
|
|
t.Run("creator cannot invoke target -> still denied", func(t *testing.T) {
|
|
// Lineage is perfect but the creator (plainMemberID) is neither the target's
|
|
// owner nor on any allow-list: the authority resolves but the gate denies.
|
|
fx := newAutopilotDelegationFixture(t, agentID, plainMemberID, "autopilot")
|
|
if got := authorityFor(fx); got != plainMemberID {
|
|
t.Fatalf("delegation authority = %q, want %q", got, plainMemberID)
|
|
}
|
|
if mentionTriggersTarget(fx) {
|
|
t.Fatal("autopilot creator without invoke rights must not reach a private agent")
|
|
}
|
|
})
|
|
|
|
t.Run("non-autopilot issue -> no authority", func(t *testing.T) {
|
|
fx := newAutopilotDelegationFixture(t, agentID, ownerID, "")
|
|
if got := authorityFor(fx); got != "" {
|
|
t.Fatalf("non-autopilot issue must resolve no authority, got %q", got)
|
|
}
|
|
if mentionTriggersTarget(fx) {
|
|
t.Fatal("a non-autopilot unattributed run must not invoke a private agent")
|
|
}
|
|
})
|
|
|
|
t.Run("missing source task -> no authority", func(t *testing.T) {
|
|
// The previous fix's blind spot: an unattributed comment with no verifiable
|
|
// lineage (source_task_id NULL) must NOT inherit the creator's authority.
|
|
fx := newAutopilotDelegationFixture(t, agentID, ownerID, "autopilot")
|
|
setCommentSourceTask(t, &fx, nil)
|
|
if got := authorityFor(fx); got != "" {
|
|
t.Fatalf("comment without source_task_id must resolve no authority, got %q", got)
|
|
}
|
|
if mentionTriggersTarget(fx) {
|
|
t.Fatal("a comment with no verifiable task lineage must not borrow creator authority")
|
|
}
|
|
})
|
|
|
|
t.Run("source task on a different issue -> no authority", func(t *testing.T) {
|
|
// Confused-deputy: a run working on ANOTHER issue comments here. Its task's
|
|
// issue_id != this issue, so it cannot borrow this autopilot's authority even
|
|
// though its agent authored the comment.
|
|
fx := newAutopilotDelegationFixture(t, agentID, ownerID, "autopilot")
|
|
other := newAutopilotDelegationFixture(t, agentID, ownerID, "autopilot")
|
|
foreignTask := seedTaskOnIssue(t, fx.LeaderAgentID, uuidToString(other.Issue.ID), fx.RuntimeID)
|
|
setCommentSourceTask(t, &fx, foreignTask)
|
|
if got := authorityFor(fx); got != "" {
|
|
t.Fatalf("cross-issue source task must resolve no authority, got %q", got)
|
|
}
|
|
if mentionTriggersTarget(fx) {
|
|
t.Fatal("a task from a different issue must not borrow this autopilot's creator authority")
|
|
}
|
|
})
|
|
|
|
t.Run("author is not the source task's agent -> no authority", func(t *testing.T) {
|
|
// The comment author is the leader, but its source task belongs to a
|
|
// different agent (the target). Author/agent mismatch fails closed.
|
|
fx := newAutopilotDelegationFixture(t, agentID, ownerID, "autopilot")
|
|
mismatchTask := seedTaskOnIssue(t, agentID, uuidToString(fx.Issue.ID), fx.RuntimeID)
|
|
setCommentSourceTask(t, &fx, mismatchTask)
|
|
if got := authorityFor(fx); got != "" {
|
|
t.Fatalf("author != task agent must resolve no authority, got %q", got)
|
|
}
|
|
if mentionTriggersTarget(fx) {
|
|
t.Fatal("a source task owned by a different agent must not confer authority on the comment author")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCreateComment_AutopilotLeaderMentionEnqueuesPrivateWorker is the MUL-4857
|
|
// end-to-end: the autopilot-dispatched leader posts an @mention delegation on the
|
|
// autopilot-created issue through the real HTTP CreateComment surface (X-Agent-ID
|
|
// + X-Task-ID), and the mentioned DEFAULT-private worker is actually enqueued —
|
|
// keyed on the autopilot creator's invoke rights, resolved from the request's
|
|
// trusted X-Task-ID lineage. This exercises handler -> comment persistence ->
|
|
// trigger -> enqueue, not just the compute function.
|
|
func TestCreateComment_AutopilotLeaderMentionEnqueuesPrivateWorker(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Private worker owned by ownerID; the autopilot is created by that same owner
|
|
// so the creator legitimately owns the worker.
|
|
workerID, ownerID, _ := privateAgentTestFixture(t)
|
|
fx := newAutopilotDelegationFixture(t, workerID, ownerID, "autopilot")
|
|
issueID := uuidToString(fx.Issue.ID)
|
|
|
|
// The leader posts the mention comment in its agent identity. resolveActor
|
|
// trusts the header pair because fx.LeaderTaskID belongs to the leader agent.
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "[@Worker](mention://agent/" + workerID + ") please handle",
|
|
})
|
|
r.Header.Set("X-Agent-ID", fx.LeaderAgentID)
|
|
r.Header.Set("X-Task-ID", fx.LeaderTaskID)
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("leader mention CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var workerTasks int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, workerID).Scan(&workerTasks); err != nil {
|
|
t.Fatalf("count worker tasks: %v", err)
|
|
}
|
|
if workerTasks != 1 {
|
|
t.Fatalf("expected the private worker to be enqueued once via autopilot-creator authority, got %d queued tasks", workerTasks)
|
|
}
|
|
|
|
// The enqueued run must stay UNATTRIBUTED: the creator authority is used for
|
|
// the gate only, never written onto the delegated task's originator (MUL-4302).
|
|
var workerOriginatorValid bool
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT originator_user_id IS NOT NULL FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, workerID).Scan(&workerOriginatorValid); err != nil {
|
|
t.Fatalf("read worker originator: %v", err)
|
|
}
|
|
if workerOriginatorValid {
|
|
t.Fatal("the delegated worker task must remain unattributed; the creator authority is authorization-only")
|
|
}
|
|
}
|
|
|
|
// nextWorkspaceIssueNumber advances and returns the test workspace's issue
|
|
// counter so a directly-inserted issue does not collide on uq_issue_workspace_number.
|
|
func nextWorkspaceIssueNumber(t *testing.T) int {
|
|
t.Helper()
|
|
var number int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
UPDATE workspace
|
|
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
|
|
WHERE id = $1 RETURNING issue_counter
|
|
`, testWorkspaceID).Scan(&number); err != nil {
|
|
t.Fatalf("next issue number: %v", err)
|
|
}
|
|
return number
|
|
}
|
|
|
|
// seedBareIssue inserts a plain (non-autopilot) issue authored by the given agent
|
|
// and returns its id, for building a cross-issue editing context.
|
|
func seedBareIssue(t *testing.T, creatorAgentID string) string {
|
|
t.Helper()
|
|
var issueID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, number)
|
|
VALUES ($1, 'agent', $2, 'MUL-4857 unrelated issue', $3) RETURNING id
|
|
`, testWorkspaceID, creatorAgentID, nextWorkspaceIssueNumber(t)).Scan(&issueID); err != nil {
|
|
t.Fatalf("seed bare issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
return issueID
|
|
}
|
|
|
|
// seedCompletedTaskOnIssueBefore inserts a completed task for the agent on the
|
|
// issue with a created_at safely before any comment made during the test, so the
|
|
// completion-reconcile pass (ListReconcilableCommentsForIssueSince) picks those
|
|
// comments up.
|
|
func seedCompletedTaskOnIssueBefore(t *testing.T, agentID, issueID, runtimeID string) string {
|
|
t.Helper()
|
|
var taskID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, created_at)
|
|
VALUES ($1, $2, $3, 'completed', 0, now() - interval '1 hour') RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("seed completed task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
return taskID
|
|
}
|
|
|
|
// seedLeaderPlainComment inserts a plain (no-mention) agent comment stamped with
|
|
// the given source_task_id, so a test can later edit it to add a mention.
|
|
func seedLeaderPlainComment(t *testing.T, issueID, leaderID, sourceTaskID string) string {
|
|
t.Helper()
|
|
var commentID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content, source_task_id)
|
|
VALUES ($1, $2, 'agent', $3, 'starting on this', $4) RETURNING id
|
|
`, testWorkspaceID, issueID, leaderID, sourceTaskID).Scan(&commentID); err != nil {
|
|
t.Fatalf("seed plain comment: %v", err)
|
|
}
|
|
return commentID
|
|
}
|
|
|
|
// TestReconcileCommentsOnCompletion_AutopilotDelegationRestoresAuthority is the
|
|
// MUL-4857 must-fix #1 (review round 2): when the mentioned target was BUSY at
|
|
// delegation time, the delegation is deferred to the target's completion
|
|
// reconcile. That replay must restore the SAME autopilot-creator authority from
|
|
// the comment's source_task_id — otherwise the unattributed autopilot chain's
|
|
// follow-up is gate-denied again and the delegation is silently lost.
|
|
func TestReconcileCommentsOnCompletion_AutopilotDelegationRestoresAuthority(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
workerID, ownerID, plainMemberID := privateAgentTestFixture(t)
|
|
|
|
// followUps drives the reconcile: an autopilot delegation comment mentions the
|
|
// busy private worker, the worker's task then completes, and we count the
|
|
// follow-up tasks the completion reconcile enqueues for it.
|
|
followUps := func(t *testing.T, creatorUserID string) (string, int) {
|
|
fx := newAutopilotDelegationFixture(t, workerID, creatorUserID, "autopilot")
|
|
issueID := uuidToString(fx.Issue.ID)
|
|
workerTaskID := seedCompletedTaskOnIssueBefore(t, workerID, issueID, fx.RuntimeID)
|
|
workerTask, err := testHandler.Queries.GetAgentTask(ctx, util.MustParseUUID(workerTaskID))
|
|
if err != nil {
|
|
t.Fatalf("load worker task: %v", err)
|
|
}
|
|
testHandler.reconcileCommentsOnCompletion(ctx, &workerTask)
|
|
var queued int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, workerID).Scan(&queued); err != nil {
|
|
t.Fatalf("count follow-ups: %v", err)
|
|
}
|
|
return issueID, queued
|
|
}
|
|
|
|
t.Run("creator owns busy target -> one unattributed follow-up", func(t *testing.T) {
|
|
issueID, queued := followUps(t, ownerID)
|
|
if queued != 1 {
|
|
t.Fatalf("expected exactly 1 reconcile follow-up for the freed worker, got %d", queued)
|
|
}
|
|
var originatorValid bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT originator_user_id IS NOT NULL FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, workerID).Scan(&originatorValid); err != nil {
|
|
t.Fatalf("read follow-up originator: %v", err)
|
|
}
|
|
if originatorValid {
|
|
t.Fatal("the reconcile follow-up must stay unattributed; creator authority is authorization-only")
|
|
}
|
|
})
|
|
|
|
t.Run("creator without rights -> no follow-up", func(t *testing.T) {
|
|
if _, queued := followUps(t, plainMemberID); queued != 0 {
|
|
t.Fatalf("a creator without invoke rights must not spawn a reconcile follow-up, got %d", queued)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestUpdateComment_AutopilotAuthorityReStampedToEditingTask is the MUL-4857
|
|
// must-fix #2 (review round 2): an edit is a NEW action, so it must judge (and
|
|
// persist) authority by the CURRENT editing task, not the comment's original
|
|
// authoring task. A same-issue edit keeps the autopilot-creator authority; a
|
|
// cross-issue edit re-stamps source_task_id to NULL and fails closed, so it can
|
|
// never borrow the old autopilot run's authority (preview and save now agree).
|
|
func TestUpdateComment_AutopilotAuthorityReStampedToEditingTask(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
workerID, ownerID, _ := privateAgentTestFixture(t)
|
|
|
|
editAddingMention := func(t *testing.T, editTaskID, commentID, issueID string, fx autopilotDelegationFixture) {
|
|
w := httptest.NewRecorder()
|
|
r := newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
|
|
"content": "[@Worker](mention://agent/" + workerID + ") please take this",
|
|
})
|
|
r.Header.Set("X-Agent-ID", fx.LeaderAgentID)
|
|
r.Header.Set("X-Task-ID", editTaskID)
|
|
r = withURLParam(r, "commentId", commentID)
|
|
testHandler.UpdateComment(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateComment: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
countQueued := func(t *testing.T, issueID string) int {
|
|
var n int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, workerID).Scan(&n); err != nil {
|
|
t.Fatalf("count queued: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
t.Run("same-issue edit keeps creator authority and triggers", func(t *testing.T) {
|
|
fx := newAutopilotDelegationFixture(t, workerID, ownerID, "autopilot")
|
|
issueID := uuidToString(fx.Issue.ID)
|
|
commentID := seedLeaderPlainComment(t, issueID, fx.LeaderAgentID, fx.LeaderTaskID)
|
|
// Edit from the leader's own task on THIS autopilot issue.
|
|
editAddingMention(t, fx.LeaderTaskID, commentID, issueID, fx)
|
|
if got := countQueued(t, issueID); got != 1 {
|
|
t.Fatalf("same-issue edit should enqueue the private worker once, got %d", got)
|
|
}
|
|
})
|
|
|
|
t.Run("cross-issue edit re-stamps source task to NULL and fails closed", func(t *testing.T) {
|
|
fx := newAutopilotDelegationFixture(t, workerID, ownerID, "autopilot")
|
|
issueID := uuidToString(fx.Issue.ID)
|
|
commentID := seedLeaderPlainComment(t, issueID, fx.LeaderAgentID, fx.LeaderTaskID)
|
|
// The leader now runs an UNATTRIBUTED task on an unrelated issue and edits
|
|
// its old autopilot comment from there.
|
|
otherIssueID := seedBareIssue(t, fx.LeaderAgentID)
|
|
crossTaskID := seedTaskOnIssue(t, fx.LeaderAgentID, otherIssueID, fx.RuntimeID)
|
|
editAddingMention(t, crossTaskID, commentID, issueID, fx)
|
|
if got := countQueued(t, issueID); got != 0 {
|
|
t.Fatalf("cross-issue edit must not borrow the old autopilot authority; got %d queued", got)
|
|
}
|
|
var sourceTaskValid bool
|
|
if err := testPool.QueryRow(ctx, `SELECT source_task_id IS NOT NULL FROM comment WHERE id = $1`, commentID).Scan(&sourceTaskValid); err != nil {
|
|
t.Fatalf("read comment source_task_id: %v", err)
|
|
}
|
|
if sourceTaskValid {
|
|
t.Fatal("a cross-issue edit must clear source_task_id so preview, save, and reconcile all fail closed")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCreateComment_AutopilotWorkerResultWakesSquadLeader locks the review's
|
|
// accepted behavior: effectiveInvoker() lets the autopilot-creator authority reach
|
|
// the plain (non-@mention) assigned-squad-leader fallback too, so a worker's
|
|
// result comment on the autopilot issue can still wake the private squad leader
|
|
// and close the leader -> worker -> leader loop under the autopilot chain.
|
|
func TestCreateComment_AutopilotWorkerResultWakesSquadLeader(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := handlerTestRuntimeID(t)
|
|
|
|
// Private squad leader owned by the autopilot creator; the worker is a distinct
|
|
// seeded agent so the leader self-trigger guard does not apply.
|
|
leaderID, ownerID, _ := privateAgentTestFixture(t)
|
|
var workerID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent WHERE workspace_id = $1 AND id <> $2 ORDER BY created_at ASC LIMIT 1
|
|
`, testWorkspaceID, leaderID).Scan(&workerID); err != nil {
|
|
t.Fatalf("load worker agent: %v", err)
|
|
}
|
|
|
|
var autopilotID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id)
|
|
VALUES ($1, 'MUL-4857 squad', $2, 'create_issue', 'member', $3) RETURNING id
|
|
`, testWorkspaceID, leaderID, ownerID).Scan(&autopilotID); err != nil {
|
|
t.Fatalf("create autopilot: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) })
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'MUL-4857 Squad', '', $2, $3) RETURNING id
|
|
`, testWorkspaceID, leaderID, ownerID).Scan(&squadID); err != nil {
|
|
t.Fatalf("create squad: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID) })
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id, number, origin_type, origin_id)
|
|
VALUES ($1, 'agent', $2, 'MUL-4857 squad issue', 'squad', $3, $4, 'autopilot', $5) RETURNING id
|
|
`, testWorkspaceID, leaderID, squadID, nextWorkspaceIssueNumber(t), autopilotID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create squad issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// The worker is running an unattributed task on this autopilot issue.
|
|
workerTaskID := seedTaskOnIssue(t, workerID, issueID, runtimeID)
|
|
|
|
// The worker posts a PLAIN result comment (no @mention) via HTTP.
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "done — pushed the change",
|
|
})
|
|
r.Header.Set("X-Agent-ID", workerID)
|
|
r.Header.Set("X-Task-ID", workerTaskID)
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("worker result CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var leaderTasks int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued' AND is_leader_task = TRUE
|
|
`, issueID, leaderID).Scan(&leaderTasks); err != nil {
|
|
t.Fatalf("count leader tasks: %v", err)
|
|
}
|
|
if leaderTasks != 1 {
|
|
t.Fatalf("expected the private squad leader to be woken once via autopilot-creator authority, got %d", leaderTasks)
|
|
}
|
|
}
|
|
|
|
// TestUpdateComment_AdminEditOfAgentCommentClearsStaleLineage is the MUL-4857
|
|
// must-fix (review round 3): a workspace admin may EDIT another author's comment,
|
|
// but that manage right is NOT an invoke right over the author's private agents
|
|
// (canInvokeAgent is deny-by-default for private agents — no admin bypass). When an
|
|
// admin edits an autopilot Agent's comment to add a private @mention while the
|
|
// target is busy, the immediate save is blocked on the admin's own member identity,
|
|
// AND the persisted source_task_id MUST be cleared. Otherwise the deferred
|
|
// completion-reconcile — which routes the comment under its ORIGINAL agent author on
|
|
// the unattributed autopilot chain — would read the stale lineage and resurrect the
|
|
// autopilot creator's authority once the target frees up.
|
|
func TestUpdateComment_AdminEditOfAgentCommentClearsStaleLineage(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
workerID, ownerID, _ := privateAgentTestFixture(t)
|
|
|
|
// The autopilot is created by ownerID, who owns the private worker — so the
|
|
// ORIGINAL agent lineage genuinely carries invoke authority. That is precisely
|
|
// the authority an admin edit must not be able to borrow.
|
|
fx := newAutopilotDelegationFixture(t, workerID, ownerID, "autopilot")
|
|
issueID := uuidToString(fx.Issue.ID)
|
|
// Neutralise the fixture's own (valid) mention comment so the ONLY comment whose
|
|
// reconcile fate is under test is the admin-edited one below.
|
|
setCommentSourceTask(t, &fx, nil)
|
|
|
|
// A plain (no-mention) leader comment stamped with the leader's real task
|
|
// lineage — the comment the admin edits to inject the mention.
|
|
commentID := seedLeaderPlainComment(t, issueID, fx.LeaderAgentID, fx.LeaderTaskID)
|
|
|
|
// A workspace admin who is NEITHER the worker owner nor the comment author.
|
|
adminID := createPermissionTestAdmin(t, "mul4857-edit-admin@multica.test")
|
|
|
|
countQueued := func() int {
|
|
var n int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, workerID).Scan(&n); err != nil {
|
|
t.Fatalf("count queued: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// The admin edits the leader's comment to add the private @Worker mention.
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(adminID, http.MethodPut, "/api/comments/"+commentID, map[string]any{
|
|
"content": "[@Worker](mention://agent/" + workerID + ") please take this",
|
|
})
|
|
r = withURLParam(r, "commentId", commentID)
|
|
testHandler.UpdateComment(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("admin UpdateComment: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Immediate save is judged on the admin's member identity, which holds no invoke
|
|
// right over the private worker — nothing is enqueued.
|
|
if got := countQueued(); got != 0 {
|
|
t.Fatalf("admin edit must be blocked immediately (no invoke right over a private agent); got %d queued", got)
|
|
}
|
|
|
|
// The stale autopilot lineage MUST be cleared so the deferred reconcile fails closed.
|
|
var sourceTaskValid bool
|
|
if err := testPool.QueryRow(ctx, `SELECT source_task_id IS NOT NULL FROM comment WHERE id = $1`, commentID).Scan(&sourceTaskValid); err != nil {
|
|
t.Fatalf("read comment source_task_id: %v", err)
|
|
}
|
|
if sourceTaskValid {
|
|
t.Fatal("an admin edit of an agent comment must clear source_task_id so the deferred reconcile cannot borrow the original autopilot creator authority")
|
|
}
|
|
|
|
// The busy worker now completes: the completion reconcile routes the comment
|
|
// under its original agent author (unattributed autopilot chain). With the
|
|
// lineage cleared it must NOT resurrect the creator authority or enqueue a
|
|
// follow-up. (Without the fix this reconcile would enqueue exactly one.)
|
|
workerTaskID := seedCompletedTaskOnIssueBefore(t, workerID, issueID, fx.RuntimeID)
|
|
workerTask, err := testHandler.Queries.GetAgentTask(ctx, util.MustParseUUID(workerTaskID))
|
|
if err != nil {
|
|
t.Fatalf("load worker task: %v", err)
|
|
}
|
|
testHandler.reconcileCommentsOnCompletion(ctx, &workerTask)
|
|
if got := countQueued(); got != 0 {
|
|
t.Fatalf("completion reconcile must not borrow the stale autopilot authority after an admin edit; got %d queued", got)
|
|
}
|
|
}
|