Skip to content

fix(control): strip incomplete turn messages after user cancel (#5286)#5303

Merged
SivanCola merged 2 commits into
esengine:main-v2from
myipanta:fix/5286-interrupted-task-re-execution
Jun 25, 2026
Merged

fix(control): strip incomplete turn messages after user cancel (#5286)#5303
SivanCola merged 2 commits into
esengine:main-v2from
myipanta:fix/5286-interrupted-task-re-execution

Conversation

@myipanta

@myipanta myipanta commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

When the user presses Ctrl+C to cancel a turn, the agent's incomplete assistant messages and tool results are already saved to the session. On the next turn, the model sees leftover in-progress todo items and partial tool calls and may re-execute the interrupted work.

Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run() returns context.Canceled AND the cancel was user-initiated (CancelRequested() == true), strip the session back to the pre-turn message count so the next prompt starts clean.

The guard (CancelRequested) ensures internal context cancellations (stream recovery, timeouts) are unaffected — only explicit user cancels trigger the strip.

Summary

  • Strip incomplete turn messages from session after user cancel so the next turn starts clean ([Bug]: 中断的任务会在后续追加执行 #5286)
  • Reset Agent.todoState after strip so Controller.Todos(), goal readiness, and the task panel no longer see cancelled in_progress items
  • Flush cleaned transcript to disk after strip to overwrite any stale mid-turn autosave

Verification

  • go test -race -run 'TestTurnOrchestratorCancelStripsIncompleteTurn|TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk' ./internal/control/ passes
  • go test ./internal/agent/ ./internal/control/ passes (both packages, no regressions)

Cache impact

Cache-impact: none - The change to internal/agent/agent.go only adds a public wrapper method RebuildTodoState() that calls the existing private rebuildTodoState(). It does not touch system prompt, tool list, tool schemas, provider serialization, compaction, or reasoning upload. No provider-visible prefix changes.
Cache-guard: go test ./internal/agent/ ./internal/control/ — existing schema stability tests pass; no cache-sensitive surface is touched.
System-prompt-review: N/A

…ine#5286)

When the user presses Ctrl+C to cancel a turn, the agent's
incomplete assistant messages and tool results are already saved
to the session. On the next turn, the model sees leftover
in-progress todo items and partial tool calls and may re-execute
the interrupted work.

Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run()
returns context.Canceled AND the cancel was user-initiated
(CancelRequested() == true), strip the session back to the
pre-turn message count so the next prompt starts clean.

The guard (CancelRequested) ensures internal context cancellations
(stream recovery, timeouts) are unaffected — only explicit user
cancels trigger the strip.
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development agent Core agent loop (internal/agent, internal/control) labels Jun 25, 2026

@SivanCola SivanCola left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, this is directionally right and the mechanism is sound. The CancelRequested() guard is the correct discriminator, and adding the strip in both the first-run and plan-approved-execution branches is the right scope. Before merging, please address two incomplete contracts:

[P1] todoState is not reset after the strip

stripTurnMessagesAfter truncates the transcript via Session.Replace, but Agent.todoState is independent in-memory state. Session.Replace does not call rebuildTodoState — that only fires from SetSession (session pointer swap) and compaction. So after a cancelled turn that had a successful todo_write, the next call to Controller.Todos(), goalTodos(), goal readiness checks, and the task panel still see the stale in_progress item from the stripped turn.

The fix: after stripTurnMessagesAfter, call rebuildTodoState (or an equivalent public wrapper) on the truncated message slice so todoState is consistent with the transcript. For the plan-approved-execution branch, restore to the post-seed state (after seedPlanTodos) rather than the pre-plan state — the seed's todos are legitimate, only the execution leftovers should go.

[P2] A mid-turn autosave can leave stale bytes on disk

autosaveWhileRunning snapshots to disk periodically during a turn. snapshotActivityIfChanged(startMessages) is deferred, but its guard is messageCount() > startMessages; after the strip, the count is back to startMessages, so the defer exits early without writing the cleaned transcript. A restart or session resume will reload the stale file and the model will see the partial turn again.

The fix: after stripping, call SnapshotActivity() (or snapshot(true)) unconditionally to flush the cleaned transcript to disk. Make sure the "new session with only a system message" edge case (where HasContent() would return false) is also covered, since a cancelled first-turn leaves a session file with only the partial turn stripped away.

What already works well:

  • The CancelRequested() guard correctly limits the strip to explicit user cancels, leaving internal timeouts and stream-recovery cancels unaffected.
  • Adding the strip in both the first-run branch and the plan-approved-execution branch is the right scope.
  • stripTurnMessagesAfter itself is correct and safe.
  • The test accurately captures the transcript strip. Once P1 is addressed, add a parallel assertion that c.Todos() (or the agent's CanonicalTodoState()) returns the pre-turn list, and that a session reload yields the clean transcript (P2).

Once these are addressed and CI stays green, I will consider merging this into main-v2.

After stripTurnMessagesAfter truncates the session transcript, two states
remained inconsistent:

1. Agent.todoState was not updated. Session.Replace only swaps the message
   slice; it does not call rebuildTodoState. So a cancelled turn that had a
   successful todo_write left the in_progress item in the canonical task list,
   visible via Controller.Todos(), goal readiness, and the task panel on the
   next turn. Fix: expose Agent.RebuildTodoState() and call it after Replace.

2. A mid-turn autosave may have already written the partial transcript to
   disk. snapshotActivityIfChanged skips the write when messageCount() returns
   to startMessages (which it does after the strip), so the stale file was
   never overwritten. A restart or session resume would reload the partial
   turn. Fix: call Session.Save(path) directly after the strip to force a
   flush, bypassing the HasContent() guard that snapshot() uses (so the clean
   state is written even when the strip leaves only a system message).

Tests: extend TestTurnOrchestratorCancelStripsIncompleteTurn to assert
Todos() is empty after cancel, and add
TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk to verify the
on-disk transcript matches the pre-cancel message count.

Co-authored-by: SivanCola <32437197+SivanCola@users.noreply.github.com>

@SivanCola SivanCola left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Both issues are addressed in commit 65f18ea. Here is what changed:

P1 — todoState now rebuilt after strip

Added Agent.RebuildTodoState() (a thin public wrapper over the existing rebuildTodoState internal method) and call it in stripTurnMessagesAfter immediately after Session.Replace. The rebuild derives canonical state from the truncated transcript, so Controller.Todos(), goal readiness, and the task panel see the correct (empty) state after a user cancel.

The test TestTurnOrchestratorCancelStripsIncompleteTurn now pre-seeds the executor's todoState via ReplaceTodoState (simulating a successful todo_write mid-turn) and asserts c.Todos() returns empty after the cancel strip.

P2 — disk flushed after strip

Added Session.Save(path) directly after the strip in stripTurnMessagesAfter. This bypasses the HasContent() guard in snapshot() so the clean transcript is written even when the strip leaves only a system message. The snapshotActivityIfChanged guard (messageCount() > startMessages) would have skipped the write because the count is back to startMessages after stripping.

Added TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk: it spins up a controller with a real session path, runs a cancel, then reloads the file with LoadSession and asserts the on-disk non-system message count matches the pre-cancel count.

Verified:

  • go test -race -run 'TestTurnOrchestratorCancelStripsIncompleteTurn|TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk|TestTurnOrchestratorStopHookCancelledContext|TestTurnOrchestratorApprovedPlanSharesOneStopHook' ./internal/control/ passed
  • go test ./internal/agent/ ./internal/control/ passed (both packages, no -race regressions)
  • Failure-mode verified: P1 test fails without RebuildTodoState, P2 test fails without Session.Save
  • gofmt -l clean, git diff --check clean

@SivanCola SivanCola merged commit 7056884 into esengine:main-v2 Jun 25, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Core agent loop (internal/agent, internal/control) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants