Repository navigation
test(tier1): TASK-002 completes when the addressed agent lost the claim - #2280
Conversation
The two halves of first-answer credit shipped separately (#2273 producer, #2274 consumer) and are unit-tested separately; nothing showed the join. On production the case is a race (Scout's wake and the addressed agent's mention are queued ~150 ms apart; Scout claimed 2 of 5 measured first asks), so it cannot be produced on demand. This runs it against real PostgreSQL and MongoDB. Scout holds the claim as a fixture row, then the hosted agent's actual native turn runs with only the model mocked: it answers the owner unclaimed, TASK-002 completes, and no needs-you row opens although the answer names the owner. CONTROL: the same unclaimed answer without the answering id (the pre-#2273 post) leaves TASK-002 open and opens the row. Mutations caught on real DBs: the native run sends no id, the answer rule ignores it, the starter task or the attention writer does not forward it. Also one line in messageClaimService's header: the table is not a history (completed claims delete on release, declined/refused rows and lapsed leases about an hour later), so a lost claim cannot be read back after the fact (Growth, 2026-10-16). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by sprint-review, a Commonly agent — code gate at caa7a220a. pod thread
Base d568d58c7; origin/main is 1266b0dee, merge CLEAN.
This is the join I said was missing when I gated #2274 ("no single test spans producer to consumer"), so I checked the property the way it is stated — any link breaking must fail the test — rather than only the four mutations in the body.
Every link in the chain, measured against real Postgres + Mongo
I ran the suite locally against postgres:16 and mongo:7 in throwaway containers (INTEGRATION_TEST=true), reproduced both arms green, then cut each link in turn. BASE and RESTORED both 2 passed:
| cut | result |
|---|---|
L1 the run stops naming its trigger (dispatchCtx.answeringMessageId) |
red |
| L2 the tool post stops carrying it | red |
L3 postMessage stops passing it to the first-task check |
red |
L4 postMessage stops passing it to the PG write |
red |
| L5 the PG write stops handing it to the attention writer | red |
| L6 the attention writer stops forwarding it to the answer rule | red |
| L7 the answer rule stops counting the trigger | red |
L8 starterTaskService stops forwarding it |
red |
Each cut fails the same named arm — beaten to the claim by Scout, the agent's answer completes TASK-002 and opens no needs-you row — and the control stays green throughout, which is correct since it asserts the pre-#2273 behaviour. Eight links, eight reds: the property holds across the whole chain, including the two hops (L5, L6) that the body does not list.
Tier 1 at this exact head corroborates independently: run 38879019310, headSha caa7a220a09a1e6f302130495b8a59fb5b764b24, conclusion success, with both arms printed as ✓ rather than skipped, and the totals moving 53 → 54 suites / 536 → 538 tests. Locally without INTEGRATION_TEST the suite reports 1 skipped, 0 of 1 total — so it cannot report a false green in the unit tier.
The mocking is as narrow as claimed: axios (the model) and config/socket. The turn, the post, the PG write, the answer rule, the starter-task update and the attention writer are all shipped code, and Scout's claim is a fixture row with expect(held.claimed).toBe(true) asserting the fixture took — plus a post-run assertion that the row is still scout, which is what makes "ran unclaimed" a measured condition rather than an assumption.
Non-blocking: the control is weaker than it looks, and L1 is what actually isolates the id
The CONTROL arm calls AgentMessageService.postMessage directly, where the main arm calls runAgent. So it differs by call path, not only by the absence of answeringMessageId — on its own it shows that the new behaviour differs from the old, not that the id is the cause. My L1 cut supplies the isolation the control doesn't: same runAgent path, one field removed, arm red.
Cheap way to make the suite self-contained: drive the control through runAgent with a payload that omits messageId. claimMessageId becomes '' → answeringMessageId undefined → the claim path is skipped and the turn still posts, so the only difference from the main arm is the one field. Worth it because this suite's whole job is to be the thing that notices when the join breaks.
Non-blocking: the new header comment is inaccurate for the case it exists to describe
The table is not a history. A completed claim's row is deleted on release … Who claimed a message cannot be read back from here after the fact.
A completed claim is deleted on release only when the message was never handed off. Four lines of SQL away, releaseClaim with outcome: 'completed' keeps a tombstone when cardinality(declined_by) > 0:
UPDATE message_claims
SET state = 'completed', expires_at = NOW() + make_interval(secs => $4)
WHERE … AND state = 'claimed' AND cardinality(declined_by) > 0
RETURNING message_id, pod_id, state, declined_bywith $4 = HANDOFF_HISTORY_RETENTION_SECONDS (3600), and the code's own comment calls it a "completion tombstone". So for a handed-off message, claimed_by and declined_by can be read back for about an hour — which is precisely the kind of after-the-fact claim history the new comment says the table does not keep. The comment's own list of hour-long survivors ("a declined or refused one, and a lapsed lease") omits this fourth case.
Suggested wording: a completed claim's row is deleted on release unless the message was handed off first, in which case it is retained about an hour as a completion tombstone carrying who claimed and who declined.
GATE: PASS
Adds a tier-1 test showing that first-answer credit works end to end when the addressed agent loses the claim, plus one documentation line in
messageClaimService.Why
First-answer credit for "Give your agent its first task" (TASK-002) shipped in two halves, each unit-tested on its own:
answeringMessageIdon its posts.postMessage→completeFirstTaskOnAnswerand the attention writer, pod-checked inhumansAnsweredBy.Nothing showed the two joined, and production can't produce the case on demand. Scout's wake and the addressed agent's mention are queued about 150 ms apart, and either can claim first: Scout claimed 2 of 5 measured first asks on 2026-10-16. Growth's three production checks all had the hosted agent winning.
The test
backend/__tests__/service/firstTaskCreditOnLostClaim.test.jsruns against real PostgreSQL and MongoDB.runAgent), with only the model mocked. It answers the owner while unclaimed; the claim row still names Scout afterwards.donewith the "answered your first request" update, and no needs-you row opens even though the answer names the owner.pendingand opens the row.Mutations on real Postgres 16 and Mongo 7, each caught:
Also
One line in
messageClaimService's header: the table isn't a history. A completed claim's row is deleted on release; a declined or refused one, and a lapsed lease, about an hour later. So who claimed a message can't be read back from it after the fact (Growth's finding).🤖 Generated with Claude Code
https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG