dsh-checkpoint
September 6, 2026 · View on GitHub
English | 中文
Git-snapshot checkpoint/rollback capability for DeepSeek Harness (dsh).
Roll a session back to a previous completed turn: in a git worktree it restores both the files and the conversation; in a non-git worktree it rolls back only the conversation and the tool result says so.
Status: spike resolved, implementing. The Task 0 spike is complete with outcome B; the interface contract below is the post-spike design. This README is the authoritative hand-off document.
Design (decided)
- Capture granularity: per turn, not per model step. One turn = one user interaction plus its agent work; rollback lands on completed
turn/endboundaries. Per-step capture (opencode-style) is a later enhancement, explicitly out of MVP. - Storage: an isolated git object database per session at
<dataDir>/checkpoint/<sessionId>/git/(git initwithGIT_DIR/GIT_WORK_TREE), holding tree hashes only — no commits, branches, or refs in the user's repo. When the workspace is already a git repo, seed the object DB from it viaobjects/info/alternatesplus a copied index, so already-hashed blobs are reused (near-zero cost on large repos). Ignored files are skipped; untracked files above a size cap (default 2 MiB) are skipped. - Snapshot index is a plugin-owned file at
<dataDir>/checkpoint/<sessionId>/index.json(one record per turn: turn, tree hash, changed paths, timestamp). See §Task 0 for why the index does not live in the session log. - Git detection with graceful degradation: resolve a git worktree once per session (
git rev-parse --is-inside-work-tree). Git repo → file+conversation rollback. Not a git repo (or nogitbinary) → conversation-only rollback;checkpoint_listreportsisGitRepo: falseandcheckpoint_restorerefuses file restore. - Rollback = fork + reseed, preserving the append-only session-log invariant:
ctx.sessions.fork(source, boundarySeq, childId)cuts the log at the target turn's inclusiveturn/endseq; the git layer restores files to the same boundary. There is no in-place truncation. - Staged revert: before restoring, snapshot the current state once more, so the undo is itself undoable (opencode semantics).
Task 0 — spike outcome (option B)
Question: can this plugin's live events carry ignorable: true? Answer: no.
Session.append(packages/core/session/src/index.ts) builds the envelope from{ type, seq, time, data, surfaceOp?, sourceEventSeqs? }only;SurfaceIntenthas no further fields. The live path has no channel for the marker.- The persistence read path (
session-persistence/src/coordinator.ts, the unknown-type guard) refuses any event type outsideKNOWN_SESSION_EVENT_TYPESunless the persisted envelope carriesignorable: true. - No producer in the harness writes
ignorable: true; the seed/restore envelope validator accepts the key, but live production cannot reach it.
Consequence: the plugin appends no custom session events. The snapshot index is a plugin-owned file; model visibility rides on the two tools, whose tool/call/tool/result are known event types (reload-safe). If the harness later opens an ignorable path for live appends, the plugin can migrate back to logged checkpoint/captured events plus a projection — do not re-add events before that.
Interface contract
Side-store index (<dataDir>/checkpoint/<sessionId>/index.json)
interface CheckpointRecord {
turn: number
treeHash: string | null // null when the workspace is not a git repo
changed: string[] // paths changed during this turn
capturedAt: number // epoch ms
}
Tools
checkpoint_list— args{}; result:{ isGitRepo: boolean, points: { turn, changedCount, capturedAt }[] }.checkpoint_restore— args{ turn: number }; performs fork+reseed + file restore + pre-revert self-snapshot; result:{ restored: string[], childSessionId?: string, contextOnly: boolean, undoPoint?: number }. In a non-git worktree,restoredis empty andcontextOnly: true.
Command
/checkpoint <turn>— registered on the harness commands service when present (user-invoked from the command palette; no model turn involved). Rolls the active session back to the turn likecheckpoint_restore, rendering the outcome directly in the UI. A trailing number names the turn; empty input replies with usage and the available turns.
Config
interface Config {
dataDir?: string // default: <dsh home>/checkpoint
untrackedFileMaxBytes?: number // default 2 MiB
}
Plugin shape: function plugin, named exports name / inject / Config / apply, no default export (harness convention). Injects subprocess, sessions, tools.
Pinned harness extension points (verified; do not re-research)
agent/pre-stepand thesession/eventfeed are the capture triggers — the plugin only READS them; it appends nothing. Listen forturn/start(capture pre-tree) andturn/end(capture post-tree + patch). The session's workspace issession.header.cwd(durable).ctx.sessions.fork(source, boundary?, childId?)— inclusive boundary seq; the selected prefix must end outside an open turn (elseOPEN_TURN); error codesSESSION_NOT_FOUND | SESSION_NOT_LIVE | SESSION_ALREADY_EXISTS | INVALID_BOUNDARY | OPEN_TURN. Child inheritscwd, stampsparentSession, setsseedLength.- Subprocess — run git through
ctx.subprocess.spawn({ argv, cwd, ... }); resolve the binary withctx.subprocess.resolveExecutable('git')and degrade gracefully when it resolves to nothing. Noprocess.platformbranching anywhere. - Host services the plugin expects in a test composition (the real web profile supplies all of them):
sessions,subprocess(subprocess-local),tools; a full-session smoke additionally needs the subagent stack,shell/shellEnv,fs,web,userQuestions,jobs,skills— mirroring any preset-driven session.
Implementation order
- ✅ Spike (§Task 0) — outcome B recorded.
src/git-snapshot.ts— the isolated git object DB (init/track/restore/patch), alternates seeding, untracked size cap. Unit-tested against a realgitbinary in a tmpdir.src/index.ts— the function plugin: Config, git-worktree detection per session, capture hooks, index.json management, the two tools, fork+reseed + staged restore.- Tests — unit (git-snapshot) plus a real-composition smoke inside a harness checkout (boot the spine, run a turn that edits files, restore, assert files revert).
- README (this file, kept current) + packaging cleanup.
Acceptance criteria
- In a git worktree: run a turn that edits files,
checkpoint_restoreto the previous turn — files restored, conversation cut at that turn, and the restore itself undoable. - In a non-git worktree:
checkpoint_listreportsisGitRepo: false;checkpoint_restorerolls back conversation only and reportscontextOnly: true; files untouched. - Sessions carrying checkpoint activity reload cleanly in a first-party build (no custom events in the log).
- No
binentry; ESM-only;@deepseek-ai/cordis+ every imported@deepseek-ai/dsh-*inpeerDependencies.
Developing against a harness checkout
The harness packages are not yet on a stable public registry cadence, so develop with a local checkout:
- Clone
deepseek-harnessnext to this repo,pnpm install && pnpm run build(the build generateslib/typert.*artifacts the launch needs). - In this repo, add the harness packages you import as
devDependenciesviafile:paths (e.g.file:../deepseek-harness/packages/core/session). - Fast iteration: a temporary vitest spec inside the harness checkout that boots the harness spine in-process with
ctx.baseUrlpointed atapps/cli, mounts the plugin under test from this repo, and drives it — then delete the spec. A real-composition boot test in this repo is the long-term home.
Boundaries and non-goals
- Per-turn only (no per-step capture).
- Local git worktrees only; no filesystem-copy fallback (scheme C) for non-git workspaces or remote backends (e2b/subprocess).
- Conversation rollback always works; file rollback is explicitly best-effort and scoped to snapshotted files (ignored files, oversized untracked files, and shell side effects are never restored).
License
MIT