feat: checkpoint restore — revert code + agent memory to any prior turn, across local ↔ cloud#3390
Open
Basit-Balogun10 wants to merge 23 commits into
Open
feat: checkpoint restore — revert code + agent memory to any prior turn, across local ↔ cloud#3390Basit-Balogun10 wants to merge 23 commits into
Basit-Balogun10 wants to merge 23 commits into
Conversation
- Add checkpoint tRPC router with restore procedure that reverts git state, truncates session JSONL to the restore point, and deletes orphaned checkpoint refs for abandoned future turns - Track lastCheckpointId per turn in buildConversationItems so each completed agent turn knows its git ref - Show per-turn restore button in AgentMessage (disabled with tooltip when no checkpoint exists for that turn) - Add CheckpointTimelineModal (mod+shift+h) — command-palette-style list of all checkpoints in the session, newest first, with user message snippet and relative timestamp; shortcut is user-remappable via keybindings store - Add RestoreCheckpointDialog with confirmation warning before reverting - Add useRestoreCheckpoint hook to wire restore flow end-to-end - Register checkpoint-timeline as a configurable shortcut Closes PostHog#2328
The cloud path (agent-server.ts) guards checkpoint capture on posthogAPI being configured, so local tasks never emit _posthog/git_checkpoint. Hook into extNotification in the local AgentService: on TURN_COMPLETE, run CaptureCheckpointSaga, then emit a synthetic _posthog/git_checkpoint ACP message to the renderer and append it to the session JSONL so it survives reload. The renderer's buildConversationItems already handles the notification correctly — it just wasn't arriving. Add console logs in buildConversationItems and structured logs in service.ts for visibility during debugging.
extNotification is not reliably called by the ACP SDK for _posthog/ notifications in the local path. The raw stream tap (onAcpMessage) is guaranteed to fire for every ndjson frame — move the TURN_COMPLETE checkpoint hook there instead.
Suppress codex-acp loadSession replay at the SDK layer so resumed sessions don't re-persist history to logs.ndjson/S3, and truncate the on-disk rollout + local logs to the checkpoint boundary so the agent remembers only up to the restored point. - Add suppressReplay gate on CodexSessionState; codex-client drops session/update events while a loadSession replay is in flight - Toggle the flag around loadSession / resumeSession / refreshSession - Add truncateCodexRollout with writeFileWithRetry to survive the Windows file-lock race after cancelSession - Truncate local logs at the prompt boundary; return restoredSessionId from the restore mutation and reconnect with it - Remove the redundant renderer suppressReplayEvents flag
…acing) - Reject overlapping restores for the same session with an in-flight lock in the checkpoint router (prevents two restores racing to truncate logs/rollout) - Surface truncation failures: restore mutation returns truncationFailed and the renderer warns the user that agent memory may extend past the checkpoint - truncateLocalLogsAtPromptBoundary now returns whether the boundary was found, so a missing anchor is reported instead of silently leaving stale turns - Block checkpoint restore for cloud sessions with a clear message instead of failing silently after the destructive revert - Show a Current badge (no Restore button) on the most-recent checkpoint and disable timeline restore buttons while a restore is in progress - Note in the confirm dialog when restoring will stop an in-progress response
…cy, parity) Adds automated coverage for the checkpoint-restore fix and hardening: - rollout.test.ts: truncateCodexRollout trims to the first/middle turn, leaves the file intact when no complete turns exist, and reports not-found - rollout.retry.test.ts: writeFileWithRetry retries on EPERM/EBUSY/EACCES and succeeds once the Windows file lock releases, fails fast on other errors - codex-client suppressReplay: replayed session/update events are dropped (no upstream forward, no re-fired structured-output) until the flag clears - codex-agent: suppressReplay is set during loadSession and cleared after, including when loadSession throws - local-logs: truncateLocalLogsAtPromptBoundary returns found/not-found and trims to the prompt boundary - checkpoint router: concurrent restores for the same session are rejected and the lock is released after success and after a failed revert - jsonl-hydration: Claude force-refetches the truncated S3 log on restore and rewrites only the surviving turns (memory parity, no replay)
…tton - Add enableOnFormTags/enableOnContentEditable to the checkpoint-timeline hotkey so Ctrl/Cmd+Shift+H opens the timeline even when the message editor has focus (previously suppressed by react-hotkeys-hook, the common case) - Render the per-turn restore button always, disabled with an explanatory tooltip when the turn has no checkpoint (or a cloud-task tooltip for cloud sessions) instead of hiding it
The timeline modal (Ctrl/Cmd+Shift+H) is dropped — the per-turn restore button on each user message is the restore entry point. Removes the component, its keyboard shortcut wiring (SHORTCUTS / KEYBOARD_SHORTCUTS / CONFIGURABLE_SHORTCUT_IDS / DEFAULT_KEYBINDINGS), and the ConversationView state, hotkey, and render. Per-turn restore, the confirm dialog, and the underlying restore/truncation flow are unchanged.
…estart
captureLocalCheckpoint stored each checkpoint only in the in-memory
sessionCheckpoints map, the agent JSONL, and S3. On a cold start the
in-memory map is gone and fetchSessionLogs reads the local logs.ndjson
cache first (before S3), so the checkpoint notifications were missing and
every restore icon showed disabled ("No checkpoint was captured for this turn").
Append the checkpoint notification to logs.ndjson at capture time (matching
the SessionLogWriter tap's line-by-line append), so cold loads after a
restart find it. S3 sync is unchanged.
Also adds buildConversationItems tests for checkpoint-to-turn association and
updates three user_message expectations for the turnContext field.
- Restore trims logs.ndjson at the restored turn's prompt-response line, which dropped that turn's git_checkpoint notification (appended after the response on TURN_COMPLETE) and left it with a disabled restore icon after an app restart. truncateLocalLogsAtPromptBoundary now accepts preserveTrailingEntries and the checkpoint router re-adds the restored checkpoint atomically, so the restored turn stays restorable. - RestoreCheckpointDialog: drop the amber title/warning icons and the amber callout background, remove the icon on the red Restore button, and equalize the Cancel/Restore buttons. Adds local-logs tests for preserved trailing entries.
…anup The local-cache fix kept the restored turn's checkpoint in logs.ndjson, but S3 still had it trimmed. If the old-session cleanup later deletes the local cache, the cold load falls back to S3 and the restored turn would lose its icon again. After the S3 truncate, re-append the restored checkpoint to S3 (gated on the truncate actually having removed it, to avoid duplicates), reusing the entry built for the local preserve.
- Fix 1: delete stale Claude JSONL on restore so SDK cannot read full history before hydrateSessionJsonl re-fetches the truncated S3 version - Fix 2: capture a git checkpoint after every cloud turn (per-turn promptId) so each cloud turn gets an enabled restore button - Fix 3: after cloud->local handoff, scan seeded logs.ndjson for all _posthog/git_checkpoint events, apply each pack locally, and register in agentService so every cloud turn shows a restore button - Fix 4: enable restore button for cloud sessions; show continue-locally copy in dialog; replace hard error with info toast - Fix 5: during local->cloud handoff, pack and upload all prior local turn checkpoints to S3 and append to the cloud run log so they survive future cloud->local handoffs - packBaseline: validate upstreamHead ref exists before using as negative ref in git pack-objects to avoid bad-object failures
Snapshot of in-progress cloud-checkpoint-restore work (restore UX: isReconnecting, restyled restore dialog, send-queues-during-reconnect) and the untracked test scaffold (cloud-native C, stable reused worktree, cloud bypass-permissions). Committed as a safety net before further cloud-test runs; staging to be reworked before the PR.
Follow-up refinements on top of the Fix 1-5 cloud handoff work: - Codex restore memory: on a checkpoint-restore reconnect, start a fresh codex session and inject a bounded context summary rebuilt from the truncated S3 log (resumeFromLog + formatConversationForResume) so the agent's memory ends at the restored checkpoint. Claude re-hydrates from its JSONL and needs no change. Adds getSurvivingCheckpointIds as an orphan-deletion whitelist. - Adapter-aware bypass-revert: extract resolveBypassRevertMode / resolveAllowAlwaysUpgradeMode into modeResolvers.ts (Claude->default, Codex->auto) to stop the post-handoff "Failed to change setting" loop. - checkpoint.ts: drop truncateCodexRollout usage; re-seed + timestamp-trim the local cache from the truncated S3 log so reloads don't resurrect restore-truncated turns. - Restore UX: disable per-turn restore icons during an in-flight handoff. - Tests: modeResolvers, codex fresh-session-on-restore reconnect, getSurvivingCheckpointIds; node-safe shared test setup.
…ture Ports the checkpoint-restore feature branch onto upstream's new architecture. Conflict resolutions match upstream (incl. AgentMessage Box tag fix). Local-only deltas (restore UX, cost coercion, test scaffolds) remain unstaged.
- Show restore icons on past turns while the agent is still responding (bind deferred checkpoints on the live/incremental path), and stop the in-flight turn before restoring. - Restore dialog: lock Cancel while restoring; turn/adapter-aware copy. - restoreCheckpointReconnect replays the persisted model so a restore no longer falls back to the gateway default. - Live event trim recognizes the replayed user_message_chunk boundary. - Add CheckpointService.restore concurrency-lock test. - Drop local-only test scaffolds + sandbox script from the tree.
Resolves 3 conflicts (both-added): workspace agent getDebugSnapshot vs our checkpoint methods; sessionStore immer setAutoFreeze import; SessionView adopts upstream useSessionHandoffInProgress while keeping isReconnecting + merges the submit-tooltip conditions (staleGate + reconnecting).
…2321 base These files (keybindingsStore + useShortcut) rode in from the branch's PostHog#2321 base and are unused by the checkpoint feature; the arch re-sync already reverted DiffStatsChip/SteerQueueToggle to upstream, leaving these two fully orphaned.
The re-sync merge resolved SessionView's import block to upstream's set, which dropped useSessionForTask that our conflict resolution still references for isReconnecting. Typecheck verified green independently (@posthog/web exit 0).
…e icons
Cloud-native (scenario C) fixes surfaced after cloud->local handoff:
- executeHandoff now falls back to session.adapter (set from runtime_adapter)
when the persisted adapterStore is empty, which it always is for a task born
in cloud. Without it the saga spawned Claude for a codex run, the model/mode
config replay failed ("Internal error"), and the first post-handoff turn 401'd
against the Anthropic endpoint. Precedence mirrors restoreCheckpointReconnect
(adapterStore stays authoritative for tasks handed off from local).
- Cloud checkpoints now bind to the correct turn. The cloud capture stamps the
true turn boundary (turnCompletedAt) at TURN_COMPLETE, before the fire-and-
forget snapshot whose own timestamp lands async-late; it rides in the
GIT_CHECKPOINT params through registerCloudCheckpoint and replay so the desktop
binds the restore icon by boundary instead of the async-late ts / turn-index
promptId (which mis-bound, leaving icons disabled at random).
Also carries prior in-progress checkpoint work: git-saga runsConcurrently
(capture no longer queues behind restore), handoff config/model replay, restore
UX, and the ChatThread restore wiring.
Two unrelated leftovers pulled from the branch before opening the PR: - UI: drop the residual PostHog#2321 file-picker keybinding infra (FILE_PICKER, CONFIGURABLE_SHORTCUT_IDS, DEFAULT_KEYBINDINGS). No production consumer and no handler for the shortcut. Completes the cleanup started in cb0e372. - Agent: remove superseded codex rollout-truncation. truncateCodexRollout/ findCodexRollout had no production caller — checkpoint restore uses a fresh session + bounded summary (resumeFromLog), not on-disk rollout truncation. Removes rollout.ts + its two tests and the stale package export and tsup entry.
The local checkpoint capture path emitted ~7 info lines per turn (announce, per-step, and per-append). Demote the internal step logs to debug and drop a duplicate announce, keeping the 'Local checkpoint captured' summary (id/commit/branch/timing) plus the once-per-op handoff/restore summaries (replay/truncate/refetch). No behavior change.
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
This was referenced Jul 13, 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.
feat: checkpoint restore — revert code + agent memory to any prior turn, across local ↔ cloud
A little history for context: this was originally split into two stacked PRs — the old PR A (restore within a session) and PR B (restore across local↔cloud handoffs) — deliberately kept separate to keep each diff small and easy to review. Partway through, a sync with
mainbrought in the new modular package architecture (thepackages/*split + DI/host-router refactor). That turned what should have been a routine rebase into a re-implementation on the new arch, and it reshuffled the very commit stack the A/B split depended on — enough conflicts that keeping them apart stopped making sense. So I folded both halves into this single, integrated PR.Summary
Adds checkpoint restoration to a PostHog Code session: after any AI response turn the user can
roll the workspace and the agent's memory back to that point — a per-turn "restore" affordance in
the conversation, similar to Claude Code's rewind. Works on both adapters (Claude + Codex) and,
crucially, across local↔cloud handoffs.
Two guarantees have to hold together for that, and this PR owns both:
is still restorable after the task moves to the cloud, and a cloud checkpoint is still restorable
after it comes back local.
checkpoint (local- or cloud-origin) from the desktop and the code and the agent's memory both
land on that turn.
Scope boundary: the restore action is always initiated from a local session (the desktop
performs the revert + truncation). You can restore to both local and cloud checkpoints and memory
stays correct across the handoff. Restoring while the task is still running in the cloud
(sandbox-side / cloud-origin restore) is a deferred follow-up — see roadmap.
1. What pre-existed vs. what this PR adds
Already on
main(upstream primitives):CaptureCheckpointSaga+RevertCheckpointSaga—packages/git/src/sagas/checkpoint.ts. Low-levelgit: snapshot a working tree to a ref; reset a working tree back to one.
captureCheckpointState—packages/agent/src/server/agent-server.ts. Existed, but wired only forhandoff state-packing (capture workspace → S3 during local→cloud handoff), not as a per-turn
user-restorable stream.
packages/workspace-server/src/services/handoff/service.ts, handoff sagas.New in this PR (absent from
main):TURN_COMPLETE, each uploaded to S3 withturn metadata.
syncCloudCheckpoints→materializeCheckpointRef+registerCloudCheckpoint: rebuild each cloud checkpoint's git ref locally at handoff socloud-origin checkpoints become restorable on the desktop.
packages/workspace-server/src/services/checkpoint/service.tsdoes not exist on
main:runRestore, memory truncation (trimReseededCacheToCheckpoint,JSONL/rollout truncation, bounded
resumeFromLogsummary), plus UI (RestoreCheckpointDialog,useRestoreCheckpoint,restoreCheckpointReconnect).turnCompletedAt;capture/restore concurrency; reload re-seeding; index.lock retry.
RevertCheckpointSaga(raw revert) pre-existed. Picking a checkpoint in the UI, resolving cloud-origin refs locally, and truncating log + agent memory so the model doesn't "remember the future" — all ours.One-liner: we don't reinvent git snapshot/revert — we turn it into a cross-environment,
memory-consistent, user-triggerable restore.
2. Background / the problem
A checkpoint must capture and later restore three things that can drift apart:
logs.ndjsoncache mirror).
re-hydrates from the JSONL; Codex resumes from its rollout / a bounded summary).
Reverting files alone isn't enough — the agent would keep acting on turns the user just undid. This
PR keeps all three consistent, both for an in-session restore and across a handoff.
3. What this PR does
Capture
TURN_COMPLETEin the rawstream tap (not
extNotification, which was lossy).TURN_COMPLETEfrom the Claude adapter so Claude turns capture checkpoints too (paritywith the Codex-first original).
logs.ndjsonso the restore affordance survives an app restart.with
turnCompletedAt(the true turn boundary) so the desktop can bind its restore icon to thecorrect turn.
Restore
truncate_log) with a client-sideprompt-boundary trim as the primary defense for Codex (whose events are all
notifications).order) so every surviving turn stays restorable, live and after a reload.
gateway default (the "model resets after restore" bug) or on the wrong runtime.
isReconnectingrace guard), tail-checkpoint edge cases,and surfaced failures.
Two adapter-memory pieces that only surface across a handoff:
post-handoff summary blob, so on a restore-reconnect Codex starts a fresh session seeded with a
bounded context rebuilt from the truncated log (
resumeFromLog+formatConversationForResume),and rollout replay on reload is suppressed so undone turns aren't re-persisted.
"default"mode, which a Codex session rejects → a toast loop after a cross-adapter handoff. Now resolved
per-adapter (Claude→
default, Codex→auto), unit-tested.UX
turns stay restorable mid-stream); a restyled, destructive-action
RestoreCheckpointDialog(Cancellocked while a restore is in flight; turn/adapter-aware copy) + an always-visible restore button.
("continue the task locally first") — matching the scope boundary above.
view," so a degraded restore is never silent.
4. End-to-end flow
Every node names the exact
file » function. Renders on GitHub and at mermaid.live.--- title: "A · Lifecycle — capture → handoff/sync → restore" config: theme: base themeVariables: darkMode: true background: "#0f1430" primaryColor: "#232a52" primaryTextColor: "#eef1ff" primaryBorderColor: "#5661a8" lineColor: "#8b94dd" secondaryColor: "#2c2350" tertiaryColor: "#1b2340" noteBkgColor: "#2c3566" noteTextColor: "#eef1ff" noteBorderColor: "#5661a8" actorBkg: "#2a3160" actorTextColor: "#eef1ff" actorBorder: "#5661a8" actorLineColor: "#8b94dd" signalColor: "#cdd4ff" signalTextColor: "#eef1ff" labelBoxBkgColor: "#2a3160" labelBoxBorderColor: "#5661a8" labelTextColor: "#eef1ff" loopTextColor: "#eef1ff" sequenceNumberColor: "#0f1430" --- sequenceDiagram autonumber participant UI as "UI · packages/ui" participant Core as "Core · sessionService.ts" participant WS as "host · workspace-server" participant Git as "git · packages/git" participant Cloud as "sandbox · agent-server.ts" participant S3 as "S3 run log" rect rgb(28,35,66) Note over Cloud,S3: CAPTURE — cloud turn Cloud->>Cloud: agent-server.ts » broadcastTurnComplete() Note right of Cloud: stamp turnCompletedAt (true boundary) Cloud->>Cloud: agent-server.ts » captureCheckpointState Note right of Cloud: inputs: turnIndex + turnCompletedAt Note right of Cloud: fire-and-forget Note right of Cloud: emits GIT_CHECKPOINT (promptId + turnCompletedAt) Cloud->>S3: handoff-checkpoint.ts » HandoffCheckpointTracker.captureForHandoff() Note right of S3: pack + upload (artifactPath) Note right of S3: marker persisted to run log end rect rgb(32,27,60) Note over WS,Git: CAPTURE — local turn WS->>Git: agent/agent.ts » captureLocalCheckpoint → CaptureCheckpointSaga Note right of Git: git-saga.ts » runsConcurrently Note right of Git: executeRead Note right of Git: temp index Git-->>WS: checkpointId + git ref WS->>WS: agent/agent.ts » sessionCheckpoints.set Note right of WS: promptId + ts + turnCompletedAt WS->>UI: GIT_CHECKPOINT notification Note right of UI: promptId + turnCompletedAt UI->>UI: buildConversationItems.ts » assignDeferredCheckpoints() Note right of UI: bind restore icon by turnCompletedAt end rect rgb(27,35,64) Note over UI,S3: HANDOFF local→cloud (pre-existing primitive) UI->>Core: sessionService.ts » handoffToCloud Note right of Core: taskId + repoPath Core->>WS: trpc handoff.execute Note right of WS: captureForHandoff packs local git state WS->>S3: agent-server.ts » captureCheckpointState Note right of S3: pendingHandoffGitState Note right of S3: pre-flight snapshot Note right of S3: promptId-less Note right of S3: iconless by design S3->>Cloud: sandbox resumes from packed state end rect rgb(43,30,63) Note over UI,S3: HANDOFF cloud→local UI->>Core: sessionService.ts » handoffToLocal Note right of Core: taskId + repoPath Core->>Core: sessionService.ts » runHandoffPreflight() Note right of Core: executeHandoff() Note right of Core: adapter = adapterStore.getAdapter(runId) or session.adapter Core->>WS: trpc handoff.execute Note right of WS: saga spawns local agent Note right of WS: applies git state WS->>S3: handoff/service.ts » syncCloudCheckpoints() Note right of S3: fetch run log WS->>Git: handoff-checkpoint.ts » HandoffCheckpointTracker.materializeCheckpointRef() Note right of Git: rebuild each cloud checkpoint git ref locally WS->>WS: agent/agent.ts » registerCloudCheckpoint Note right of WS: promptId + turnCompletedAt WS->>UI: agent/agent.ts » replayCheckpoints / emit Note right of UI: icons enabled Core->>WS: sessionService.ts » handoffToLocal Note right of WS: replay persisted config (model + mode) endThe story — a checkpoint is a snapshot of the working tree taken at a turn boundary (the moment
an agent finishes a turn). Two paths capture them; a third makes them portable:
broadcastTurnCompletestamps the trueboundary time (
turnCompletedAt) before firing a fire-and-forget capture → pack tree → upload toS3 → write a
GIT_CHECKPOINTmarker into the run log.CaptureCheckpointSagaagainst a temp git index(never blocks the agent) → records the checkpoint → notifies the UI, which pins the restore icon to
the turn by
turnCompletedAt— the one key immune to the async-late timestamp and the reusedturn-index.
pre-flight snapshot is promptId-less and stays iconless by design. Shown for completeness — the
primitive we build on, not part of what this PR adds.
(
adapterStore ?? session.adapter— the fix that stops a codex task waking up as Claude) → sagaspawns the local agent + applies git state →
syncCloudCheckpointswalks the S3 log to rebuildeach cloud checkpoint's git ref locally (
materializeCheckpointRef) and re-register it → replaypersisted model/mode.
--- title: "B · Restore internals — click → code + memory consistent" config: theme: base themeVariables: darkMode: true background: "#0f1430" primaryColor: "#232a52" primaryTextColor: "#eef1ff" primaryBorderColor: "#5661a8" lineColor: "#8b94dd" clusterBkg: "#161c38" clusterBorder: "#5661a8" edgeLabelBackground: "#161c38" titleColor: "#eef1ff" --- flowchart TD A["UI click<br/>RestoreCheckpointDialog.tsx<br/>hooks/useRestoreCheckpoint.ts"] --> B["checkpoint/service.ts » runRestore()"] B --> C{"git/lock-detector » isLocked()?"} C -- yes --> C1["git/lock-detector » waitForUnlock()<br/>bounded retry"] --> D C -- no --> D["sagas/checkpoint.ts » RevertCheckpointSaga<br/>reset working tree to ref<br/>local OR materialized-cloud"] D --> E["Truncate agent memory to boundary"] E --> E1["S3 log — POST /truncate_log"] E --> E2["checkpoint/service.ts » trimReseededCacheToCheckpoint()<br/>local cache, keyed by turnCompletedAt"] E --> E3{"adapter?"} E3 -- claude --> E4["agent » jsonl-hydration.ts » hydrateSessionJsonl()<br/>(invoked on reconnect via agent/agent.ts)<br/>rebuild JSONL from truncated S3"] E3 -- codex --> E5["agent/agent.ts » fresh session +<br/>resume.ts » resumeFromLog() bounded summary"] E --> F["agent/agent.ts » truncateCheckpoints()<br/>drop orphaned refs after target"] F --> G["agent/agent.ts » markCheckpointRestore()<br/>→ sessionService.ts » restoreCheckpointReconnect()<br/>re-seed cache, replay adapter+model, isReconnecting UI"] G --> H["Session resumes at checkpoint<br/>code + memory consistent"] classDef blue fill:#232a52,stroke:#5661a8,color:#eef1ff; classDef purple fill:#2c2350,stroke:#6a4f9e,color:#eef1ff; classDef decide fill:#33285c,stroke:#7b64c9,color:#eef1ff; classDef done fill:#1d3a49,stroke:#4a8fb0,color:#eaffff; class A,B,C1,D blue; class E,E1,E2,E4,E5,F,G purple; class C,E3 decide; class H done;The story — restoring isn't just
git reset; the code and the agent's memory must land at thesame boundary, or the model "remembers the future." A click in
RestoreCheckpointDialog→runRestore, then:index.lockis held, wait (bounded) instead of failing.RevertCheckpointSagaresets the working tree to the target ref — alocally-captured checkpoint or a materialized cloud-origin one, same code path (that's the
point).
(
POST /truncate_log) · local cache (trimmed byturnCompletedAt) · the agent's own conversation.fresh session seeded with a bounded summary (
resumeFromLog), because codex's handoff summary isone opaque rollout turn that turn-granular truncation can't slice.
restoreCheckpointReconnectre-seedsthe cache + replays adapter/model → session resumes.
5. Anatomy of the diff (~4.8k lines / ~59 files)
Where the change lives, so the file count reads at a glance:
checkpoint/service.ts(restore orchestration — the largest single file),useRestoreCheckpoint+RestoreCheckpointDialog(UI),checkpoint.router(IPC),identifiers.turnCompletedAt,adapter,checkpointId— through git → agent → workspace-server → core → host-router → ui → shared → api-client.The change reaches into many packages because restore has to keep three things on the same turn
boundary — the code (git working tree), the run log (authoritative S3 copy + local cache), and
the agent's conversation memory — and they live in different packages. So it spans git (revert +
ref materialization), agent (capture + JSONL truncation / codex fresh-session), workspace-server
(orchestration), core (reconnect + adapter/model replay), and ui (the affordance). The diagrams above
trace that path.
Built on existing primitives —
CaptureCheckpointSaga/RevertCheckpointSagafor git and theexisting handoff transport are reused as-is, so there's no new git plumbing or transport layer.
6. Design decisions & trade-offs
differences; the basis the cross-handoff re-seed logic builds on.
complement. Restore never hard-fails on a backend hiccup — it logs and falls back (only the git
revert can fail hard; everything after it is non-fatal).
materializeCheckpointRefis non-destructive — unpacks objects + rebuilds the ref only; theapply step already set the working tree, so the sync pass just needs refs.
reload can't resurrect restore-truncated turns.
becomes a faithful summary rather than verbatim rollout turns — accepted because a cross-handoff
restore has no verbatim option anyway (the pre-handoff turns exist only as one summary blob).
7. Roadmap / planned follow-ups
Each builds directly on the restore + memory-rebuild primitives shipped here:
local-side restore.
plus summarize-from / up-to-here. A "soft" restore (revert code, keep memory) is one cell of
this matrix.
8. Testing
Automated (in this PR, CI)
packages/git/src/sagas/checkpoint.tstests — RevertCheckpointSaga (renames, deletes, binary,worktree, submodules, large files).
packages/git/src/handoff.test.ts—materializeCheckpointRef, pack/apply parity.packages/agent/src/adapters/claude/session/jsonl-hydration.hydrate.test.ts— Claude re-hydrationfrom the truncated log.
packages/agent/src/adapters/codex/{codex-agent,codex-client}.test.ts— codex rollout-replaysuppression on
loadSession(so a reload doesn't re-persist undone turns).packages/workspace-server/src/services/checkpoint/service.test.ts— restore concurrency lock +trimReseededCacheToCheckpoint.packages/workspace-server/src/services/local-logs/service.test.ts— prompt-boundary trim.packages/workspace-server/src/services/agent/agent.test.ts— codex fresh-session-on-restorereconnect.
packages/core/src/sessions/handoffAdapter.test.ts— handoff adapter fallback (codex stays codex).packages/ui/…/buildConversationItems.test.ts,incrementalConversationItems.test.ts— per-turnrestore-icon association + live/idle equivalence.
Run:
pnpm --filter @posthog/code exec vitest run. Typecheck:tsc -p tsconfig.node.json --noEmittsc -p tsconfig.web.json --noEmit.Manual — the handoff matrix
Each row is deliberately a superset of the one above it, so a failure pinpoints which mechanism
actually broke rather than implicating the whole feature. A is the narrowest case — pure
local→cloud→local with zero cloud-side turns — so a failure there means the bug is in checkpoint
upload/sync across a handoff, not in cloud-side capture. B adds a cloud turn, bringing cloud-side
checkpoint capture into scope. C removes local history entirely and starts cold in the cloud
(nothing to upload, sync starts from zero) — a distinct failure mode from B's "there was already
something local to carry along." D stacks everything: two full round-trips plus a restore to a
cloud checkpoint sandwiched between two local ones — the only row that exercises restore against a
genuinely mixed-origin checkpoint history and upload de-dup on a second handoff.
For each: create a turn that writes a file, restore, confirm the file reverts and the agent's
memory drops the undone turn. Run on both Claude and Codex:
Also verify: no
Failed to change settingtoast loop after a cross-adapter handoff; a reload afterrestore doesn't resurrect truncated turns.
Optional — runnable smoke-test harness
Scenarios A–D above aren't just a manual checklist — they're also automated as a dev-console harness
(
runA()…runD(), orrunAll()for all four back-to-back) that drives the real running append-to-end: creates a task, sends real prompts to a real agent (Claude or Codex), waits for real
checkpoints, hands off to a real cloud sandbox and back, restores, and asserts file content + agent
memory at every step. It's the same four scenarios from the table above, scripted instead of clicked
through by hand — handy for re-running the full matrix after any future change to the handoff/restore
path without a manual pass.
It's intentionally not in this PR's diff (imports renderer internals directly; a dev instrument,
not a CI test), so it's published as a Gist instead:
Run:
pnpm dev→ devtools →const m = await import('<raw-gist-url>')→await m.runA("claude")(or
"codex"),runB,runC,runD.Validation status
All four scenarios pass on both Claude and Codex; automated suites green.
9. Risk / blast radius
warn/error), never crashing a run.10. Screenshots / recording
Demo — Scenario D: full bidirectional restore (local→cloud→local→cloud→local)
posthog-code-restore-checkpoint-demo-10x-github.mp4
Oh well, let's not mind the video quality, couldn't record at a better one due to excessive lag and yeah, the demo is sped up as well (and just under 5 minutes)