fix(rewind): truncate by conversation-wide message boundary, invalidate live mirror - #1389
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
dennisonbertram
added a commit
that referenced
this pull request
Sep 5, 2026
… rewind Coordinator follow-up on PR #1389: the boundary as merged kept the assistant message that carries the rewound tool call, so a restore could leave persisted history ending in an assistant message whose tool_calls have no tool-result messages. Real providers reject that shape (OpenAI: "An assistant message with 'tool_calls' must be followed by tool messages responding to each tool_call_id"); the fake/stub providers used in this repo's tests do not enforce it, which is why the merged tests passed anyway. New red-first test: TestRestoreRewindPoint_NeverLeavesDanglingToolCall (internal/harness/runner_test.go) drives a real single-turn run with two parallel tool calls (one assistant message, two ToolCalls entries) through the actual step-engine capture path and restores using either call's point, asserting: both calls' points share one MessageBoundary, the last persisted message is never an assistant message with tool_calls, and no tool message lacks a preceding assistant tool_calls entry for its ID. Existing tests updated to the corrected semantics (MessageBoundary is the index of the assistant tool-call message itself, not the index just after it): TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (boundary 6->5, MessagesTruncated 2->3, kept messages 6->5) and TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (MessagesTruncated 2->3, kept messages 6->5), both gaining a "no dangling assistant tool_calls" assertion on the final persisted message. Test runner output (red, against the runner_step_engine.go capture site unchanged from the already-merged fix): === RUN TestRestoreRewindPoint_NeverLeavesDanglingToolCall runner_test.go:496: restore left a dangling assistant message with tool_calls as the last persisted message: {... ToolCalls:[{ID:pa1 ...} {ID:pa2 ...}] ...} --- FAIL: TestRestoreRewindPoint_NeverLeavesDanglingToolCall (0.01s) === RUN TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror http_rewind_test.go:353: MessagesTruncated = 2, want 3 (run2's tool-call message, tool result, and final answer) http_rewind_test.go:358: after rewind: got 6 messages, want 5 (run1's 4 plus run2's user prompt): [...] --- FAIL: TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (0.03s) TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (store-level, boundary hand-set to 5 in the test) already passes: it exercises RestoreRewindPoint's truncation contract in isolation from the runner_step_engine.go capture-value bug these two new/updated tests target. Both real failures are behavioral (dangling tool_calls / wrong counts), not compile errors, and reproduce exactly the shape the coordinator described. These tests will pass after the implementation in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
dennisonbertram
force-pushed
the
issue-1370-rewind-truncation
branch
from
September 5, 2026 15:31
cf876be to
54b6e2d
Compare
dennisonbertram
added a commit
that referenced
this pull request
Sep 5, 2026
… rewind Coordinator follow-up on PR #1389: the boundary as merged kept the assistant message that carries the rewound tool call, so a restore could leave persisted history ending in an assistant message whose tool_calls have no tool-result messages. Real providers reject that shape (OpenAI: "An assistant message with 'tool_calls' must be followed by tool messages responding to each tool_call_id"); the fake/stub providers used in this repo's tests do not enforce it, which is why the merged tests passed anyway. New red-first test: TestRestoreRewindPoint_NeverLeavesDanglingToolCall (internal/harness/runner_test.go) drives a real single-turn run with two parallel tool calls (one assistant message, two ToolCalls entries) through the actual step-engine capture path and restores using either call's point, asserting: both calls' points share one MessageBoundary, the last persisted message is never an assistant message with tool_calls, and no tool message lacks a preceding assistant tool_calls entry for its ID. Existing tests updated to the corrected semantics (MessageBoundary is the index of the assistant tool-call message itself, not the index just after it): TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (boundary 6->5, MessagesTruncated 2->3, kept messages 6->5) and TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (MessagesTruncated 2->3, kept messages 6->5), both gaining a "no dangling assistant tool_calls" assertion on the final persisted message. Test runner output (red, against the runner_step_engine.go capture site unchanged from the already-merged fix): === RUN TestRestoreRewindPoint_NeverLeavesDanglingToolCall runner_test.go:496: restore left a dangling assistant message with tool_calls as the last persisted message: {... ToolCalls:[{ID:pa1 ...} {ID:pa2 ...}] ...} --- FAIL: TestRestoreRewindPoint_NeverLeavesDanglingToolCall (0.01s) === RUN TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror http_rewind_test.go:353: MessagesTruncated = 2, want 3 (run2's tool-call message, tool result, and final answer) http_rewind_test.go:358: after rewind: got 6 messages, want 5 (run1's 4 plus run2's user prompt): [...] --- FAIL: TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (0.03s) TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (store-level, boundary hand-set to 5 in the test) already passes: it exercises RestoreRewindPoint's truncation contract in isolation from the runner_step_engine.go capture-value bug these two new/updated tests target. Both real failures are behavioral (dangling tool_calls / wrong counts), not compile errors, and reproduce exactly the shape the coordinator described. These tests will pass after the implementation in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
dennisonbertram
added a commit
that referenced
this pull request
Sep 5, 2026
…ed older points Coordinator follow-up on PR #1389, found by live verification on main after #1378 merged: RestoreRewindPoint's future-point pruning query (`DELETE FROM rewind_points WHERE conversation_id=? AND (step>? OR (step=? AND id<>?))`) compares point.Step, a run-local tool-call counter, the same bug issue #1370 already fixed for message truncation. Step numbering restarts each run, so run 2's edit point (step 1 within run 2) and run 1's write point (also step 1 within run 1, an unrelated run) collide: rewinding to the edit point deleted the write point too, and a later restore to that still-valid older point returned "not found". New red-first test: TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns (internal/harness/runner_test.go) drives two real runs (run 1 writes a.txt, run 2 edits it), restores to run 2's edit point, then restores to run 1's write point and asserts it succeeds. Test runner output (red): === RUN TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns runner_test.go:594: RestoreRewindPoint(write) after an unrelated later-run restore: rewind point "run_...-1-c1" not found --- FAIL: TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns (0.01s) This is a behavioral failure (the older point vanished), not a compile error, and reproduces exactly the 404 the coordinator described. This test will pass after the implementation in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
dennisonbertram
force-pushed
the
issue-1370-rewind-truncation
branch
from
September 5, 2026 15:37
54b6e2d to
549ca9b
Compare
…live mirror invalidation Behavioral tests added: - BT-001 internal/harness/rewind_store_test.go: TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint — rewinding to a point captured during a conversation's second run must keep every message from the first run plus the second run's user prompt and tool-call message, deleting only what came after. - (fallback regression) TestRestoreRewindPoint_FallsBackWhenBoundaryUnset — legacy points with no recorded boundary must fall back to the documented step-based behavior with a logged warning, not silently over-delete. - BT-002 internal/server/http_rewind_test.go: TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror — through the real HTTP handler, reproduces issue #1370's exact repro (two completed runs, rewind to run 2's first tool call) and proves GET /messages reflects the truncation immediately instead of continuing to serve the runner's stale in-memory mirror. Included in this commit alongside the tests: the inert RewindPoint.MessageBoundary field and its rewind_points.message_boundary column (idempotent migration, default 0) plus Save/List wiring. This is test scaffolding, not the fix -- RestoreRewindPoint's truncation query is UNCHANGED here and still compares against the run-local Step field, so the new field carries no behavior yet. Without it there is no way for a test to express "this point's true conversation-wide boundary is 6," since production code (runner_step_engine.go) will be the one to populate it in the green commit. Test runner output (red, both packages): === RUN TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint rewind_store_test.go:178: MessagesTruncated = 7, want 2 (run2's tool result and final answer) rewind_store_test.go:185: LoadMessages returned 1 messages, want 6 ... --- FAIL: TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (0.01s) === RUN TestRestoreRewindPoint_FallsBackWhenBoundaryUnset rewind_store_test.go:234: expected a logged warning naming the point falling back to step-based truncation, got: "" --- FAIL: TestRestoreRewindPoint_FallsBackWhenBoundaryUnset (0.02s) FAIL go-agent-harness/internal/harness === RUN TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror http_rewind_test.go:352: MessagesTruncated = 6, want 2 (run2's tool result and final answer) http_rewind_test.go:357: after rewind: got 8 messages, want 6 ... --- FAIL: TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (0.04s) FAIL go-agent-harness/internal/server Both failures are behavioral (wrong truncation counts / stale mirror), not compile or import errors, confirming they exercise the real bug described in issue #1370. These tests will pass after the implementation in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
…ate live mirror Implementation for tests added in f5e0cb7. Two independent bugs, one root cause each: 1. RestoreRewindPoint (internal/harness/conversation_store_sqlite.go) compared point.Step -- a run-local tool-call counter set in runner_step_engine.go:1352 -- against conversation_messages.step, a conversation-wide message index. In a multi-run conversation this deletes an earlier run's later messages too. Fixed by recording the true conversation-wide message count at capture time (RewindPoint.MessageBoundary = len(messages), where messages already holds every prior run's persisted history plus this run's user prompt and the assistant tool-call message about to execute -- exactly what Runner.completeRun will persist) and truncating with `step >= MessageBoundary`. Points captured before this field existed have MessageBoundary == 0 ("not recorded"); restore falls back to the legacy step comparison and logs a warning rather than silently over-deleting. Schema: idempotent `ALTER TABLE rewind_points ADD COLUMN message_boundary INTEGER NOT NULL DEFAULT 0`. 2. The runner's in-memory conversation mirror (r.conversations, populated at each run's completion and served by ConversationMessages / ConversationMessagesSnapshot / GET /v1/conversations/{id}/messages) is a write-behind cache that the store-level restore never touches. Fixed by adding Runner.InvalidateConversationHistory(conversationID), which drops the mirror entry (and its paired watermark) so the next read falls through to the store; internal/server/http_conversations.go's handleRestoreRewind calls it immediately after a successful RestoreRewindPoint, before responding. Test runner output (green): === RUN TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint --- PASS: TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (0.01s) === RUN TestRestoreRewindPoint_FallsBackWhenBoundaryUnset --- PASS: TestRestoreRewindPoint_FallsBackWhenBoundaryUnset (0.00s) PASS go-agent-harness/internal/harness === RUN TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror --- PASS: TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (0.03s) PASS go-agent-harness/internal/server Full package suites, no regressions: ok go-agent-harness/internal/harness 6.411s ok go-agent-harness/internal/harness/tools 17.933s ok go-agent-harness/internal/harness/tools/core 2.652s ok go-agent-harness/internal/harness/tools/deferred 18.489s ok go-agent-harness/internal/harness/tools/descriptions 1.541s ok go-agent-harness/internal/harness/tools/recipe 2.476s ok go-agent-harness/internal/harness/tools/script 9.820s ok go-agent-harness/internal/server 15.449s Behavioral tests covered: BT-001 (rewind_store_test.go), BT-002 (http_rewind_test.go). Files changed: internal/harness/conversation_store_sqlite.go, internal/harness/runner.go, internal/harness/runner_step_engine.go, internal/server/http_conversations.go Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
Regression test added that would fail if the fix in d90f3af is reverted: TestRewindThenNextRunDoesNotResurrectTruncatedMessages (internal/harness/runner_test.go). It exercises a third observation point, distinct from the red commit's two tests (store LoadMessages and the HTTP /messages endpoint): the actual provider request payload for the run that follows a rewind, driven through a real three-run Runner flow with a capturingProvider. Confirmed meaningful by temporarily removing the runner.InvalidateConversationHistory(convID) call from the test body alone (implementation untouched) and observing it fail: runner_test.go:408: run3's context resurrected run2's truncated tool result: [...] --- FAIL: TestRewindThenNextRunDoesNotResurrectTruncatedMessages (0.01s) Restoring the call turns it back to PASS, confirming the test actually detects the resurrection regression rather than passing vacuously. Full test suite output: go test ./internal/harness ./internal/server -race ok go-agent-harness/internal/harness 9.748s ok go-agent-harness/internal/server 21.891s go vet ./internal/harness/... ./internal/server/... (clean, exit 0) Regression scenarios covered: - After rewind, a fresh run's LLM context omits the truncated tool result (matched by ToolCallID) and the truncated final answer (matched by content), instead of resurrecting them via the runner's in-memory mirror. - Proves the fix holds through the full Runner step-engine flow (rewind point capture during a live mutating tool call, not just a store call built by hand), independently of the store-level and HTTP-level tests added in the red commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
… fix Documents the conversation-wide message boundary (vs. run-local step) and the live in-memory mirror invalidation in docs/runbooks/session-rewind.md, and records the cause/fix/regression in docs/logs/engineering-log.md following the file's existing per-issue format. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
… rewind Coordinator follow-up on PR #1389: the boundary as merged kept the assistant message that carries the rewound tool call, so a restore could leave persisted history ending in an assistant message whose tool_calls have no tool-result messages. Real providers reject that shape (OpenAI: "An assistant message with 'tool_calls' must be followed by tool messages responding to each tool_call_id"); the fake/stub providers used in this repo's tests do not enforce it, which is why the merged tests passed anyway. New red-first test: TestRestoreRewindPoint_NeverLeavesDanglingToolCall (internal/harness/runner_test.go) drives a real single-turn run with two parallel tool calls (one assistant message, two ToolCalls entries) through the actual step-engine capture path and restores using either call's point, asserting: both calls' points share one MessageBoundary, the last persisted message is never an assistant message with tool_calls, and no tool message lacks a preceding assistant tool_calls entry for its ID. Existing tests updated to the corrected semantics (MessageBoundary is the index of the assistant tool-call message itself, not the index just after it): TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (boundary 6->5, MessagesTruncated 2->3, kept messages 6->5) and TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (MessagesTruncated 2->3, kept messages 6->5), both gaining a "no dangling assistant tool_calls" assertion on the final persisted message. Test runner output (red, against the runner_step_engine.go capture site unchanged from the already-merged fix): === RUN TestRestoreRewindPoint_NeverLeavesDanglingToolCall runner_test.go:496: restore left a dangling assistant message with tool_calls as the last persisted message: {... ToolCalls:[{ID:pa1 ...} {ID:pa2 ...}] ...} --- FAIL: TestRestoreRewindPoint_NeverLeavesDanglingToolCall (0.01s) === RUN TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror http_rewind_test.go:353: MessagesTruncated = 2, want 3 (run2's tool-call message, tool result, and final answer) http_rewind_test.go:358: after rewind: got 6 messages, want 5 (run1's 4 plus run2's user prompt): [...] --- FAIL: TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror (0.03s) TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint (store-level, boundary hand-set to 5 in the test) already passes: it exercises RestoreRewindPoint's truncation contract in isolation from the runner_step_engine.go capture-value bug these two new/updated tests target. Both real failures are behavioral (dangling tool_calls / wrong counts), not compile errors, and reproduce exactly the shape the coordinator described. These tests will pass after the implementation in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
…ndex Implementation for tests added in 66b823b5. Corrects the semantics merged in d90f3af: MessageBoundary must be the conversation-wide index of the assistant message carrying the rewound tool call (assistantToolCallIndex, captured immediately after that message is appended in runner_step_engine.go), not len(messages) at capture time. len(messages) pointed just past that assistant message, so a restore kept it while deleting its tool result -- a real provider (OpenAI et al.) rejects an assistant message with tool_calls that isn't followed by matching tool messages; the fake/stub providers used across this repo's tests don't enforce that, so the bug shipped with green tests. Parallel tool calls issued in one assistant turn all capture the same assistantToolCallIndex, since they share one assistant message. RestoreRewindPoint's truncation query (`step >= MessageBoundary`) and its fallback-when-unset branch are unchanged; only the captured value at the runner_step_engine.go call site changes. Test runner output (green): === RUN TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint --- PASS (0.01s) === RUN TestRestoreRewindPoint_FallsBackWhenBoundaryUnset --- PASS (0.01s) === RUN TestRewindThenNextRunDoesNotResurrectTruncatedMessages --- PASS (0.01s) === RUN TestRestoreRewindPoint_NeverLeavesDanglingToolCall --- PASS (0.01s) PASS go-agent-harness/internal/harness === RUN TestRestoreRewindEndpointRestoresFileAndTruncatesMessages --- PASS (0.02s) === RUN TestRestoreRewindEndpointRequiresPointID --- PASS (0.00s) === RUN TestRestoreRewindEndpointRefusesExternalModificationWithoutForce --- PASS (0.01s) === RUN TestRestoreRewindEndpoint_MultiRunKeepsPriorMessagesAndInvalidatesLiveMirror --- PASS (0.05s) PASS go-agent-harness/internal/server Full package suites, no regressions: ok go-agent-harness/internal/harness 4.126s ok go-agent-harness/internal/harness/tools (cached) ok go-agent-harness/internal/harness/tools/core (cached) ok go-agent-harness/internal/harness/tools/deferred (cached) ok go-agent-harness/internal/harness/tools/descriptions (cached) ok go-agent-harness/internal/harness/tools/recipe (cached) ok go-agent-harness/internal/harness/tools/script (cached) ok go-agent-harness/internal/server 12.804s go vet ./internal/harness/... ./internal/server/... clean, exit 0. Files changed: internal/harness/runner_step_engine.go, internal/harness/runner_test.go Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
…ls fix Updates docs/runbooks/session-rewind.md's truncation sentence and adds a docs/logs/engineering-log.md followup entry describing the boundary correction (assistant tool-call message index, not len(messages)) from commit 8c2e1879, and corrects the earlier same-day entry's now-stale description of the originally merged value. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
…ed older points Coordinator follow-up on PR #1389, found by live verification on main after #1378 merged: RestoreRewindPoint's future-point pruning query (`DELETE FROM rewind_points WHERE conversation_id=? AND (step>? OR (step=? AND id<>?))`) compares point.Step, a run-local tool-call counter, the same bug issue #1370 already fixed for message truncation. Step numbering restarts each run, so run 2's edit point (step 1 within run 2) and run 1's write point (also step 1 within run 1, an unrelated run) collide: rewinding to the edit point deleted the write point too, and a later restore to that still-valid older point returned "not found". New red-first test: TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns (internal/harness/runner_test.go) drives two real runs (run 1 writes a.txt, run 2 edits it), restores to run 2's edit point, then restores to run 1's write point and asserts it succeeds. Test runner output (red): === RUN TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns runner_test.go:594: RestoreRewindPoint(write) after an unrelated later-run restore: rewind point "run_...-1-c1" not found --- FAIL: TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns (0.01s) This is a behavioral failure (the older point vanished), not a compile error, and reproduces exactly the 404 the coordinator described. This test will pass after the implementation in the next commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
…dary Implementation for the test added in 56d6bb11. RestoreRewindPoint's future-point pruning query compared point.Step, a run-local tool-call counter that restarts every run, so a point from one run could collide with and delete an unrelated point from a different run that happened to capture the same step number. Fixed by pruning on MessageBoundary when recorded: a point is superseded only if its boundary is strictly greater than the target's (later in conversation order), or equal (parallel tool calls sharing one assistant message) but captured later (created_at). The target itself is always excluded via id<>?. Falls back to the legacy step predicate only when the target point has no recorded boundary (MessageBoundary==0), matching the existing message-truncation fallback. Test runner output (green): === RUN TestRestoreRewindPoint_PruneKeepsOlderPointsFromEarlierRuns --- PASS (0.00s) PASS go-agent-harness/internal/harness Full suite, no regressions (including #1371's older-point-after-agent-edit tests, which construct points without MessageBoundary and correctly exercise the legacy fallback branch): ok go-agent-harness/internal/harness 3.869s ok go-agent-harness/internal/harness/tools (cached) ok go-agent-harness/internal/harness/tools/core (cached) ok go-agent-harness/internal/harness/tools/deferred (cached) ok go-agent-harness/internal/harness/tools/descriptions (cached) ok go-agent-harness/internal/harness/tools/recipe (cached) ok go-agent-harness/internal/harness/tools/script (cached) ok go-agent-harness/internal/server 12.700s go test ./internal/harness ./internal/server -race: ok go-agent-harness/internal/harness 7.155s ok go-agent-harness/internal/server 17.446s go vet ./internal/harness/... ./internal/server/...: clean, exit 0. Files changed: internal/harness/conversation_store_sqlite.go Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
…ne fix Adds a docs/logs/engineering-log.md entry for the prune fix in 73ff1609 and extends the session-rewind runbook sentence to note that pruning of superseded rewind points now uses the same conversation-wide boundary as message truncation, so an earlier run's point is never deleted by a later run's restore. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
dennisonbertram
force-pushed
the
issue-1370-rewind-truncation
branch
from
September 5, 2026 15:38
549ca9b to
be80c24
Compare
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1370
Summary
Rewinding a multi-run conversation truncated the wrong messages and the live daemon kept serving the pre-rewind history.
RestoreRewindPointcompared the rewind point's run-local tool-call step againstconversation_messages.step, a conversation-wide message index shared across every run on that conversation, so rewinding to a point in run 2 deleted run 1's later messages too. Separately, the runner's in-memory conversation mirror (populated at each run's completion, served byGET /messagesand the next run's context) was never invalidated by rewind, so it kept serving — and the next run kept re-persisting — the pre-rewind history until a daemon restart.Fix:
RewindPointgainedMessageBoundary, the conversation-wide index recorded at capture time of the assistant message that carries the rewound tool call (not just a message count — see the first review follow-up below).RestoreRewindPointtruncates by that boundary when it is recorded, deleting that assistant message and everything after it; points captured before this field existed fall back to the legacy step comparison with a logged warning instead of silently over-deleting.Runner.InvalidateConversationHistorydrops the in-memory mirror entry for a conversation; the rewind HTTP handler calls it immediately after a successful restore.Related: #1303 describes the same resurrection symptom from a different angle (workspace population, TUI JSON tags) and remains open — not touched by this PR.
Review follow-up 1: dangling
tool_callsafter restoreA reviewer caught that the boundary as first implemented (
len(messages)at capture time) pointed just past the assistant message carrying the rewound tool call, so a restore kept that assistant message while deleting only its tool result. Real providers (OpenAI et al.) reject an assistant message withtool_callsthat isn't immediately followed by matching tool messages; the fake/stub providers this repo's tests use do not enforce that, so the bug shipped with green tests. Fixed by capturingassistantToolCallIndex— the index of the assistant message itself, right after it's appended inrunner_step_engine.go— and using that asMessageBoundaryinstead. Parallel tool calls issued in one assistant turn all capture the same index, since they share one assistant message. Commits:3b6e0833red →e934fbd9green →354c0f53docs.Review follow-up 2: rewind_points pruning deleted unrelated older points
Found by live verification on
mainafter #1378 merged:RestoreRewindPoint's other truncation query — pruning supersededrewind_pointsrows — had the same run-local-step bug as message truncation, just not yet fixed. Rewinding to run 2's edit point (step 1 within run 2) deleted run 1's write point too (also step 1 within run 1, an unrelated run), so a later rewind to that still-valid older point returned 404 "not found". Fixed by pruning onMessageBoundarytoo: a point is superseded only by a strictly greater boundary, or an equal boundary (parallel tool calls sharing one assistant message) captured later (created_at); the target itself is always excluded; falls back to the legacy step predicate only when the target has no recorded boundary. Commits:b0d814a2red →2f2a1f58green →549ca9b7docs.Both follow-ups are folded into this same PR rather than filed as separate issues, since each corrects a value or query this PR itself introduced or was scoped to fix, and neither was ever merged in its original/unfixed form.
Scope and issue reconciliation
In scope per the issue: record a conversation-wide boundary on each rewind point and truncate by it; invalidate the runner's in-memory conversation mirror on rewind. Both are done, refined during review twice (assistant-message index instead of a raw count; the rewind_points prune query keyed on the same boundary). Out of scope per the issue (not touched): crash-safety journaling (#1349), TUI count display (#1303 item 2), snapshot format redesign. #1371's older-point-guard fix (hash refresh across a conversation's snapshots of the same path) is unrelated and already on
main, picked up by this branch's rebase; not touched here. No deviations from the issue's stated scope.Impact analysis reconciliation
internal/harness/rewind.go:RewindPointgainsMessageBoundary int(zero value = "not recorded", intentionally indistinguishable from omission since a real boundary is always >= 1 messages long in practice).internal/harness/conversation_store_sqlite.go: idempotent migration addsrewind_points.message_boundary INTEGER NOT NULL DEFAULT 0;SaveRewindPoint/ListRewindPointsread/write it;RestoreRewindPoint's message-truncation query and its future-point pruning query both branch onMessageBoundarywith alog.Printffallback warning when it's unset.internal/harness/runner_step_engine.go: right after the assistant message withtool_callsis appended, capturesassistantToolCallIndex := len(messages) - 1; the rewind-point capture site (for each mutating, non-parallel-safe tool call in that turn) setsMessageBoundary: assistantToolCallIndex.internal/harness/runner.go: newRunner.InvalidateConversationHistory(conversationID)deletes the conversation fromr.conversations/r.conversationTouched/r.conversationMessageWatermarksso the next read falls through to the store.internal/server/http_conversations.go:handleRestoreRewindcalls the new invalidation method after a successful restore, before responding.RewindPoint.MessageBoundaryisomitemptyand additive), no new routes, no config/env changes.conversations.workspace/tenant handling, approval/permission gating, and file-restore logic are unchanged.RewindPoint/RewindStore(search:runner_step_engine.go,rewind_store_test.go,runner_test.go,http_rewind_test.go) either doesn't readMessageBoundary(unaffected, and correctly exercises the legacy fallback — e.g. bug(rewind): external-modification guard refuses points older than the latest agent edit (stale expected_hash) #1371's own tests) or is one of the tests added/updated here.Architecture and duplication check
RestoreRewindPointonSQLiteConversationStore(internal/harness/conversation_store_sqlite.go) is the single owner of both rewind truncation queries (messages and points);Runner.InvalidateConversationHistoryis the single owner of in-memory-mirror invalidation, placed next to the existingConversationMessages/ConversationMessagesSnapshotmethods it is meant to keep coherent with. No parallel truncation path, no second conversation-store implementation, no new HTTP route. The rewind HTTP handler (internal/server/http_conversations.go) is the only caller that needed wiring — it already owned the workspace/owner lookup and theRestoreRewindPointcall.Test-first evidence
Initial fix (red
ff24b839→ greendb137258→ regressiona7a0b6f8):Red command:
Observed failure:
This reproduced the exact run-local-step-vs-conversation-wide-index bug from the issue at both the store and HTTP layers, plus the live-mirror-never-invalidated bug (
after rewind: got 8, unchanged frombefore rewind: got 8). Both behavioral, not compile/import errors.Regression:
TestRewindThenNextRunDoesNotResurrectTruncatedMessages(internal/harness/runner_test.go) drives a real three-runRunnerflow and proves the run following a rewind doesn't resurrect the truncated tool result/final answer in its LLM request. Confirmed meaningful by temporarily removing therunner.InvalidateConversationHistory(convID)call from the test body alone (implementation untouched): it failed withrun3's context resurrected run2's truncated tool result; restoring the call turned it back to PASS.Review follow-up 1 (red
3b6e0833→ greene934fbd9→ docs354c0f53):Red command:
Observed failure:
This test drives a real single-turn run with two parallel tool calls in one assistant turn through the actual step-engine capture path and restores using either call's point, asserting: both calls' points share one
MessageBoundary, the last persisted message is never an assistant message withtool_calls, and no tool message lacks a preceding assistanttool_callsentry for its ID. The two pre-existing tests above were updated to the corrected semantics (TestRestoreRewindPoint_MultiRunTruncatesOnlyAfterPoint: boundary 6→5,MessagesTruncated2→3;TestRestoreRewindEndpoint_...:MessagesTruncated2→3, kept messages 6→5) and both gained the same "no danglingtool_calls" assertion, and both failed for the same behavioral reason before the fix (see commit3b6e0833's message for the full failure output of all three).Review follow-up 2 (red
b0d814a2→ green2f2a1f58→ docs549ca9b7):Red command:
Observed failure:
This test drives two real runs (run 1 writes a.txt, run 2 edits it), restores to run 2's edit point, then restores to run 1's write point — reproducing the exact 404 from the coordinator's live verification. Behavioral, not a compile error.
Green command (all, after both follow-ups):
All PASS.
Verification evidence
Rebased onto latest
origin/maintwice as it moved during review (final tip2f3e1609, picking up #1372/#1376/#1373/#1384/#1383/#1382/#1371/#1377/#1381/etc.) withgit rebase origin/main. Both rebases conflicted only indocs/logs/engineering-log.md(and once indocs/runbooks/session-rewind.md) — same-day entries/sentences from this branch landing alongside #1371's and #1381's own same-day entries — resolved by keeping both sides' content in every case.Targeted (all PASS, post-rebase):
Package suites, race, and vet (all PASS, post-rebase):
Full repository package sweep (
go test ./internal/..., run twice for reproducibility, prior to the rebases): every package passes exceptinternal/acceptance/ptyrunner, which fails the same way on both runs (PTY did not create completed run for prompt ...). That package has zero import ofinternal/harnessorinternal/serverand no reference to rewind at all — it drives a realharnessclibinary through a spawned PTY (github.com/creack/pty), which this sandboxed environment doesn't support cleanly. This is a pre-existing, unrelated, environment-dependent failure, not caused by this change.I did not run the full
scripts/test-regression.sh(it runsgo test ./...twice plus a coverage gate over the whole repo, including Python-onlybenchmarks//harness_agent/dirs that don't build as Go perCLAUDE.md, and would take considerably longer than this fix's scope warrants); the scoped commands above are the smaller regression surface documented for a change of this size.Real user path: not exercised as a running daemon (no live
harnessd/harnessclisession was started for manual verification) — the HTTP-level test drives the real handler (internal/server/http_conversations.go) end-to-end throughnet/http/httptest, including two realRunner.StartRuncalls with a mutating tool, which is the closest available proxy to the issue's live repro without standing up a daemon process.Rollout and rollback
No migration required beyond the automatic idempotent
ALTER TABLE rewind_points ADD COLUMN message_boundary INTEGER NOT NULL DEFAULT 0that already runs inMigrate()alongside the repo's other incremental column additions — no operator action needed. Existing rewind points recorded before this change havemessage_boundary=0and fall back to the previous (imperfect) step-based truncation and pruning with a logged warning, so they remain usable rather than being invalidated. Rollback is a revert of this PR; reverting does not need a down-migration since the added column is additive and unused by anything else. Observability: the fallback path logs via the standardlogpackage naming the point ID and conversation ID whenever a legacy point is restored.Documentation
docs/runbooks/session-rewind.md: explains the conversation-wide index of the assistant tool-call message (not a raw message count, and not the run-local step) as the truncation boundary, that pruning of superseded rewind points uses the same boundary, and the live in-memory mirror invalidation.docs/logs/engineering-log.md: a2026-09-05 (Issue #1370 ...)entry plus two same-day follow-up entries documenting the dangling-tool_callscorrection and the rewind_points prune correction, following the file's existing per-issue cause/fix/regression format.Contract checklist
internal/acceptance/ptyrunner, an unrelated pre-existing failure, and on not running the full repo-widescripts/test-regression.sh)🤖 Generated with Claude Code
https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5