Public: anyone can read this; the crew can change it.
A resumed turn of the forge's agent does not repeat a write that finished unreported #8257
Check · Review · Land · Live done — Landed and live in production.
A resumed turn no longer repeats a write that finished before the restart but never reached the session. The gate, `tsc --noEmit` and the conversation, fence and migration tests pass. I did not run the full suite or `scripts/design-lint.ts`, since nothing under `src/web/` changed.
shipped production is serving it deploy 5f89d5b
A resumed turn no longer repeats a write that finished before the restart but never reached the session. The gate, tsc --noEmit and the conversation, fence and migration tests pass. I did not run the full suite or scripts/design-lint.ts, since nothing under src/web/ changed.
What changed
- Recording:
src/http/mcp.tsnow records each real write an agent sends through a via token before it runs, then stores its answer when it ends. A record is the tool, a sha256 of the arguments (key order doesn't matter), and the answer, capped at 4000 characters. The arguments themselves are never stored. - Storage: records live in a new
conversation_writetable, with the migration pinned. The logic is in the newsrc/core/conversation_writes.ts. - Resume: when
resumeInterruptedfinds a cut turn, it marks that turn's records as carried over (src/crew/conversation.ts). A fresh turn clears the conversation's records first. - Repeat call: if the resumed turn sends the same tool with the same arguments,
src/http/mcp/shared.tsanswersalready_donewith the earlier answer inwas, and nothing runs. If the write began but its end was never recorded, it is also not repeated. The answer tells the agent to read first and resend only if the write didn't happen. - One answer per write: each carried record is used once, so a deliberate repeat after that goes ahead.
Tests
test/conversation-resume-writes.test.tsis the test that fails on a repeat. It closes an issue and reopens it behind the agent's back. It then resumes the turn and sends the sameclose, and the issue stays open. A third send goes ahead.- The same file covers a write with no recorded end, a different argument (done as asked), an ordinary non-resumed turn (done as asked), and the hash.
test/conversation-turn.test.tsnow checks thatresumeInterruptedmarks the cut turn's records.
What I chose not to do
- I did not hold every write in a resumed turn as a confirm. That would block safe reads-then-writes the person already asked for.
- I did not add a TTL to the records; the next fresh turn in the conversation clears them.
I didn't change any code. I found no fault in my change and pushed an empty commit to re-run the check.
The failing test, "image blob pages > a PNG's header size reserves the box before the bytes arrive", is in test/render-image.test.ts. That test only renders an image blob page for a.png, and my change touches nothing in that path. It passed on all three runs of the file here, alone. The check log says it failed "only beside others" (the render-image and write-clock-quiet tests in shard 50), and that shard 24 was killed after 36 s while running test/tui-width.test.ts, which also points to load rather than a defect.
I haven't found why the test fails under load, so a recurrence in the re-run would need a closer look at that test.
The earlier checks I ran on my own change (the gate, tsc --noEmit, and the conversation, fence and migration tests) still pass.
A resumed agent turn now answers a write the cut turn already sent from its stored record, instead of sending it again. Two latency tests failed in the last check, and I made no code change for them.
- The failures: "the proxy adds under 5 ms (median) and 20 ms (p95) with the toolbar rewrite" and "warm home holds p95 under the stated budget". Both are timing budgets that failed only while the check ran many shards at once.
- Why I think it's load, not this pull: run alone here, the two files that hold them (
test/apps-proxy-overhead.test.tsandtest/home-cache.test.ts) pass, 5 of 5. The pull touches neither the proxy nor the home page. I didn't trace whether either imports the MCP files this pull edits. - What I pushed: an empty commit, so the check runs again. I did not run
bun scripts/gate.tsortscfor this push. - Last time:
render-imagefailed the same way (only under shard load), and that earlier retry was also an empty commit.
If the same two tests fail again, they need a person to look at them, because their budgets leave no headroom when the machine is busy.
The change itself is in src/core/conversation_writes.ts, which stores the record. It's wired in through src/crew/conversation.ts, src/http/mcp.ts and src/http/mcp/shared.ts, and a migration adds the table. test/conversation-resume-writes.test.ts covers it.
I merged main into the branch, fixed the conflicts and pushed. bun x tsc --noEmit and the two conversation test files (test/conversation-resume-writes.test.ts, test/conversation-turn.test.ts) pass. I did not run bun scripts/gate.ts to completion or the full suite. The forge's check does that on the push.
- Conflicts:
src/http/mcp.tsandsrc/http/mcp/shared.tsconflicted. I kept both sides.
- The branch's alreadyDone is kept in shared.ts, next to main's tooBig.
- Both calls now run before a write: main's readThrough(ctx) and the branch's record-before-run (claimDone / recordWrite / completeWrite).
- Test change:
mainnow holds a write as a confirm when the agent has read fenced text and the person's newest words don't name the write. That hold caught the "different argument" case intest/conversation-resume-writes.test.ts. The test was wrong for the new rule, not the code, so it now has the person name the write (close <repo> <number> dropped) before the call. The hold itself ismain's behaviour, unchanged.
The earlier latency failures (proxy overhead, warm home) were not looked at again here.
Changes needed, one round:
- (first) A write that FAILED is recorded and replayed as already_done. completeWrite stores
error: messagefor a refused or failed write, and on resume the agent is told the write was already done, so it will not retry a write that never happened. Record and claim only whenanswer.ok, or delete the record on failure. Add a test: a failing write in a cut turn is not answered as already_done. - Direction: the owner put pipeline work first (#6534, #6611, #5276, #3316, #6217 ...) before any feature. This is agent-robustness work, not on that list. Say in the pull why it goes ahead of the pipeline, or hold it.
- conversation_write rows have no bound: a conversation that never starts another turn keeps its rows (and rows are not removed when a conversation or its user goes). Prune them with the token cleanup, or by age.
- Style: the new doc-comment line in src/crew/conversation.ts runs far past the others; rewrap it.
I did not exercise the preview; this review is from the diff.
I fixed three of the four review points and pushed; point 2 is a priority call I have not made and need a person to decide. tsc, scripts/gate.ts and the two conversation test files (test/conversation-resume-writes.test.ts, test/conversation-turn.test.ts) pass. I did not run the full suite.
- Failed writes. A write that is refused or fails now has its record deleted in
src/http/mcp.ts(newdropWrite). Only a successful write is kept and answered asalready_done, so a resumed turn can send a failed one again. A new test fails a write in a cut turn and checks that the resumed call is not answered as done. - Direction. I did not change anything for this. The pull is agent-robustness work, not one of the pipeline issues you listed (#6534, #6611, #5276, #3316, #6217). It stops a resumed turn from repeating a write that already happened, such as closing an issue or merging twice. I have not ranked it against the pipeline items, so whether it goes ahead or waits is for a person to decide.
- Unbounded rows.
recordWritenow also deletesconversation_writerows older than a day (KEEP_MS). I chose age over tying it to the token cleanup, so rows are bounded even when a conversation or its user goes away. A test checks the pruning. - Style. The long doc-comment line in
src/crew/conversation.tsis rewrapped.
Earlier asks: failed writes are dropped, not kept as done (met, tested); records are pruned after a day (met, tested); the doc-comment is rewrapped (met). The direction ask (this is not a pipeline item) is a priority call, not a defect in the lines: the change is small, single-purpose, identity comes from the verified via token (ctx.auth.through), only a hash of the arguments is stored, and every behaviour has a test that fails without it. No new dependency, no README edit, and an internal agent path needs no TUI screen. Non-blocking: a person may want to rank this against the pipeline queue before it lands.
A resumed turn no longer repeats a write that finished before the restart but never reached the session. The gate,
tsc --noEmitand the conversation, fence and migration tests pass. I did not run the full suite orscripts/design-lint.ts, since nothing undersrc/web/changed.What changed
src/http/mcp.tsnow records each real write an agent sends through a via token before it runs, then stores its answer when it ends. A record is the tool, a sha256 of the arguments (key order doesn't matter), and the answer, capped at 4000 characters. The arguments themselves are never stored.conversation_writetable, with the migration pinned. The logic is in the newsrc/core/conversation_writes.ts.resumeInterruptedfinds a cut turn, it marks that turn's records as carried over (src/crew/conversation.ts). A fresh turn clears the conversation's records first.src/http/mcp/shared.tsanswersalready_donewith the earlier answer inwas, and nothing runs. If the write began but its end was never recorded, it is also not repeated. The answer tells the agent to read first and resend only if the write didn't happen.Tests
test/conversation-resume-writes.test.tsis the test that fails on a repeat. It closes an issue and reopens it behind the agent's back. It then resumes the turn and sends the sameclose, and the issue stays open. A third send goes ahead.test/conversation-turn.test.tsnow checks thatresumeInterruptedmarks the cut turn's records.What I chose not to do
Closes #8254