Skip to content

Add fx session fork and fx session rewind - #560

Draft
Aarya2004 wants to merge 7 commits into
vercel-labs:mainfrom
Aarya2004:aarya/session-fork-rewind
Draft

Add fx session fork and fx session rewind#560
Aarya2004 wants to merge 7 commits into
vercel-labs:mainfrom
Aarya2004:aarya/session-fork-rewind

Conversation

@Aarya2004

Copy link
Copy Markdown

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 /fork and /rewind slash 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.

$ fx session fork <id> --at 2
[session fork] forked <src> to <dst>
history_turns: 2 of 4
resume: fx --resume <dst>

$ fx session rewind <id> --by 1 --json
{"kind":"session_rewind","id":"<id>","status":"rewound","history_turns":3,"removed_turns":1}

Scope

Store.forkSessionCopy and Store.rewindSession in src/core/session/session_store.zig. Fork reuses the staging pipeline that recoverSessionCopy already 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 skips recoverManifestBoundary, which repairs a corrupt log a healthy source does not have. Rewind truncates in place under the same session id and copies nothing.

ReplacementReason gains fork and rewind in src/core/session/session_event.zig. In session_log.zig, fork joins migration and recovery on immediate cache publication because it writes a new session; rewind joins compaction because it rewrites the same one.

The first commit renames eight private staging helpers from recovery* to staging*, 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.

SessionForkResult and SessionRewindResult in session_store_types.zig, mirroring SessionRecoveryResult. SessionForkSnapshot and SessionRewindSnapshot in output_contracts.zig, rendering text and JSON from one struct. Two parsers and two dispatch blocks in cli_surface.zig beside the existing recover and migrate blocks, 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: /fork and /rewind, and every file under src/core/slash_commands/ and src/core/app/.

Tradeoffs

Relative addressing is not idempotent. Running rewind --by 2 twice removes four turns. The durable layer takes an absolute count so it stays deterministic, and --by is 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: compactCanonicalLogIfDue rewrites 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.json is a projection and events.jsonl is 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_start is clamped to the retained length, or a resumed fork would build model context from an out-of-range cursor.

Existing commands are unaffected. recoverSessionCopy keeps its behavior; only private helper names moved. No public API was removed.

Rewind refuses a session another process holds open, and returns already_at_target without writing when there is nothing to drop.

Verification

zig fmt --check src/ and zig build clean.

zig build test reports 8640 passed, 20 skipped, 10 failed. The 10 failures are pre-existing and unrelated: session-picker tests in app_session_runtime, command_effect native printf, builtins.tools terminal schema, transcript.runtime_tests tool status, and app_mcp_runtime authority 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.ts 7 pass, 0 fail. bun test cli.test.ts 113 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 the forked_with_unverified_artifacts status are exercised only through recovery's own coverage of those helpers, never through forkSessionCopy. Worth a follow-up.

session-fork.test.ts is classified verification-only in scripts/pgso/corpus.json as verify-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: feature label. I cannot apply labels on this repository.

Aarya2004 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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant