Files
multica/server/internal/handler/comment_merge_failclosed_test.go
Bohan Jiang 49fd6cd08a fix(handler,service): return 409/coalesced instead of 500 on duplicate-key violations (MUL-5285) (#5958)
Two unique-constraint violations surfaced as an HTTP 500 with the raw Postgres
constraint name leaked to the caller (#5914).

- UpdateAgent now mirrors CreateAgent: a 23505 / agent_workspace_name_unique
  violation returns a clean 409 instead of a 500 whose body leaked the constraint
  name. The (workspace_id, name) constraint does not exclude archived agents, so
  renaming into a name still held by an archived agent hit exactly this path.
- The mention enqueue detects the idx_one_pending_task_per_issue_agent violation,
  returns a bare typed sentinel (no driver text), and logs the benign race at
  debug instead of error.

Review then hardened the same duplicate-enqueue path so it never reports an
outcome it cannot back: coalesced only after an atomic head-scoped merge,
deferred only when an active task's reconcile will replay the comment, queued
only after a fresh enqueue, and an honest internal_error otherwise. Head scoping
(TEN-356) is preserved throughout, planned-id registration excludes queued tasks
so re-attribution stays atomic (MUL-4302), and a blocked completion replay is
handed on rather than discarded.

One shape remains best-effort — a different-head queued blocker can neither cover
the comment nor accept it without violating TEN-356 — and is logged at error;
that drop predates this change. Tracked in #5985.

No migration, foreign key, or cascade.

MUL-5285

Fixes #5914
2026-07-27 14:26:23 +08:00

139 lines
6.4 KiB
Go

package handler
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestMergeCommentIntoPendingTask_FailClosedKeepsOriginalSnapshot is Elon's must-fix
// regression exercising the REAL merge path (mergeCommentIntoPendingTask), not just the
// service helper. On a fail-closed workspace, folding a comment that resolves to no
// precise human must REFUSE the merge and leave the queued task's entire attribution
// snapshot (trigger comment, originator, accountable, source) intact — never re-stamped
// to a degraded owner_fallback. A fail-open control proves the same comment DOES merge
// (owner_fallback) when the workspace permits the degrade (MUL-4302).
func TestMergeCommentIntoPendingTask_FailClosedKeepsOriginalSnapshot(t *testing.T) {
ctx := context.Background()
agentID := createWebhookTestAgent(t, "MergeFailClosed Agent")
var runtimeID string
if err := testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, agentID).Scan(&runtimeID); err != nil {
t.Fatalf("load runtime: %v", err)
}
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, creator_type, creator_id, assignee_type, assignee_id, priority)
VALUES ($1, 'merge fail-closed', 'member', $2, 'agent', $3, 'medium') RETURNING id`,
testWorkspaceID, testUserID, agentID).Scan(&issueID); err != nil {
t.Fatalf("seed issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
// cidA: the precise member comment the queued task is attributed to.
var cidA string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, 'please do X') RETURNING id`,
issueID, testWorkspaceID, testUserID).Scan(&cidA); err != nil {
t.Fatalf("seed comment A: %v", err)
}
// cidB: an agent comment with no source-task lineage → resolves to no precise human.
var cidB string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'agent', $3, 'autonomous follow-up') RETURNING id`,
issueID, testWorkspaceID, agentID).Scan(&cidB); err != nil {
t.Fatalf("seed comment B: %v", err)
}
// A QUEUED task with a fully precise snapshot triggered by cidA.
if _, err := testPool.Exec(ctx, `
INSERT INTO agent_task_queue
(agent_id, runtime_id, issue_id, trigger_comment_id, status, priority,
originator_user_id, accountable_user_id, originator_source,
trigger_evidence_kind, trigger_evidence_ref_id)
VALUES ($1, $2, $3, $4, 'queued', 0, $5, $5, 'comment_source', 'comment', $4)`,
agentID, runtimeID, issueID, cidA, testUserID); err != nil {
t.Fatalf("seed queued task: %v", err)
}
agent, err := testHandler.Queries.GetAgent(ctx, parseUUID(agentID))
if err != nil {
t.Fatalf("get agent: %v", err)
}
issue := db.Issue{ID: parseUUID(issueID), WorkspaceID: parseUUID(testWorkspaceID)}
trigger := commentAgentTrigger{Agent: agent, Source: commentTriggerSourceIssueAssignee}
readSnapshot := func() (triggerComment, originator, accountable, source string) {
if err := testPool.QueryRow(ctx, `
SELECT COALESCE(trigger_comment_id::text,''), COALESCE(originator_user_id::text,''),
COALESCE(accountable_user_id::text,''), COALESCE(originator_source,'')
FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2`,
issueID, agentID).Scan(&triggerComment, &originator, &accountable, &source); err != nil {
t.Fatalf("read snapshot: %v", err)
}
return
}
// ── Fail-closed: merge must be refused, snapshot preserved ──
if _, err := testPool.Exec(ctx, `UPDATE workspace SET attribution_fail_closed = true WHERE id = $1`, testWorkspaceID); err != nil {
t.Fatalf("set fail-closed: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `UPDATE workspace SET attribution_fail_closed = false WHERE id = $1`, testWorkspaceID)
})
countTasks := func() int {
var n int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2`, issueID, agentID).Scan(&n); err != nil {
t.Fatalf("count tasks: %v", err)
}
return n
}
before := countTasks()
if result := testHandler.mergeCommentIntoPendingTask(ctx, issue, trigger, parseUUID(cidB), pgtype.Text{}); result != commentMergeAttributionBlocked {
t.Fatalf("fail-closed merge result = %d, want commentMergeAttributionBlocked (refused, non-success)", result)
}
if after := countTasks(); after != before {
t.Errorf("task count changed %d -> %d on refused merge; a fail-closed refusal must not spawn a task", before, after)
}
// The refused merge must surface as a non-success blocked/attribution_blocked
// outcome, never a fabricated coalesced success.
if status, reason, terminal := commentMergeTerminalOutcome(commentMergeAttributionBlocked); !terminal || status != DispatchBlocked || reason != ReasonAttributionBlocked {
t.Errorf("attribution-blocked outcome = %s/%s (terminal=%v), want blocked/attribution_blocked", status, reason, terminal)
}
tc, orig, acc, src := readSnapshot()
if tc != cidA {
t.Errorf("trigger_comment_id = %s, want unchanged %s (fail-closed must keep the original trigger)", tc, cidA)
}
if orig != testUserID || acc != testUserID {
t.Errorf("originator/accountable = %s/%s, want unchanged %s (fail-closed must not re-stamp)", orig, acc, testUserID)
}
if src != "comment_source" {
t.Errorf("originator_source = %s, want unchanged comment_source (fail-closed must not degrade)", src)
}
// ── Fail-open control: the same comment now merges to owner_fallback ──
if _, err := testPool.Exec(ctx, `UPDATE workspace SET attribution_fail_closed = false WHERE id = $1`, testWorkspaceID); err != nil {
t.Fatalf("clear fail-closed: %v", err)
}
if result := testHandler.mergeCommentIntoPendingTask(ctx, issue, trigger, parseUUID(cidB), pgtype.Text{}); result != commentMergeSucceeded {
t.Fatalf("fail-open merge result = %d, want commentMergeSucceeded", result)
}
tc2, orig2, _, src2 := readSnapshot()
if tc2 != cidB {
t.Errorf("fail-open: trigger_comment_id = %s, want repointed to %s (merge should proceed)", tc2, cidB)
}
if src2 != "owner_fallback" {
t.Errorf("fail-open: originator_source = %s, want owner_fallback (merge proceeded)", src2)
}
if orig2 != "" {
t.Errorf("fail-open owner_fallback: originator = %s, want NULL (authorization carries no human)", orig2)
}
}