From b8096107dae42c2eab6edb6fa56dfdc606969dac Mon Sep 17 00:00:00 2001 From: J Date: Fri, 10 Jul 2026 13:15:50 +0800 Subject: [PATCH] fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When closing the last sub-issue in a Stage wakes the parent's assignee agent, the run was enqueued via a system-authored child-done comment with no actor, which the resolver classified as unattributed and then degraded to owner_fallback (the agent's own owner). That is the wrong accountable human: the woken run should be accountable to whoever caused the parent issue to exist. attributionForIssueTask now detects a system-authored trigger comment and falls through to the parent issue's own provenance — the same creator / agent_create-origin / autopilot-origin chain a direct enqueue resolves (so an agent-decomposed parent attributes via delegation to the human who drove it; a member-created parent to that member; an autopilot parent to the rule publisher). owner_fallback is now only the last resort when the parent provenance itself has no human. - Extract attributionFromComment so attributionForIssueTask can inspect author_type without a second GetComment; authorization resolution stays byte-identical. - Add a DB-backed test asserting a system child-done comment resolves to the parent issue's origin human (delegation), not owner_fallback. Co-authored-by: multica-agent --- .../service/resolve_originator_test.go | 53 +++++++++++++++++++ server/internal/service/task.go | 33 +++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) 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