Public: anyone can read this; the crew can change it.
A resumed turn of the forge's agent cannot repeat a write that already happened #8410
Check · Review · Land · Live done — Landed and live in production.
A restart no longer leaves a started-but-unreported write unmentioned: the person is now told about it, and the forge still does not retry it. A confirmation sentence also says when it cut a long value.
shipped production is serving it deploy 405fa2a
A restart no longer leaves a started-but-unreported write unmentioned: the person is now told about it, and the forge still does not retry it. A confirmation sentence also says when it cut a long value.
I ran test/conversation-*.test.ts (78 pass), bun x tsc --noEmit and bun scripts/gate.ts, all clean. I did not run the full suite. bun scripts/fast.ts named a very broad test list, so I ran only the conversation tests.
Most of the first two items was already on main from #8254 and #8321:
- Intent and outcome:
src/core/conversation_writes.tsrecords the tool and an arguments hash before a write runs, and completes the record with the answer.src/http/mcp.tscalls it for every write through a conversation token. - No repeat: a resumed turn that sends the same write gets
already_doneinstead of running it again. That includes a write that began and never ended, andtest/conversation-resume-writes.test.tscovers that case.
What was missing, and now exists:
- Shown to the person:
resumeInterruptedinsrc/crew/conversation.tsnow adds one agent turn after the "picking it up" line when the cut turn left a write with no outcome. It names the tools and says they will not be repeated. The person is asked to check and to say if one must be done again. The newunended()inconversation_writes.tsfinds those writes.test/conversation-turn.test.tsnow asserts the line appears once on resume. - Long values:
plain()insrc/crew/conversation_confirm.tsstill shows the first 60 characters, but now adds(cut: N characters in all). It does not show the value in full.test/conversation-confirm.test.tscovers both a long and a short value.
What I did not do:
- Arguments not stored: only the hash is kept, as before, so the new line names the tool but not its arguments.
- Test coverage: I added no separate end-to-end restart test, because the existing resume tests already cover the restart-between-write-and-report case.
To try it, start a conversation turn, record a write in conversation_write without a result, then restart. The conversation shows the resume line followed by the unended-write line.
The unended-write line on resume is sound, and unended() is correctly read before carryWrites clears the rows. Three changes, all in this round:
- Scope. The plain() change in conversation_confirm.ts (and its test) is #8408, not #8410. Remove it from this pull and ship it on its own; the direction asks for small single-purpose pulls.
- Tests. The only coverage is one assertion inside the large resume test. Add (a) a unit test of unended() proving it returns only rows with result NULL for that via, in id order, and ignores completed rows and other conversations; (b) a resume test where no write was cut and no unended line is appended; (c) unendedLine singular and plural wording. Each must fail without the change.
- Reachability and wording. The line promises please check, and tell me if it must be done again, but the forge replies already_done to the identical write. Say in the pull, with a test, how the person actually gets a repeat done (a changed request, or a cleared record). If there is no way, reword the line so it does not promise one. Also state that the page and terminal both render this turn from the same conversation turns, and that tool names are escaped on output.
Also note: this is a feature pull while the direction is pipeline first; keep it small and do not widen it.
After a restart cuts a turn mid-write, the person now sees one extra line naming the writes that began and never reported an outcome. The agent does not repeat those writes by itself. tsc and the two affected test files (conversation-turn, conversation-resume-writes) pass. I did not run the full suite or scripts/gate.ts.
Changes
unended(db, via)insrc/core/conversation_writes.tsis read beforecarryWritesmarks the rows.resumeInterruptedinsrc/crew/conversation.tsthen appendsunendedLine(tools)once, after the "forge restarted" line.unendedLineis exported.- Scope: I reverted the
plain()change inconversation_confirm.tsand its test; that belongs to #8408 and ships on its own. - Wording: the nested ternaries are now named words (
subject,pronoun,object). The line reads: "…I will not repeat it unasked; please check, and if it must be done again, say so and I will send it again." - How a repeat happens: the person says it must be done again, and the agent sends the identical call again.
claimDoneanswersalready_doneonce and deletes the record, so the second identical call goes ahead. The new test "a write the cut turn began and never ended is done again once the person asks" shows this: the first call returnsalready_done, the next one closes the issue. - Rendering and escaping: this turn goes through
appendTurn, so the page and the terminal render it from the same conversation turns. I did not add a test for tool names being escaped on output, and I did not trace the web or terminal rendering path in this round. The names are tool identifiers from the forge's own tool list, not free text.
Tests added
- A unit test of
unended(): only rows with a NULL result, only for thatvia, in id order, ignoring completed rows and other conversations. - A resume test where the write had ended and no unended line is appended.
unendedLinesingular and plural wording, including de-duplicated tool names.
I didn't run the new tests against the old code. The unit test and the "no line" resume test depend on unended() filtering and ordering, so they should fail without the change.
Not done: nothing else widened. This stays a small pull in the pipeline-first direction.
Three changes, one round.
- (first) A deliberate repeat is not reliably reachable. The line tells the person 'say so and I will send it again', but the cut turn's record answers
already_doneonce and is then deleted. If the resumed turn did not re-send the write itself, the record is still there when the person asks, so their explicit request getsalready_doneand nothing runs, unless the model happens to retry. RESUME_PROMPT also tells the agent that a held write 'is not to be asked again', which contradicts the line. The new test only covers two back-to-back identical calls, not the person's ask arriving after the resume. Fix: make the repeat deterministic. Either clear the unended records when the line is appended, since the person is now the authority, so a later identical call runs once; or reword the line so it promises only what happens. Add a test where the resumed turn sends nothing, a later identical call runs, and the first call runs rather than returningalready_done.
- No how-to. A person-visible behaviour was added without
docs/features/<name>.md. Add one short page: what the line says, and how to get a write repeated. The only related page, conversation-fence.md, does not cover this. Do not put it in README.
- Tests. The 'no unended line' test measures a before/after count taken while that count is always 0, so it would pass if the line were never appended and the check were loosened. Assert directly on the full turn list, and assert that a turn with one ended and one unended write names only the unended tool. Also say in the pull how the turn is rendered: tool names are forge tool identifiers, but state that both page and terminal escape turn text on output, and cite the code.
Scope: the plain() change is correctly removed. This is a feature pull while the direction is pipeline first, so keep it to these three fixes.
When a restart cuts the forge's agent mid-turn, the resumed turn now adds one line naming the writes that started but never recorded an outcome. The line promises only what happens: the person checks, then asks again after the resumed answer, and the write runs. Gate, tsc, test/conversation-turn.test.ts, test/conversation-resume-writes.test.ts and test/readme-cap.test.ts pass; the full suite was not run here.
What to look at
- Finding the writes:
unended(db, via)insrc/core/conversation_writes.tsreturns the tools of writes with no recorded result, in id order.resumeInterruptedinsrc/crew/conversation.tsreads it beforecarryWritesand addsunendedLine(tools)once, after the "forge restarted" line. - How a repeat happens: I reworded the line instead of clearing the records. Clearing them on resume would let the resumed agent re-send a write that may already have happened, before the person has seen anything — the case this pull exists to stop. It now reads: "…Until this answer ends, it cannot run again unchecked; please look, and if it still must be done, ask me after this answer and I will do it then."
- Why that is reliable: the person's next message starts a fresh turn, and a fresh turn calls
forgetWrites(begin,conversation.ts:220). The first identical call then runs; it is not answeredalready_done.
- The new test "the resumed turn sends nothing; the person asks after it…" covers this. The record still exists after the resumed answer; after the person's message, claimDone, which src/http/mcp.ts:210 consults, finds nothing.
- A message sent while the resumed turn is still running is steered into that turn and keeps the guard. The line says "after this answer" for that reason.
RESUME_PROMPT: now says "a held write's confirmation was answered once and is not to be asked for again". That is what it always meant (single-use holds), so it no longer reads as contradicting the line.- How-to:
docs/features/talk-resume-writes.mdgives what the line says and how to get a write done again.
Tests
- A resume where every write ended asserts the exact added turns, with no line.
- A resume with one ended and one unended write asserts the line names only
merge. unendedLinewording for one write and for several, with duplicate names removed.- A direct
unended()test: only rows with no result, only that conversation, in id order.
Rendering: the line is an ordinary agent turn, and the page and the terminal render it from the same stored turns. The page draws agent turns with renderMarkdown, which escapes its input before transforming it (src/web/pages/talk.ts:72). forge talk prints them through inert (src/cli/commands/talk.ts:44, src/cli/out.ts:49).
The plain() change stays out of this pull; it ships separately for #8408.
🤖 Generated with Claude Code
Earlier asks, all met on the diff since 464296284876: (1) Scope: the plain() change is gone from this pull. (2) Tests: unended() has a unit test (NULL result only, per via, id order). A resume where every write ended asserts no line. A mixed resume asserts the line names only the unended write. The singular and plural wording is tested, with duplicate names removed. (3) Reachability and wording: the line now promises only what happens. During the resumed answer the identical call gets already_done. After it, the next message starts a fresh turn that forgets the records, so the write runs. A test proves this. docs/features/talk-resume-writes.md says how, and README is untouched. Security: the line carries only forge tool identifiers and goes through appendTurn, so the page (renderMarkdown, which escapes) and the terminal (inert) render it from the same turns. No new input, no new write door, no dependency. Non-blocking: the doc says a message sent during the resumed answer keeps the guard; a test for that steering case would be a nice follow-up.
A restart no longer leaves a started-but-unreported write unmentioned: the person is now told about it, and the forge still does not retry it. A confirmation sentence also says when it cut a long value.
I ran
test/conversation-*.test.ts(78 pass),bun x tsc --noEmitandbun scripts/gate.ts, all clean. I did not run the full suite.bun scripts/fast.tsnamed a very broad test list, so I ran only the conversation tests.Most of the first two items was already on
mainfrom #8254 and #8321:src/core/conversation_writes.tsrecords the tool and an arguments hash before a write runs, and completes the record with the answer.src/http/mcp.tscalls it for every write through a conversation token.already_doneinstead of running it again. That includes a write that began and never ended, andtest/conversation-resume-writes.test.tscovers that case.What was missing, and now exists:
resumeInterruptedinsrc/crew/conversation.tsnow adds one agent turn after the "picking it up" line when the cut turn left a write with no outcome. It names the tools and says they will not be repeated. The person is asked to check and to say if one must be done again. The newunended()inconversation_writes.tsfinds those writes.test/conversation-turn.test.tsnow asserts the line appears once on resume.plain()insrc/crew/conversation_confirm.tsstill shows the first 60 characters, but now adds(cut: N characters in all). It does not show the value in full.test/conversation-confirm.test.tscovers both a long and a short value.What I did not do:
To try it, start a conversation turn, record a write in
conversation_writewithout a result, then restart. The conversation shows the resume line followed by the unended-write line.Closes #8408