Files
multica/server/internal/handler/comment_reconcile_test.go
e2f4a22030 feat: steer a running agent from the normal composer, per recipient (MUL-7631) (#8760)
* feat(server): steer running turns from ordinary comments (MUL-7631)

A comment can now name recipients whose running turn should receive it
(`steer_agent_ids` on POST /issues/:id/comments). Each named recipient
with a running, steer-capable turn gets the comment bound to that turn
instead of a follow-up run; any other recipient keeps its normal
queued / coalesced / deferred trigger, so losing a race never drops input.

- One comment can steer several turns: receipts are keyed by
  (comment_id, task_id) and returned as `supplements` on comments and
  timeline entries. The single supplement_* fields mirror the first
  receipt for older clients.
- Completion reconciliation skips a steered comment only for the agent
  it steered, so the same comment still reaches recipients that were
  addressed without steering.
- Delivery claims match the (comment, run) pair.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): choose per recipient how a message reaches a running agent (MUL-7631)

Steering moves from a separate "Add message" box on the run row into the
normal reply and comment composers. Recipients keep the existing trigger
rules; each recipient's current run on the issue decides what the message
can do:

- running (and steer-capable): add to the current run (default in that
  run's own thread), start after this run, stop and start over, or skip
- queued: include when it starts, or skip
- idle: start on send, or skip

Receipts follow the message ("Waiting for Lambda to read it" → "Read by
Lambda · after step N"), a failed delivery offers retry or sending it as
a new run, and the run's step list marks where it read the message.
A draft whose recipient finished meanwhile stays in place and says it
will start a new run; a message with files is handled after the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): steer only the chosen turn, and make a retried steer idempotent (MUL-7631)

- Steering names the exact running turns the author chose
  (`steer_task_ids`). A chosen turn that ended before the send arrives is
  never swapped for a later turn of the same agent; that recipient keeps
  its normal trigger.
- A steering send carries `client_request_id`. A retry after a lost
  response returns the original comment (200) instead of creating a second
  comment and a second delivery; a concurrent twin binding the same request
  is treated as already steered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): guard steering sends against stale previews and broad resends (MUL-7631)

- "Stop and start over" never stops a run while the trigger preview is
  still catching up with an edited @mention: the stop is dropped unless the
  preview answers the submitted text.
- Steering sends the chosen turns' task ids and one request id per draft
  text, reused on retries of the same text.
- "Send as a new run" on a failed receipt reaches only that receipt's
  agent: every other current recipient is suppressed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): keep the reply box placeholder neutral (MUL-7631)

The empty reply box cannot know who a reply goes to: the author may
@ someone else or only talk to a teammate. The recipient chip already
says what Send will do once there is text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(issues): give steering composer tests room for the preview debounce (MUL-7631)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): choose the default reply to a running agent (MUL-7631)

Preferences > Comments gains "When replying to a running agent":
"Add to current run" (default) or "Start after this run". It only
changes what a reply does without a per-message choice; the recipient
chip still offers both, and replies that never steered by default
(another thread, an idle or queued agent) are unaffected. Saved on this
device next to the sticky comment bar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): save one comment per send and honor "don't start" on replay (MUL-7631)

Idempotency now lives on the comment, not the delivery receipt. A send's
client_request_id is stored on the comment behind a partial unique index
(migrations 550/551), so a retry, or a concurrent twin, returns the saved
comment (200) whether the send steered a running turn or fell back to a
normal trigger. Before, a twin could save a second comment, and a send whose
turn had ended kept no receipt to find.

The agents an author chose not to start are stored on the comment too, and
completion replay skips them. "Send as a new run" for one missed recipient,
and "Won't start this time", no longer wake the other agent once its current
run completes. An edit re-records the choice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): repeat the original steering request on retry (MUL-7631)

Until a send is settled, retrying the same text sends its first steering
request unchanged, even after the chosen turn ended. The server then returns
the comment the first attempt saved instead of posting it again as new input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(migrations): fail fast on locks in 549 and 550 (MUL-7631)

Both take an ACCESS EXCLUSIVE lock on a table the product touches
continuously: comment for every read and write, task_supplement for every
daemon claim. Bound lock acquisition and execution, as 483 does for issue,
so a long transaction makes the migration fail and retry on the next run
instead of queueing all traffic behind it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): steer at most one turn per comment during rollout (MUL-7631)

Steering is live, and servers from before per-run receipts claim every
receipt of a comment at once. While they still serve daemons during a
rollout, a comment bound to two turns could have the second copy marked in
flight and never delivered. Until every server claims per (comment, run),
a comment steers the first chosen turn only; later recipients keep their
normal trigger. A follow-up lifts the limit after this ships.

Also covers a steer_task_ids entry that names another issue's turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): finish a saved send on retry; keep an edit's choices with its text (MUL-7631)

A send's comment was saved before its agents were started or steered, and
deduplication treated the saved comment as the whole send. A dropped
connection cancelled the request between the two, and the retry only
returned the comment, so no agent ever received it.

- Attempts of one logical send now run one at a time under a transaction
  advisory lock, held on its own connection: a twin waits for the attempt in
  progress (up to 30s, then 409) instead of racing it, and a process that
  dies releases it.
- After the comment is saved, dispatch runs on a non-cancelable context, and
  comment.client_request_dispatched_at (migration 552) records that it
  finished. A retry that finds a saved but undispatched comment finishes the
  dispatch with the comment's recorded choices.

An edit's "don't start" choices were written by a separate statement after
the text, so interleaved edits could leave an older edit's choice. They are
now part of UpdateComment, under the same revision check as the text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): offer one steered recipient per message during rollout (MUL-7631)

The server steers at most one turn per comment until #8800, but the
composer showed "Add to current run" for every running recipient and the
server quietly queued the rest. Now only one recipient steers: the one the
author picked, otherwise the first that would by default. The others show
"Start after this run", and picking "Add to current run" on one of them
moves the steer there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): claim a send before dispatching it; drop the request lock (MUL-7631)

The previous recovery marked a send dispatched only after reaching its
agents, so a failure after dispatch made a retry reach them all again. It
also held a pooled connection per send for an advisory lock while the send
needed more from the same pool, which could wait on itself until timeout.

A send's comment now holds a dispatch claim instead
(comment.client_request_dispatched_at, one conditional update): only the
attempt whose claim takes effect publishes the comment and reaches its
agents. A failed claim writes nothing else and returns 500 for the client
to retry; a retry that finds an unclaimed comment claims and dispatches
it; a claimed one is only returned. Concurrent attempts no longer wait on
each other, and post-save dispatch keeps a 30s bound of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(server): let one comment steer several running turns (MUL-7631)

Lifts the rollout limit from #8760. Every server now claims receipts per
(comment, run), so a comment can be bound to each running turn its author
chose, and each recipient receives it in its own run.

Merge only after #8760 is deployed everywhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): steer every running recipient a message addresses (MUL-7631)

The client side of lifting the rollout limit: each running recipient can
take the message in its current turn again, instead of only one per
message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(issues): drop send-retry idempotency and two display extras (MUL-7631)

A steering send now behaves like any other comment when its response is
lost: a retry posts it again. The machinery that tried to make that retry
return the saved comment grew with every edge it met, and ordinary comments
never had it:

- Remove comment.client_request_id, its unique index, and the dispatch
  claim (migrations 551/552, and 550 reduced to suppressed_agent_ids).
- Remove the replay/resume path in CreateComment, the post-save
  non-cancelable dispatch context, and the client's sticky request id.
  Steering sends only `steer_task_ids`; each binding uses the comment id
  as its receipt request id.

Two display extras go too, since the UI already shows the same thing:

- The "run ended while you were writing" notice: the recipient chip
  already switches from "Add to current run" to its new action.
- "after step N" on a read receipt: the run trace already places the
  message at the step where it was read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): space agent names in Chinese copy; neutral recipients header (MUL-7631)

- The steer badge joined names with Intl.ListFormat, which gives
  "Lambda和Orion" in Chinese. Latin names are now spaced from the Chinese
  joiner ("Lambda 和 Orion"), per the zh copy conventions; the enumeration
  comma stays unspaced.
- The multi-recipient popover was still titled "Will start when sent" and
  its tooltip said "choose who runs", though each row now picks how that
  agent handles the message. Title it "Recipients" and reword the tooltip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(issues): translate the recipient source labels in Chinese (MUL-7631)

"assignee" and "@mention" were left in English in zh-Hans; the recipient
menu header shows them. Use 负责人 (per the zh conventions) and @提及.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-25 04:42:26 +08:00

829 lines
37 KiB
Go

package handler
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/testutil"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// completeTaskViaHandler drives the daemon CompleteTask endpoint for taskID.
func completeTaskViaHandler(t *testing.T, taskID, output string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/complete",
map[string]any{"output": output},
testWorkspaceID, "legit-daemon")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("taskId", taskID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
testHandler.CompleteTask(w, req)
return w
}
// pendingTaskCountForAgentIssue counts claimable (queued/dispatched) tasks for
// an (issue, agent) pair.
func pendingTaskCountForAgentIssue(t *testing.T, issueID, agentID string) int {
t.Helper()
var n int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched')`,
issueID, agentID).Scan(&n)
return n
}
// queuedTaskCountForAgentIssue counts only QUEUED (not dispatched) tasks for an
// (issue, agent) pair. Used to distinguish a freshly-enqueued follow-up from a
// pre-seeded dispatched task in the same assertion.
func queuedTaskCountForAgentIssue(t *testing.T, issueID, agentID string) int {
t.Helper()
var n int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID).Scan(&n)
return n
}
// TestCompleteTask_ReconcilesMemberCommentPostedDuringRun proves the MUL-4195
// completion-reconciliation guarantee: a deliberate member comment that lands
// while the agent is busy (after the run's started_at) must earn a follow-up
// run instead of being silently lost.
func TestCompleteTask_ReconcilesMemberCommentPostedDuringRun(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&agentID, &runtimeID)
// Issue assigned to the agent so a plain member comment routes to it.
issueID := dbfx.Issue(t, "reconcile-e2e fixture", testutil.Cols{
"status": "in_progress",
"number": 999001,
"assignee_type": "agent",
"assignee_id": agentID,
})
// Trigger comment created BEFORE the run starts.
triggerCommentID := dbfx.Comment(t, issueID, "initial request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
// A running task whose started_at is in the past.
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'running', 0, now() - interval '10 minutes', now() - interval '5 minutes')
RETURNING id
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
// A deliberate member comment that arrived DURING the run (after started_at).
dbfx.Exec(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'member', $3, 'wait, also handle this', 'comment', now() - interval '1 minute')
`, issueID, testWorkspaceID, testUserID)
dbfx.Exec(t, `UPDATE comment SET parent_id=$2 WHERE issue_id=$1 AND id<>$2`, issueID, triggerCommentID)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
// A follow-up run must now be queued for the agent.
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 1 {
t.Fatalf("expected exactly 1 follow-up task after reconciliation, got %d", n)
}
}
// A daemon may replay /complete after the server committed but its response was
// lost. The task CAS makes that replay a 200, but the handler must also skip the
// transaction-external reconciliation; otherwise a follow-up that finished
// between deliveries leaves no pending dedupe row and the same member comment
// creates a second real agent run.
func TestCompleteTask_ReplayDoesNotCreateSecondFollowUp(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&agentID, &runtimeID)
issueID := dbfx.Issue(t, "replayed-complete fixture", testutil.Cols{
"status": "in_progress",
"number": 999009,
"assignee_type": "agent",
"assignee_id": agentID,
})
triggerCommentID := dbfx.Comment(t, issueID, "initial request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'running', 0, now() - interval '10 minutes', now() - interval '5 minutes')
RETURNING id
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
dbfx.Exec(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id, created_at)
VALUES ($1, $2, 'member', $3, 'also handle this once', 'comment', $4, now() - interval '1 minute')
`, issueID, testWorkspaceID, testUserID, triggerCommentID)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("first CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
var followUpID string
dbfx.QueryRow(t, `
SELECT id FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND id <> $3 AND status = 'queued'
`, issueID, agentID, taskID).Scan(&followUpID)
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'completed', completed_at = now() WHERE id = $1`, followUpID)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("replayed CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
var total int
dbfx.QueryRow(t, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2`, issueID, agentID).Scan(&total)
if total != 2 {
t.Fatalf("task rows after replay = %d, want original + exactly one follow-up", total)
}
if pending := pendingTaskCountForAgentIssue(t, issueID, agentID); pending != 0 {
t.Fatalf("replayed completion created %d additional pending follow-up(s)", pending)
}
}
// TestCompleteTask_NoReconcileWhenNoNewMemberComment guards against spurious
// follow-ups: when no member comment arrived after the run started, completion
// must not enqueue any new task.
func TestCompleteTask_NoReconcileWhenNoNewMemberComment(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&agentID, &runtimeID)
issueID := dbfx.Issue(t, "reconcile-negative fixture", testutil.Cols{
"status": "in_progress",
"number": 999002,
"assignee_type": "agent",
"assignee_id": agentID,
})
triggerCommentID := dbfx.Comment(t, issueID, "the only request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'running', 0, now() - interval '10 minutes', now() - interval '5 minutes')
RETURNING id
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 0 {
t.Fatalf("expected no follow-up task when no new member comment, got %d", n)
}
}
// TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun is the MUL-4195
// review must-fix #2 regression test. Agent A is running on an issue when a
// member posts a comment that @-mentions a DIFFERENT agent B. B is triggered at
// comment-creation time (not exercised here). When A's run completes, the
// completion reconcile must NOT replay that comment through the full trigger
// pipeline and spawn a SECOND B run — reconcile is scoped to the agent that
// just ran (A). Before the fix, reconcile fanned the latest member comment out
// to every routed agent, so completing A re-woke B (and any other agent the
// comment mentioned), breaking the bounded-follow-up guarantee.
func TestCompleteTask_DoesNotReTriggerOtherAgentMentionedDuringRun(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentA, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&agentA, &runtimeID)
// A second, workspace-invocable agent that a member can @mention.
agentB := createHandlerTestAgent(t, "Reconcile Other Agent B", nil)
// Issue assigned to A so A's completion is the one that reconciles.
issueID := dbfx.Issue(t, "reconcile-other-agent fixture", testutil.Cols{
"status": "in_progress",
"number": 999003,
"assignee_type": "agent",
"assignee_id": agentA,
})
// A's trigger comment, created before the run starts.
triggerCommentID := dbfx.Comment(t, issueID, "initial request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
// A running task for A whose started_at is in the past.
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'running', 0, now() - interval '10 minutes', now() - interval '5 minutes')
RETURNING id
`, agentA, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
// A member comment posted DURING A's run that @-mentions agent B.
mention := "[@B](mention://agent/" + agentB + ") please take a look"
dbfx.Exec(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', now() - interval '1 minute')
`, issueID, testWorkspaceID, testUserID, mention)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
// The @B comment routes to B, not A, so scoping reconcile to A means
// NEITHER agent gets a completion-driven follow-up. B in particular must
// not be re-woken by A's completion.
if n := pendingTaskCountForAgentIssue(t, issueID, agentB); n != 0 {
t.Fatalf("agent B must not be re-triggered by agent A's completion, got %d B task(s)", n)
}
if n := pendingTaskCountForAgentIssue(t, issueID, agentA); n != 0 {
t.Fatalf("agent A must not enqueue a follow-up for a comment addressed to B, got %d A task(s)", n)
}
}
// TestCompleteTask_ReconcilesAgentAuthoredMentionToCompletedAgent is the
// MUL-4304 regression test. It drives the ACTUAL drop path (review must-fix):
//
// - Agent B already has a DISPATCHED task on the issue. (This is the only
// state that drops the mention. `running`/`queued` do not: a queued task
// merges the comment in, and a running-only target is not AlreadyPending so
// it takes the normal fresh-enqueue path.)
// - Agent A posts an explicit `@B` comment through the real trigger path
// (triggerTasksForComment, same entry CreateComment uses). Because B's task
// is dispatched, `AlreadyPending` is true, mergeCommentIntoPendingTask finds
// no QUEUED row to fold into, and the active-task check `continue`s — the
// mention is dropped at creation time and deferred to completion reconcile.
// - Before the fix, reconcile listed only member comments, so A's
// agent-authored `@B` mention was NEVER replayed and B was silently never
// re-woken. With the fix, completing B's task must enqueue exactly one
// follow-up for B.
//
// The test asserts BOTH halves: no queued follow-up at creation (proving the
// drop actually happens), then exactly one after completion (proving reconcile
// recovers it).
func TestCompleteTask_ReconcilesAgentAuthoredMentionToCompletedAgent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var runtimeID string
dbfx.QueryRow(t,
`SELECT runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&runtimeID)
// Two workspace-invocable agents: A authors the mention, B is the target
// (and the agent whose run completes / reconciles).
agentA := createHandlerTestAgent(t, "Reconcile A2A Author A", nil)
agentB := createHandlerTestAgent(t, "Reconcile A2A Target B", nil)
// Issue assigned to B so B's completion is the one that reconciles.
issueID := dbfx.Issue(t, "reconcile-a2a-mention fixture", testutil.Cols{
"status": "in_progress",
"number": 999007,
"assignee_type": "agent",
"assignee_id": agentB,
})
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) })
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("setup: load issue: %v", err)
}
// B's trigger comment, created before the run starts.
triggerCommentID := dbfx.Comment(t, issueID, "initial request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
// B's task is DISPATCHED (claim response already built, not yet running) —
// the state that makes an incoming mention hit the merge-miss + active-task
// drop at creation time.
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, dispatched_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'dispatched', 0, now() - interval '10 minutes', now() - interval '5 minutes')
RETURNING id
`, agentB, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
// Agent A posts an explicit @B mention through the real trigger path while
// B is dispatched. Insert the row, then drive triggerTasksForComment exactly
// as the CreateComment handler would for an agent-authored comment.
var mentionCommentID string
mention := "[@B](mention://agent/" + agentB + ") please also handle this"
dbfx.QueryRow(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
VALUES ($1, $2, 'agent', $3, $4, 'comment')
RETURNING id
`, issueID, testWorkspaceID, agentA, mention).Scan(&mentionCommentID)
dbfx.Exec(t, `UPDATE comment SET parent_id=$2 WHERE id=$1`, mentionCommentID, triggerCommentID)
mentionComment, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(mentionCommentID))
if err != nil {
t.Fatalf("setup: load mention comment: %v", err)
}
testHandler.triggerTasksForComment(ctx, issue, mentionComment, nil, "agent", agentA, "", nil, nil)
// Drop happened: the mention found no queued task to merge into and an
// active (dispatched) task exists, so NO fresh queued follow-up was created.
if n := queuedTaskCountForAgentIssue(t, issueID, agentB); n != 0 {
t.Fatalf("expected the mention to be dropped at creation (0 queued follow-up), got %d", n)
}
// B's task progresses dispatched → running, then completes.
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'running', started_at = now() - interval '1 minute' WHERE id = $1`, taskID)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Reconcile recovers the dropped mention: exactly one queued follow-up for B.
if n := queuedTaskCountForAgentIssue(t, issueID, agentB); n != 1 {
t.Fatalf("expected exactly 1 follow-up for B from the agent-authored @B mention after completion, got %d", n)
}
// A authored the comment but is not its target, so A must not be enqueued.
if n := pendingTaskCountForAgentIssue(t, issueID, agentA); n != 0 {
t.Fatalf("comment author A must not be enqueued, got %d A task(s)", n)
}
}
// TestCompleteTask_DoesNotReconcilePlainAgentReply guards the anti-loop
// boundary of MUL-4304 on an agent-assigned issue: an agent-authored comment
// with NO explicit @mention (a plain reply / acknowledgement) must never earn a
// follow-up, even though reconcile now considers agent comments. Only explicit
// @agent/@squad mentions are replayed.
func TestCompleteTask_DoesNotReconcilePlainAgentReply(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var runtimeID string
dbfx.QueryRow(t,
`SELECT runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&runtimeID)
agentA := createHandlerTestAgent(t, "Reconcile PlainReply Author A", nil)
agentB := createHandlerTestAgent(t, "Reconcile PlainReply Target B", nil)
issueID := dbfx.Issue(t, "reconcile-plain-agent-reply fixture", testutil.Cols{
"status": "in_progress",
"number": 999008,
"assignee_type": "agent",
"assignee_id": agentB,
})
triggerCommentID := dbfx.Comment(t, issueID, "initial request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'running', 0, now() - interval '10 minutes', now() - interval '5 minutes')
RETURNING id
`, agentB, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
// A plain agent-authored reply during B's run — NO mention of anyone.
dbfx.Exec(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'agent', $3, 'thanks, looks good to me', 'comment', now() - interval '1 minute')
`, issueID, testWorkspaceID, agentA)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
if n := pendingTaskCountForAgentIssue(t, issueID, agentB); n != 0 {
t.Fatalf("a plain agent reply (no mention) must not enqueue a follow-up, got %d B task(s)", n)
}
if n := pendingTaskCountForAgentIssue(t, issueID, agentA); n != 0 {
t.Fatalf("a plain agent reply (no mention) must not enqueue a follow-up, got %d A task(s)", n)
}
}
// TestCompleteTask_DoesNotReconcilePlainWorkerReplyOnSquadIssue is the MUL-4304
// review must-fix #2 regression test. On a SQUAD-assigned issue,
// computeCommentAgentTriggers routes a plain worker-agent reply (no mention) to
// the squad leader via routeAssignedSquadLeaderFallback (Source = issue
// assignee) — that is the create-time leader→worker→leader coordination path.
// This reply was never accepted into a run's input plan. Reconcile must NOT
// turn a timestamp-only plain reply into a new conversation. Accepted worker
// handoffs are covered separately by TestWorkerReplyDelivery.
func TestCompleteTask_DoesNotReconcilePlainWorkerReplyOnSquadIssue(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newSquadCommentTriggerFixture(t)
issueID := uuidToString(fx.Issue.ID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
})
var leaderRuntimeID string
dbfx.QueryRow(t, `SELECT runtime_id FROM agent WHERE id = $1`, fx.LeaderID).Scan(&leaderRuntimeID)
// A running leader task whose completion drives reconcile.
leaderTaskID := dbfx.Task(t, fx.LeaderID, testutil.Cols{
"runtime_id": leaderRuntimeID,
"issue_id": issueID,
"status": "running",
"is_leader_task": true,
"squad_id": fx.SquadID,
"created_at": testutil.Raw("now() - interval '10 minutes'"),
"started_at": testutil.Raw("now() - interval '5 minutes'"),
})
// A plain worker-agent reply (no mention) posted during the leader's run.
// At create time this WOULD route to the leader via the squad-leader
// fallback; reconcile must not replay it.
dbfx.Exec(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'agent', $3, 'done — pushed the change', 'comment', now() - interval '1 minute')
`, issueID, testWorkspaceID, fx.OtherID)
if w := completeTaskViaHandler(t, leaderTaskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
// The squad-leader fallback is a non-mention route, so reconcile must not
// enqueue any follow-up for the leader from a plain worker reply.
if n := pendingTaskCountForAgentIssue(t, issueID, fx.LeaderID); n != 0 {
t.Fatalf("plain worker reply must not reconcile-wake the squad leader, got %d leader task(s)", n)
}
}
// handlerWorkspaceMember inserts a fresh user + workspace member and returns
// the user id (for a second distinct originator).
func handlerWorkspaceMember(t *testing.T, slug string) string {
t.Helper()
var userID string
email := slug + "-" + time.Now().Format("150405.000000") + "@example.test"
dbfx.QueryRow(t, `INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`,
"Reconcile Test "+slug, email).Scan(&userID)
dbfx.Exec(t, `INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'admin')`,
testWorkspaceID, userID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM member WHERE user_id = $1`, userID)
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
})
return userID
}
// TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath is the MUL-4195
// second-round must-fix #1 regression test, driving the FULL handler enqueue
// path (computeCommentAgentTriggers → enqueueCommentAgentTriggers → merge), not
// just the SQL. Member A's comment creates a queued task; member B (a different
// originator) then comments before the run starts. The earlier build returned
// ErrNoRows from the originator gate and fell through to a fresh enqueue that
// tripped the one-pending-per-(issue,agent) unique index, silently dropping B's
// comment. With recompute-on-merge, B's comment folds into the single task:
// still one task (no drop, no collision), trigger repointed to B, originator
// re-stamped to B, and A's comment preserved as coalesced.
func TestConsecutiveCommentsDifferentOriginatorsFullEnqueuePath(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID string
dbfx.QueryRow(t,
`SELECT id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID).Scan(&agentID)
userB := handlerWorkspaceMember(t, "originatorB")
issueID := dbfx.Issue(t, "diff-originator fixture", testutil.Cols{
"status": "in_progress",
"number": 999004,
"assignee_type": "agent",
"assignee_id": agentID,
})
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("load issue: %v", err)
}
insertMemberComment := func(authorID, content string) db.Comment {
t.Helper()
id := dbfx.Comment(t, issueID, content, testutil.Cols{
"author_id": authorID,
})
c, err := testHandler.Queries.GetComment(ctx, util.MustParseUUID(id))
if err != nil {
t.Fatalf("load comment: %v", err)
}
return c
}
// A's comment → creates the queued task (originator A).
cA := insertMemberComment(testUserID, "first, from A")
testHandler.triggerTasksForComment(ctx, issue, cA, nil, "member", testUserID, testUserID, nil, nil)
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 1 {
t.Fatalf("after A's comment expected exactly 1 queued task, got %d", n)
}
// B's comment (different originator) before start → must fold in, NOT drop.
cB := insertMemberComment(userB, "second, from B — different user")
dbfx.Exec(t, `UPDATE comment SET parent_id=$2 WHERE id=$1`, cB.ID, cA.ID)
cB.ParentID = cA.ID
testHandler.triggerTasksForComment(ctx, issue, cB, nil, "member", userB, userB, nil, nil)
// Still exactly one task (bounded concurrency, no unique-index collision).
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 1 {
t.Fatalf("after B's comment expected still exactly 1 task (folded in, not dropped/duplicated), got %d", n)
}
// Trigger repointed to B, originator re-stamped to B, A coalesced.
trigger, originator, coalesced := taskTriggerOriginatorCoalesced(t, issueID, agentID)
if trigger != uuidToString(cB.ID) {
t.Errorf("expected trigger repointed to B's comment %s, got %s", uuidToString(cB.ID), trigger)
}
if originator != userB {
t.Errorf("expected originator re-stamped to B (%s), got %s", userB, originator)
}
if !containsUUID(coalesced, uuidToString(cA.ID)) {
t.Errorf("expected A's comment %s preserved as coalesced, got %v", uuidToString(cA.ID), coalesced)
}
}
// TestCompleteTask_ReconcilesDispatchedWindowComment is the MUL-4195
// second-round must-fix #2 regression test. A member comment that lands AFTER
// the claim response was built (after dispatched_at) but BEFORE StartTask
// (before started_at) must still earn a follow-up. The earlier reconcile
// anchored on started_at and missed this window; anchoring on dispatched_at
// catches it.
func TestCompleteTask_ReconcilesDispatchedWindowComment(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&agentID, &runtimeID)
issueID := dbfx.Issue(t, "dispatched-window fixture", testutil.Cols{
"status": "in_progress",
"number": 999005,
"assignee_type": "agent",
"assignee_id": agentID,
})
triggerCommentID := dbfx.Comment(t, issueID, "initial request", testutil.Cols{
"created_at": testutil.Raw("now() - interval '10 minutes'"),
})
// Running task: dispatched 5m ago, started 2m ago. The claim response was
// built at dispatch.
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, trigger_comment_id, delivered_comment_ids, status, priority, created_at, dispatched_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$4::uuid], 'running', 0, now() - interval '10 minutes', now() - interval '5 minutes', now() - interval '2 minutes')
RETURNING id
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
// A member comment in the dispatch→start window: after dispatched_at
// (5m ago), before started_at (2m ago). A started_at anchor would miss it.
dbfx.Exec(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'member', $3, 'squeezed in before start', 'comment', now() - interval '3 minutes')
`, issueID, testWorkspaceID, testUserID)
dbfx.Exec(t, `UPDATE comment SET parent_id=$2 WHERE issue_id=$1 AND id<>$2`, issueID, triggerCommentID)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 1 {
t.Fatalf("expected exactly 1 follow-up for the dispatch-window comment, got %d", n)
}
}
// taskTriggerOriginatorCoalesced returns (trigger_comment_id, originator_user_id,
// coalesced_comment_ids) as text for the most recent task of (issue, agent).
func taskTriggerOriginatorCoalesced(t *testing.T, issueID, agentID string) (string, string, []string) {
t.Helper()
var trigger, originator string
var coalesced []string
dbfx.QueryRow(t, `
SELECT COALESCE(trigger_comment_id::text, ''),
COALESCE(originator_user_id::text, ''),
coalesced_comment_ids::text[]
FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2
ORDER BY created_at DESC
LIMIT 1
`, issueID, agentID).Scan(&trigger, &originator, &coalesced)
return trigger, originator, coalesced
}
func containsUUID(ids []string, want string) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// TestCompleteTask_ReconcilesPreDispatchMergeRaceComment is the MUL-4195
// round-3 must-fix regression test. A member comment is created while the task
// is still queued, but its merge loses the race to the daemon claiming the task
// (queued→dispatched); the merge then finds no pre-claim row and the enqueue
// path defers to reconcile. The comment's created_at is BEFORE dispatched_at,
// so a dispatched_at-anchored reconcile would skip it and it would vanish. The
// created_at anchor + delivered-set exclusion must catch it — while NOT
// re-firing a comment that WAS delivered as a pre-claim coalesced entry.
func TestCompleteTask_ReconcilesPreDispatchMergeRaceComment(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND runtime_id IS NOT NULL LIMIT 1`,
testWorkspaceID).Scan(&agentID, &runtimeID)
issueID := dbfx.Issue(t, "pre-dispatch race fixture", testutil.Cols{
"status": "in_progress",
"number": 999006,
"assignee_type": "agent",
"assignee_id": agentID,
})
// Timeline: task created 10m ago; the run's trigger 10m ago; a delivered
// pre-claim coalesced comment 8m ago; the RACE comment 7m ago (still before
// dispatch); dispatched 6m ago; started 5m ago.
insertComment := func(content, age string) string {
t.Helper()
var id string
dbfx.QueryRow(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', now() - $5::interval) RETURNING id
`, issueID, testWorkspaceID, testUserID, content, age).Scan(&id)
return id
}
triggerCommentID := insertComment("initial request", "10 minutes")
deliveredCoalescedID := insertComment("folded in while queued (delivered)", "8 minutes")
raceCommentID := insertComment("posted before dispatch, merge lost the race", "7 minutes")
// The running task: created before every comment window, with the delivered
// comment recorded in coalesced_comment_ids, dispatched after the race
// comment, started later still.
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue
(agent_id, runtime_id, issue_id, trigger_comment_id, coalesced_comment_ids, delivered_comment_ids, status, priority, created_at, dispatched_at, started_at)
VALUES ($1, $2, $3, $4, ARRAY[$5::uuid], ARRAY[$4::uuid, $5::uuid], 'running', 0,
now() - interval '10 minutes', now() - interval '6 minutes', now() - interval '5 minutes')
RETURNING id
`, agentID, runtimeID, issueID, triggerCommentID, deliveredCoalescedID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) })
dbfx.Exec(t, `UPDATE comment SET parent_id=$2 WHERE issue_id=$1 AND id<>$2`, issueID, triggerCommentID)
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Exactly one follow-up, and it must be for the RACE comment — the
// delivered coalesced comment must be excluded (not re-fired).
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 1 {
t.Fatalf("expected exactly 1 follow-up for the pre-dispatch race comment, got %d", n)
}
trigger, _, coalesced := taskTriggerOriginatorCoalesced(t, issueID, agentID)
if trigger != raceCommentID {
t.Errorf("follow-up trigger must be the race comment %s, got %s", raceCommentID, trigger)
}
if containsUUID(coalesced, deliveredCoalescedID) || trigger == deliveredCoalescedID {
t.Errorf("the already-delivered coalesced comment %s must be excluded from the follow-up, got trigger=%s coalesced=%v",
deliveredCoalescedID, trigger, coalesced)
}
}
// TestCompleteTask_ReconcilesPlannedButUndeliveredComments pins the distinction
// between the enqueue plan and the claim receipt. The planned comments predate
// the task (as they do on an auto-retry), so the planned-id query branch is the
// only way completion can recover payload-overflow or legacy-undelivered input.
func TestCompleteTask_ReconcilesPlannedButUndeliveredComments(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
tests := []struct {
name string
deliveredOldCount int
wantFollowup int
}{
{name: "partial receipt replays only omitted suffix", deliveredOldCount: 1, wantFollowup: 1},
{name: "legacy trigger-only receipt replays whole coalesced batch", deliveredOldCount: 0, wantFollowup: 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
runtimeID := createClaimReclaimRuntime(t, ctx, "Planned undelivered runtime "+tc.name)
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Planned undelivered agent "+tc.name)
dbfx.Exec(t, `
UPDATE issue
SET assignee_type = 'agent', assignee_id = $2
WHERE id = $1
`, issueID, agentID)
insertComment := func(content, age string) string {
t.Helper()
var id string
dbfx.QueryRow(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', now() - $5::interval)
RETURNING id
`, issueID, testWorkspaceID, testUserID, content, age).Scan(&id)
return id
}
old1 := insertComment("planned old one", "10 minutes")
old2 := insertComment("[@Agent](mention://agent/"+agentID+") planned old two", "9 minutes")
trigger := insertComment("planned trigger", "8 minutes")
dbfx.Exec(t, `UPDATE comment SET parent_id=$2 WHERE issue_id=$1 AND id<>$2`, issueID, old1)
delivered := []string{trigger}
if tc.deliveredOldCount > 0 {
delivered = append([]string{old1}, delivered...)
}
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, trigger_comment_id,
coalesced_comment_ids, delivered_comment_ids,
status, priority, created_at, dispatched_at, started_at
)
VALUES (
$1, $2, $3, $4,
ARRAY[$5::uuid, $6::uuid], $7::uuid[],
'running', 0, now() - interval '5 minutes', now() - interval '4 minutes', now() - interval '3 minutes'
)
RETURNING id
`, agentID, runtimeID, issueID, trigger, old1, old2, delivered).Scan(&taskID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
})
if w := completeTaskViaHandler(t, taskID, "done"); w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
if n := pendingTaskCountForAgentIssue(t, issueID, agentID); n != 1 {
t.Fatalf("expected one bounded follow-up, got %d", n)
}
followupTrigger, _, followupCoalesced := taskTriggerOriginatorCoalesced(t, issueID, agentID)
covered := append([]string{}, followupCoalesced...)
covered = append(covered, followupTrigger)
slices.Sort(covered)
want := []string{old2}
if tc.wantFollowup == 2 {
want = []string{old1, old2}
}
slices.Sort(want)
if !slices.Equal(covered, want) {
t.Fatalf("follow-up coverage = %v, want %v", covered, want)
}
})
}
}