diff --git a/server/internal/service/resolve_originator_test.go b/server/internal/service/resolve_originator_test.go index 3e71f43329..cd27a789a7 100644 --- a/server/internal/service/resolve_originator_test.go +++ b/server/internal/service/resolve_originator_test.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + "github.com/multica-ai/multica/server/internal/attribution" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/featureflags" "github.com/multica-ai/multica/server/internal/runtimeapps" @@ -268,6 +269,58 @@ func TestResolveOriginatorFromTriggerComment_AgentAuthoredInheritsFromParent(t * } } +// TestAttributionForIssueTask_SystemCommentFallsThroughToIssueProvenance covers +// the Stage-completion cascade (MUL-4302; raised by Bohan). Closing the last +// sub-issue in a Stage wakes the parent's assignee agent through a SYSTEM-authored +// child-done comment that threads no actor. That system comment carries no human, +// so attribution must NOT stop at it (which would degrade to owner_fallback, the +// agent's own owner) — it must fall through to the PARENT issue's own provenance +// and attribute to the human who caused the parent issue to exist. Here the parent +// was created by an agent on behalf of userID (agent_create origin), so the woken +// run is delegation-accountable to userID. +func TestAttributionForIssueTask_SystemCommentFallsThroughToIssueProvenance(t *testing.T) { + pool := newResolveOriginatorPool(t) + _, _, parentTaskID, userID := seedOriginatorFanout(t, pool) + ctx := context.Background() + + // Plant a system-authored comment on the seed's issue (mirrors the child-done + // comment). Its issue/workspace are derived from the origin task's issue. + var issueIDStr, workspaceIDStr string + if err := pool.QueryRow(ctx, ` + SELECT i.id::text, i.workspace_id::text + FROM agent_task_queue t JOIN issue i ON i.id = t.issue_id + WHERE t.id = $1 + `, util.UUIDToString(parentTaskID)).Scan(&issueIDStr, &workspaceIDStr); err != nil { + t.Fatalf("load issue/workspace: %v", err) + } + var systemCommentIDStr string + if err := pool.QueryRow(ctx, ` + INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type) + VALUES ($1, $2, 'system', '00000000-0000-0000-0000-000000000000', 'child done', 'system') + RETURNING id + `, issueIDStr, workspaceIDStr).Scan(&systemCommentIDStr); err != nil { + t.Fatalf("seed system comment: %v", err) + } + systemCommentID := util.MustParseUUID(systemCommentIDStr) + + svc := &TaskService{Queries: db.New(pool)} + // Parent issue created by an agent on behalf of userID (agent_create origin). + issue := db.Issue{ + CreatorType: "agent", + OriginType: pgtype.Text{String: "agent_create", Valid: true}, + OriginID: parentTaskID, + } + + got := svc.attributionForIssueTask(ctx, issue, systemCommentID, attribution.SourceDelegation, pgtype.UUID{}) + if got.Source != attribution.SourceDelegation { + t.Fatalf("source = %q, want delegation (system comment must fall through to issue provenance, not owner_fallback)", got.Source) + } + if !got.UserID.Valid || got.UserID.Bytes != userID.Bytes { + t.Errorf("accountable = %s, want %s (the human who caused the parent issue to exist)", + util.UUIDToString(got.UserID), util.UUIDToString(userID)) + } +} + // TestResolveOriginatorForIssueTask_MemberCreatedNoComment covers direct issue // assignment/creation: there is no trigger comment to inspect, but the issue's // human creator is still the run originator for Composio overlay gating. diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 047024c4ed..2847e9c8ec 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -298,8 +298,16 @@ func (s *TaskService) attributionFromTriggerComment(ctx context.Context, comment if err != nil { return attribution.Result{Source: attribution.SourceUnattributed} } + return s.attributionFromComment(ctx, comment, agentAuthoredSource) +} + +// attributionFromComment classifies a run from an already-loaded trigger comment, +// so a caller that already has the row (e.g. to inspect author_type) does not +// re-read it. Kept byte-identical to the inline logic attributionFromTriggerComment +// used before, so authorization behavior is unchanged. +func (s *TaskService) attributionFromComment(ctx context.Context, comment db.Comment, agentAuthoredSource attribution.Source) attribution.Result { facts := attribution.CommentFacts{ - CommentID: commentID, + CommentID: comment.ID, AuthorType: comment.AuthorType, AuthorID: comment.AuthorID, } @@ -347,7 +355,28 @@ func (s *TaskService) attributionForIssueTask(ctx context.Context, issue db.Issu return attribution.ClassifyDirect(attribution.DirectFacts{IssueID: issue.ID, ActorUserID: actorUserID}) } if triggerCommentID.Valid { - return s.attributionFromTriggerComment(ctx, triggerCommentID, agentAuthoredSource) + if s == nil || s.Queries == nil { + return attribution.Result{Source: attribution.SourceUnattributed} + } + comment, err := s.Queries.GetComment(ctx, triggerCommentID) + if err != nil { + return attribution.Result{Source: attribution.SourceUnattributed} + } + // A member/agent trigger comment resolves the human (direct_human / delegation + // / comment_source). A SYSTEM-authored comment — today the Stage-completion + // child-done comment (issue_child_done.go), which wakes the parent assignee + // and threads no actor — carries no human and is not part of any delegation + // chain. Classifying it would degrade straight to owner_fallback (the agent's + // own owner), which is wrong for a Stage cascade: the woken run should be + // accountable to whoever caused the PARENT issue to exist. So for a system + // comment we skip the comment branch and fall through to the parent issue's + // own provenance below — the same creator / agent_create-origin / + // autopilot-origin chain a direct enqueue resolves — reaching owner_fallback + // only if that provenance itself has no human (MUL-4302; raised by Bohan on + // the stage-cascade fallback). + if comment.AuthorType != "system" { + return s.attributionFromComment(ctx, comment, agentAuthoredSource) + } } // Autopilot-origin issues (origin_id is the autopilot id) from a schedule / // webhook trigger: no human authorized the run, so originator stays NULL, but it