Add fx session fork and fx session rewind - #560
Draft
Aarya2004 wants to merge 7 commits into
Draft
Conversation
added 6 commits
August 31, 2026 23:47
The staging lock, staging root, staged-session start, promotion, and discard helpers are generic. Session fork will call them too, so the `recovery` prefix would misdescribe them. The on-disk directory and lock file names are unchanged. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
A fork writes its history into a brand-new session, so its cache publication cannot be deferred. A rewind rewrites the same session in place and follows compaction. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`forkSessionCopy` stages a new session holding the first N turns of a healthy source, copies only the artifacts those turns reach, and commits the branch history as a `fork` state replacement. The source is opened under its writer lock and read through the read-only replay, so it is never rewritten. `rewindSession` truncates the same session in place under a `rewind` state replacement. Artifacts for the dropped turns are left on disk. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
Both verbs render text and JSON from one snapshot, following the session recovery contract. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
`fork --at <turn>` names an absolute boundary read straight off the `[turn N]` labels `fx session <id>` prints. `rewind --by <count>` names a relative one, which is how undoing the last turns is asked for. The CLI resolves both to the absolute retained-turn count the store takes, and reports an out-of-range request with the session's real turn count. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
The e2e suite drives the built binary against a fake gateway: it seeds a multi-turn session, forks it, resumes the branch, and checks the source directory is byte-identical afterward. Classified verification-only in the PGSO corpus, since branching and undo are deliberate rare operations that must stay correct without being made hot. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
1 task
A recovery checkpoint describes an in-flight turn that sits past the last committed one, so any rewind that drops turns leaves it describing work the session no longer has. Keeping it let `--continue-recovery` resume a response for a turn that was removed. `forkSessionCopy` already cleared it. A no-op rewind still keeps it, because that path drops nothing and commits nothing. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD
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 part of #537.
Why
Long sessions reach a point where a user wants to try another approach or drop recent turns. Today the only ways out are
/clear, which starts over, and/compact, which hides history from the model without removing it. Neither preserves a branch point.This adds the durable half of #537: branch a session at a turn, or drop trailing turns from one. Both are non-interactive, so they work in scripts and from a second terminal. The
/forkand/rewindslash commands are deliberately out of scope and stack on top of these primitives.Turn numbers are the ones
fx session <id>already prints. Read the conversation, pick a turn, name it. No new addressing scheme.Scope
Store.forkSessionCopyandStore.rewindSessioninsrc/core/session/session_store.zig. Fork reuses the staging pipeline thatrecoverSessionCopyalready uses: stage a new session under the staging root, copy only the artifacts the retained turns reach, commit the truncated history as a state replacement, validate, then rename into place. It skipsrecoverManifestBoundary, which repairs a corrupt log a healthy source does not have. Rewind truncates in place under the same session id and copies nothing.ReplacementReasongainsforkandrewindinsrc/core/session/session_event.zig. Insession_log.zig,forkjoinsmigrationandrecoveryon immediate cache publication because it writes a new session;rewindjoinscompactionbecause it rewrites the same one.The first commit renames eight private staging helpers from
recovery*tostaging*, since fork is now their second caller. On-disk names are unchanged, so staged directories written by earlier versions are still found and cleaned up.SessionForkResultandSessionRewindResultinsession_store_types.zig, mirroringSessionRecoveryResult.SessionForkSnapshotandSessionRewindSnapshotinoutput_contracts.zig, rendering text and JSON from one struct. Two parsers and two dispatch blocks incli_surface.zigbeside the existingrecoverandmigrateblocks, with five new errors added to both the text and JSON failure switches.Flag spellings differ on purpose.
fork --at <turn>is absolute, because you read[turn 7]and branch there.rewind --by <count>is relative, because the thought is "undo my last two turns". Both resolve to one absolute retained-turn count at the argument boundary, so the store layer has no relative path.Out of scope:
/forkand/rewind, and every file undersrc/core/slash_commands/andsrc/core/app/.Tradeoffs
Relative addressing is not idempotent. Running
rewind --by 2twice removes four turns. The durable layer takes an absolute count so it stays deterministic, and--byis resolved against the live history before any write.The fork carries the source token totals rather than recomputing them. There is no per-turn usage ledger to recompute a truncated total from, and zeroing would misreport what producing that history cost.
The fork opens its source under the writer lock, as recovery does, so a concurrent writer cannot tear the artifact copy. The state is read through read-only replay, and the source is left byte-identical.
Rewind drops turns from the active conversation but deletes nothing. The state replacement is appended, so the dropped turns stay readable until the log compacts, and their artifacts stay on disk. This is narrower than "recoverable forever", which the append-only log does not guarantee:
compactCanonicalLogIfDuerewrites it past 4096 frames or 128MB.Blast Radius
Touches durable session storage, so a bug here corrupts a user's history. Three properties carry that risk and each has a test.
The source of a fork is never modified.
session.jsonis a projection andevents.jsonlis authoritative, so a new session is created empty and then receives a state replacement; a pre-populated manifest would produce a session whose log replays to zero turns.context_history_startis clamped to the retained length, or a resumed fork would build model context from an out-of-range cursor.Existing commands are unaffected.
recoverSessionCopykeeps its behavior; only private helper names moved. No public API was removed.Rewind refuses a session another process holds open, and returns
already_at_targetwithout writing when there is nothing to drop.Verification
zig fmt --check src/andzig buildclean.zig build testreports 8640 passed, 20 skipped, 10 failed. The 10 failures are pre-existing and unrelated: session-picker tests inapp_session_runtime,command_effectnative printf,builtins.toolsterminal schema,transcript.runtime_teststool status, andapp_mcp_runtimeauthority reduction. A run on a worktree at the base commit was in progress at submission time; Full CI is the authority.bun test session-fork.test.ts7 pass, 0 fail.bun test cli.test.ts113 pass, 6 skip, 0 fail, confirming the updated help-text assertions.Nine Zig unit tests cover the truncation arithmetic, cursor clamping, range validation, the no-op rewind, the busy-session refusal, and that a fork leaves its source unchanged.
Driven on the built binary against real multi-turn sessions produced through the agent loop, not hand-written fixtures. Verified there: the source directory is byte-identical after a fork; the fork holds the first N turns in order; a forked session resumes and continues, which is what proves the event log and manifest agree; rewind keeps the session id; the pre-rewind log is a strict byte prefix of the post-rewind log; and a fork at turn 1 from a four-turn source resumes without an out-of-range cursor. Also exercised
--at 0, a missing--at, a non-numeric--at, unknown ids for both verbs, and both verbs run from a second workspace against a session bound to the first.Not verified: no test seeds a session carrying image attachments or managed child artifacts, so
copyRecoveredImageSnapshots,rebaseRecoveredImageSnapshots, and theforked_with_unverified_artifactsstatus are exercised only through recovery's own coverage of those helpers, never throughforkSessionCopy. Worth a follow-up.session-fork.test.tsis classified verification-only inscripts/pgso/corpus.jsonasverify-session-fork. Forking and rewinding are deliberate, infrequent, recovery-adjacent operations that must stay correct without consuming PGSO budget that belongs on the ask and resume paths.This PR needs the
type: featurelabel. I cannot apply labels on this repository.