fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302)

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 <github@multica.ai>
This commit is contained in:
J
2026-07-10 13:15:50 +08:00
parent 97b06d15ff
commit b8096107da
2 changed files with 84 additions and 2 deletions

View File

@@ -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.

View File

@@ -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