Repository navigation
feat: stable turn ids, per-turn workspace snapshots, and conversation history that survives restart (#354) - #361
Merged
Merged
Conversation
…history that survives restart Foundation for going back a turn, forking from history, comparing backends and per-turn diffs (#354). Backend-agnostic: everything comes from codeoid's own records, never a backend's native session. - Every turn gets a turnId, stamped on each SessionMessage produced while it runs and on the canonical history (user and assistant turns). - At turn start in a git workdir, the working tree (tracked + untracked, non-ignored) is snapshotted under refs/codeoid/checkpoints/<session>/<turn> via a throwaway index and commit-tree — branch, index, stash, working tree and git log untouched. Bounded (untracked size/count caps, timeouts, a 2s max wait before the turn starts), pruned per session, deleted on destroy. Config: session.checkpoints. - The canonical history is persisted per session (<id>.canonical.jsonl) and restored on resume; sessions from before the log are rebuilt once from the transcript. Before this, after a restart a fork or backend switch carried no conversation, and stateless backends answered with no memory of it. - session.turns lists a session's turns with their snapshots. - A Stop (or drain) while a send is still preparing its turn now cancels it; the turn used to start anyway after the Stop. - TranscriptStore.append's stored chain no longer leaks an unhandled rejection on a failed write, and clears itself. Closes #354 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ounded history log Audit round 1 on #354: Security (High): snapshots ran git inside the user's repository, so its own config ran code on every turn outside the approval gate — core.fsmonitor, filter drivers, reference-transaction hooks — with the daemon's environment. Checkpoints now live in a daemon-owned shadow repository per session (<transcriptDir>/checkpoints/<session>.git, GIT_WORK_TREE = the workdir): config comes only from the shadow repo (system/global off), hooks and fsmonitor are forced off, the environment is an allowlist with no GIT_*. Nothing lands in the user's repo (no pushable refs, no leftover objects), destroy is rm -rf, the user repo's objects are an alternate for dedup, the snapshot is scoped to the session's directory, common secret files and dependency dirs are excluded, it works in non-git directories, refs are trusted only when they match the daemon's own turns.log, storage is capped per session, one snapshot runs at a time, and unknown sessions' repos are swept at resume. Correctness: - A Stop before a send's turn starts throws SendStoppedError: a pipeline phase or background wake waiting on that turn no longer hangs or drops reports; SessionManager#send doesn't report it as a failure. - A message sent mid-turn joins the running turn instead of stealing its output; no snapshot wait on that path. - The snapshot is taken at the last moment before the agent starts; one still running when it starts is recorded as late. - A turn index separate from the canonical history: rotation no longer empties session.turns; refused/stopped sends aren't listed and hand attribution back. - Forks inherit the parent's turns and snapshots, which outlive the parent; forks after a restart use the whole history log. - The history log caps oversized fields, compacts past 32 MiB, is read with a 4 MiB tail on resume, serializes at call time, and is owner-only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…join backend turns after the snapshot Audit round 2 on #354: - Checkpoint repos are fully self-contained: no copied user index (an agent could plant skip-worktree entries naming any blob; assume-unchanged and split-index broke snapshots) and no alternates (a rewrite + gc in the user repo corrupted snapshots). A snapshot holds exactly the work tree's bytes, stored in the shadow repo. First snapshot is capped by total size and file count, later ones by new files; a missing index (a fork's copied history) is a first snapshot, so forks keep snapshotting. - Exclusions are :(exclude,glob) pathspecs — a .gitignore negation can't pull .env or keys back in, and tracked secret files stay out — with a longer list; the daemon's data dirs are excluded too. - Shadow repos are created atomically; a stale index.lock is cleared; a nested repository with no commit no longer fails every snapshot; over the storage budget the oldest snapshots are dropped (down to none) so the latest always fits; space is reclaimed in batches with repack + prune; turns.log rewrites are atomic. - The "did the backend start its own turn?" check runs after the snapshot wait: a turn started in that window is joined instead of having its queue closed by a second runTurn. - The history log can't compact or tail-read to empty: tail reads widen until they hold a prompt, and a whole turn is capped (1 MiB). - Sessions from before the log are rebuilt from the whole transcript, once. - A snapshot taken for a send that never became a turn is deleted; fleet event delivery survives a stopped send; a stopped phase reports that; the turn index is capped; a fork inherits only turns its history contains. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The env-leak test sets GIT_DIR / GIT_INDEX_FILE / GIT_OBJECT_DIRECTORY on process.env; its own git show then inherited the decoy object directory, which newer git (CI) honours over --git-dir. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Oct 10, 2026
feat: go back a turn — take back messages on any backend, optionally restoring the files (#355)
#362
Merged
KunalJavelin
approved these changes
Oct 10, 2026
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 #354
What this does
Every turn in a session now has a stable identity, codeoid snapshots the files each turn starts from, and the conversation history survives a daemon restart.
This is the foundation for going back a turn (#355), forking from an earlier point (#356), comparing backends (#357) and per-turn diffs (#358).
It works the same on every backend, today's and future ones: nothing depends on a backend's own session format.
session.turns(order, a short preview of the prompt, when it started, and its snapshot).git log --all, no hooks or filters run. It works in folders that aren't git repositories, and leaves out.gitignored files, common secret files (.env, keys, credential files) and dependency folders. If a snapshot is still being taken when the agent starts, it's marked "late".How it was checked
Live, against a real daemon with the real Claude backend (a scratch git repo with an untracked
.env):hello.txtsession.turnslists both with snapshots. Turn 1's snapshot holds onlyREADME.md(no.env), turn 2's holdshello.txt=one. The user repo has no new refs or log entries.mainTests: typecheck, lint and build pass, plus 2,848 daemon tests and 577 web tests. New:
checkpoints.test.ts(21),turns.test.ts(20) andcanonical-log.test.ts(14).core.fsmonitor, filter driver or hook never running; inheritedGIT_*variables ignored; snapshots surviving deletion of the user's.git; planted skip-worktree / assume-unchanged / split-index entries having no effect; a.gitignorethat negates.envnot pulling it in; nested repositories; stale locks; storage budget; forks.Audits: two rounds, each with a correctness reviewer and a security reviewer. All findings were fixed in this PR.
Behaviour changes and limits
session.checkpoints(enabled,maxPerSession,maxUntrackedBytes,waitMs)..env.exampleis excluded along with.env, because the exclusions are deliberately not overridable.🤖 Implementation context (for agents / maintainers)
Files
src/daemon/checkpoints.ts: shadow repo per session at<transcriptDir>/checkpoints/<id>.git, used withGIT_WORK_TREE=<workdir>.GIT_CONFIG_NOSYSTEM=1,GIT_CONFIG_GLOBAL=/dev/null,-c core.hooksPath=/dev/null -c core.fsmonitor=false.:(exclude,glob)pathspecs. Thenadd -A,write-tree,commit-tree,refs/turns/<turnId>, and a line in the daemon-ownedturns.log(order, plus thelateflag).turns.logare trusted.repack -a -d+prune. AlsocopyCheckpoints(fork, viafetch),deleteCheckpoint(s), andsweepCheckpoints(unknown session ids, run at resume).src/daemon/session.ts:#currentTurnIdis stamped by#makeMessage. In#sendInner:joinsRunningTurnfor mid-turn pushes; hooks, then snapshot, then stop-check, then the adopted-turn check, thenrunTurn; a try/catch hands attribution back.SendStoppedErrorvia#stopGen/#preparingSends(andpreparingTurnfor drain).#turnIndex/#indexTurn(capped at 1000).restoreTurns,fullCanonicalHistory,inheritTurns.src/daemon/providers/canonical.ts:CanonicalTurngainsturnId/prompt/at/background. The accumulator emitsonChange(append/replace), andrestore()loads without re-emitting.src/daemon/transcript.ts:<id>.canonical.jsonl: serialized at call time, field caps plus a 1 MiB whole-turn cap, compaction at 32 MiB, tail reads that widen until they hold a prompt, 0600.<id>.turns.jsonlturn index.#chainhelper. This also fixesappend()'s stored promise chain, which leaked an unhandled rejection on a failed write and never cleared itself.src/daemon/session-manager.ts:fullCanonicalHistory+inheritTurns.session.turns(attach/watch scope + ownership).SendStoppedErrorhandled in#send, in the pipeline phase driver and in fleet event delivery.drain()counts sends that are still preparing.packages/protocol/src/turns.ts(session.turns/session.turns.result,TurnSummary) andSessionMessage.turnId.src/daemon/canonical-restore.ts:canonicalFromTranscript,turnIndexFromHistory.Known edges
fsmonitorand the untracked cache are deliberately off for snapshots, so a snapshot walks the tree.🤖 Generated with Claude Code