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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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.ts records the tool and an arguments hash before a write runs, and completes the record with the answer. src/http/mcp.ts calls it for every write through a conversation token.
  • No repeat: a resumed turn that sends the same write gets already_done instead of running it again. That includes a write that began and never ended, and test/conversation-resume-writes.test.ts covers that case.

What was missing, and now exists:

  • Shown to the person: resumeInterrupted in src/crew/conversation.ts now 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 new unended() in conversation_writes.ts finds those writes. test/conversation-turn.test.ts now asserts the line appears once on resume.
  • Long values: plain() in src/crew/conversation_confirm.ts still 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.ts covers 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.

Closes #8408

commented

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.ts records the tool and an arguments hash before a write runs, and completes the record with the answer. src/http/mcp.ts calls it for every write through a conversation token.
  • No repeat: a resumed turn that sends the same write gets already_done instead of running it again. That includes a write that began and never ended, and test/conversation-resume-writes.test.ts covers that case.

What was missing, and now exists:

  • Shown to the person: resumeInterrupted in src/crew/conversation.ts now 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 new unended() in conversation_writes.ts finds those writes. test/conversation-turn.test.ts now asserts the line appears once on resume.
  • Long values: plain() in src/crew/conversation_confirm.ts still 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.ts covers 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.

Changes requested — before the latest push

The unended-write line on resume is sound, and unended() is correctly read before carryWrites clears the rows. Three changes, all in this round:

  1. 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.
  2. 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.
  3. 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.

commented

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) in src/core/conversation_writes.ts is read before carryWrites marks the rows. resumeInterrupted in src/crew/conversation.ts then appends unendedLine(tools) once, after the "forge restarted" line. unendedLine is exported.
  • Scope: I reverted the plain() change in conversation_confirm.ts and 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. claimDone answers already_done once 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 returns already_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 that via, in id order, ignoring completed rows and other conversations.
  • A resume test where the write had ended and no unended line is appended.
  • unendedLine singular 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.

Changes requested — before the latest push

Three changes, one round.

  1. (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_done once 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 gets already_done and 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 returning already_done.
  1. 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.
  1. 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.

commented

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) in src/core/conversation_writes.ts returns the tools of writes with no recorded result, in id order. resumeInterrupted in src/crew/conversation.ts reads it before carryWrites and adds unendedLine(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 answered already_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.md gives 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.
  • unendedLine wording 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs