Skip to content

test(tier1): TASK-002 completes when the addressed agent lost the claim - #2280

Merged
lilyshen0722 merged 1 commit into
mainfrom
lily/first-task-join-tier1
Oct 16, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
lily/first-task-join-tier1

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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:

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.js runs against real PostgreSQL and MongoDB.

  • Fixture: an owner, a pod, an active native installation, an open TASK-002 card and the owner's @-ask. Scout holds the ask's claim as a row written before the turn.
  • The turn: the hosted agent's actual native turn runs (runAgent), with only the model mocked. It answers the owner while unclaimed; the claim row still names Scout afterwards.
  • Assertions: TASK-002 is done with the "answered your first request" update, and no needs-you row opens even though the answer names the owner.
  • CONTROL: the same unclaimed answer posted without the answering id, i.e. what the turn sent before feat(native): every post names the message the turn answers; hosted prompt says it cannot change a task's status #2273, leaves TASK-002 pending and opens the row.

Mutations on real Postgres 16 and Mongo 7, each caught:

  1. the native run sends no id;
  2. the answer rule ignores it;
  3. the starter task doesn't forward it;
  4. the attention writer doesn't forward it.

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

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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_by

with $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

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit 5ef41d3 Oct 16, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant