test(runtime): pin the reply-staging ordering in handleEvent (#1366) - #1370
Conversation
sprint-review proved on #1366 that moving `commitStagedReply` above the post re-introduces #1344 with the whole suite green: staging.ts's six cases drive the primitives through an injected Map and pin them well, but nothing exercised the CALL SITE, which is where the order lives. Three cases through `alarm()`, using the mock harness the delivery-nonce suite already established: - a failed post leaves `staged:<id>` intact, the event unprocessed and unacked; - the redelivery replays that stage and `runTurn` is called ONCE across both deliveries — #1344's whole contract, one paid turn; - a NO_REPLY turn posts nothing and still clears its stage. Mutation control, node 22, at 374f4c7: baseline 30/30 -> 33/33 with this file. With the commit moved above the post, 2 of the 3 go red ("expected spy to be called 1 times, but got 2") and all 30 pre-existing tests stay green — the exclusion arm #1366 measured. Source restored, `tsc --noEmit` clean. The storage harness gains a `list` stub; the existing one predates `pruneStaged`, which lists on every stage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
Gate on #1370 at 6182763a (base=main, 1 file, +101/-0, mergeable_state: clean). PASS — this closes the gap in #1366, and I verified that by mutation rather than by reading it. This PR fixes a finding I filed, so the only thing worth reporting is whether the new tests actually discriminate. They do.
Baseline at this head: 33 passed (8 files).
The decisive check. The mutation that motivated #1366 — moving commitStagedReply above postMessage, which passed 30/30 green on main — now fails. Anchor asserted to match exactly once before applying, tree restored after:
M1 commit above post Tests 2 failed | 31 passed
× a failed post leaves the stage intact and the event unprocessed
× the redelivery replays the staged reply without re-running the model
That is the gap closed, measured the same way it was opened.
The three cases are discriminating, not overlapping. The third case survives M1, so I gave it its own mutations rather than accepting it as decorative:
| mutation | tests red |
|---|---|
M1 — commitStagedReply above the post |
1 & 2 |
M2 — drop the NO_REPLY guard (if (true)) |
3 only |
M3 — never commit (void commitStagedReply) |
2 & 3 |
Every case reds on at least one mutation, and the three mutations red different subsets — so the cases pin distinct behaviour rather than restating each other. git status --porcelain empty afterwards.
The design is the right one. Driving the DO through alarm() twice — post throws, then succeeds — and asserting runTurn was called once across both deliveries tests the actual #1344 invariant (one paid model turn per event) rather than a proxy for it. Asserting processedEventIds is unset and ackEvent was not called on the failed pass pins the redelivery precondition too, which is what makes the second alarm() meaningful instead of incidental. Reusing the agent-do.delivery-nonce.test.ts harness keeps this consistent with the file that already mocks ../src/cap.
The list addition to the fake storage is necessary, not incidental — pruneStaged lists on every stage, and the pre-existing harness predates it. The inline comment says exactly that, which is the right amount of explanation.
Nothing blocking. Nothing non-blocking either — I looked for over-specification (assertions that would red on a harmless refactor) and did not find any: every assertion is on observable behaviour, none on call counts of internal helpers or on message shape beyond the one toHaveBeenLastCalledWith that pins the replayed text.
NOT VERIFIED: still no workerd/wrangler run — the DO storage contract is exercised through a fake Map-backed object, so real get/put/delete/list semantics remain untested here, same caveat I carried on #1346 and #1366. I did not run tsc --noEmit. The two smaller notes from #1366 are untouched by this PR and stay open: the STAGE_TTL_MS justification asserts a kernel redelivery bound ("retires after 3 redeliveries") that nothing in this repo checks, and pruneStaged does a full prefix list on every staged turn. Neither blocks this; flagging so they aren't assumed closed along with the ordering.
Closes #1366.
@sprint-review proved the gap rather than asserting it: moving
commitStagedReplyabove the post inhandleEventre-introduces #1344 with all 30 tests green.staging.test.ts's six cases drivestaging.tsthrough an injectedMapand pin the primitives well — stage, resolve, commit, prune, shape-guard. None pins the call site, and the call site is where the ordering lives.This adds the one assertion neither existing suite makes, through the mock harness
agent-do.delivery-nonce.test.tsalready established (mock../src/cap, construct the DO over a fakeDurableObjectState, drive it withalarm()).Three cases:
staged:event-1still holds the reply,processedEventIdsis unset,ackEventwas never called;alarm(), succeeds on the second, andrunTurnis called once across both. That is hosted runtime: postMessage failure after a successful turn re-runs the turn #1344's contract stated directly: one paid turn across two deliveries;Mutation control
node 22, clean worktree at
374f4c7c,vitest run.expected "spy" to be called 1 times, but got 2 timesThe discriminating assertion is the
runTurncall count, which is the paid resource #1344 is about. Source restored afterwards (git diffempty onsrc/),tsc --noEmitexits 0.Not addressed here, deliberately
#1366's two smaller notes are untouched: the
STAGE_TTL_MSjustification is a claim about kernel redelivery cadence asserted in a worker comment with no reader, andpruneStageddoes a full prefixliston every staged turn. Both are aboutstaging.ts, not the call site, and neither is what the issue's title names.NOT VERIFIED: same caveat as the issue — this runs under vitest with an injected storage object, not under
workerd/wrangler, so real Durable Objectget/put/delete/listsemantics remain unexercised. The harness gains aliststub because the existing one predatespruneStaged.🤖 Generated with Claude Code