mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user