dsh-checkpoint
Git-snapshot checkpoint/rollback capability for DeepSeek Harness: revert files and conversation to a previous completed turn.
- Stars
- 0
- Language
- TypeScript
- Created
- Sep 1, 2026
- Updated
- Sep 1, 2026
Introduction
dsh-checkpoint
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 UI says so.
Status: pre-implementation design, spike-first. This README is the implementation hand-off: the decisions are made, the harness extension points are pinned, and the first task is a bounded spike. A new session can implement the plugin from this document alone — the master design doc (
dsh-main-loop-sisyphus-spec.md§5 in the deepseek-harness checkout this was authored against) is optional background.
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
<data-dir>/checkpoint/<sessionId>/(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. - 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, surfaced to the UI through a projection wire value. - 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).
Interface contract
Session events
Declared by module augmentation (plan-mode is the template — packages/plan/plan-mode/src/index.ts and src/types.ts):
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/** log-only, non-surface, whole-value. One checkpoint per completed turn. */
'checkpoint/captured': {
turn: number
treeHash: string | null // null when the workspace is not a git repo
changed: string[] // paths changed during this turn
isGitRepo: boolean
}
/** log-only, non-surface. One restore action. */
'checkpoint/restored': {
turn: number // target turn rolled back to
treeHash: string | null
restored: string[] // files actually restored (empty for context-only)
}
}
}
Plus an invariant companion (src/invariant.ts) validating both payloads, registered like plan-mode's.
Projection
Register { key: 'checkpoints', stateSchema, init, apply, wire, stateVersion } on ctx.sessionProjections. State folds checkpoint/captured into a per-turn index { turn, treeHash, changed }[]. The wire.view exposes the rollback-point list and { isGitRepo }, which flows to the web UI automatically (session-controller broadcasts projection updates) — this is the "not a git repo" hint channel.
Tools
checkpoint_list— args{}; result lists rollback points (turn number, changed-file count, timestamp) andisGitRepo.checkpoint_restore— args{ turn: number }; performs fork+reseed + file restore + pre-revert self-snapshot; result states what was restored and that later turns are no longer part of this conversation.
Config
interface Config {
untrackedFileMaxBytes?: number // default 2 MiB
}
Plugin shape: function plugin, named exports name / inject / Config / apply, no default export (harness convention). Inject the services below.
Pinned harness extension points (verified; do not re-research)
agent/pre-step(waterfall, declared bydsh-agent, dispatched bydsh-agent-loop):(payload { agent, messages, turn, step, signal }, next) => Promise<PreStepDecision>. The canonical capture hook — callawait next()first, then, only ifdecision.kind === 'enter' && !signal.aborted, run the capture andagent.session.append(...). plan-mode (packages/plan/plan-mode/src/index.ts) is the reference implementation of exactly this pattern.Session.append(packages/core/session/src/index.ts) does not expose anignorableparameter — this is the spike's whole subject.KNOWN_SESSION_EVENT_TYPES(packages/core/session/src/known-event-types.ts) is repo-static; external events not in it are required-on-read, so a first-party persistence load refuses a session carrying them unless the envelope saysignorable: true. Background: Agent Note.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.mdin the harness checkout.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 rows expect (for the test composition — the real web profile supplies all of them):
shell+shellEnv(frombash-local/shell-env),fs(fs-local),subprocess(subprocess-local),web(dsh-web+ a provider),userQuestions(dsh-user-questions),jobs(jobs-local),skills,subagents(+spawn/forkproviders). Function plugins register as namespace modules, service classes as default exports.
Task 0 — the ignorable spike (do this before everything)
Question: can this plugin's live events carry ignorable: true?
- Read
Session.appendand the persistence layer (packages/session/session-persistence, thePersistenceCoordinatorrefusal path) in a deepseek-harness checkout. Determine whether any append/restore path accepts anignorablemarker for live events. - Either:
- A (preferred): a route exists — declare the checkpoint events informational, mark them ignorable, keep them in the session log.
- B: no route exists — move the snapshot index into a plugin-owned side store (
ctx.storage), keep only the restore action as a surface event, and update this README's Interface-contract section accordingly.
- Write the answer into this README (replace this section) and proceed.
The decision is architectural, not cosmetic: option B changes where checkpoint state lives, which is why it gates all further code.
Implementation order
- Spike (§Task 0), record the outcome.
src/git-snapshot.ts— the isolated git object DB (init/track/restore/patch), including alternates seeding and the untracked size cap.src/index.ts— the real function plugin: Config, service-inject list, git-worktree detection,agent/pre-stepcapture hook, event append.src/types.ts+src/invariant.ts— event declarations and payload validation.- Projection registration + wire view.
checkpoint_list/checkpoint_restoretools + fork+reseed + staged restore.- Non-git degradation path (context-only rollback + projection hint).
- Tests — a REAL-composition test (harness policy: product plugins need one; boot a test cordis.yml through the Loader) plus focused unit tests for the snapshot module (tmpdir-based, no network).
- 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_listreports context-only rollback; files untouched; the UI hint fires. - Event payloads pass the invariant; the projection wire value reaches the client through session-controller.
checkpoint/capturednever breaks a first-party session reload (the spike's outcome made explicit).- 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 public registry cadence, so develop with a local checkout:
- Clone
deepseek-harness,pnpm install && pnpm run build(the build generateslib/typert.*artifacts the launch needs). - In this repo, add the harness packages you import as
devDependenciesvia afile:orlink:path (or a pnpm workspace overlay). - Validate exactly the way this design was validated for the sisyphus preset: 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 boot test in this repo is the long-term home; the in-harness scratch spec is the fast iteration loop.) For a real-model smoke, load the key from the harness root.envand routedeepseek-official/deepseek-v4-flash.
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