Skip to content

Every checkpoint should have only one commit#325

Open
Soph wants to merge 8 commits intomainfrom
soph/one-to-one-checkpoints
Open

Every checkpoint should have only one commit#325
Soph wants to merge 8 commits intomainfrom
soph/one-to-one-checkpoints

Conversation

@Soph
Copy link
Collaborator

@Soph Soph commented Feb 13, 2026

1:1 Checkpoint-to-Commit Mapping with Deferred Transcript Finalization

Problem

Previously, checkpoints used a 1:N model — one checkpoint ID could span multiple commits. When an agent made several commits during a single turn, they all shared the same checkpoint ID. This made it impossible to tie a specific commit to a specific snapshot of session context, and the ACTIVE_COMMITTED phase added
complexity to the state machine.

Approach

Every user commit now gets its own unique checkpoint ID. The core challenge is that mid-turn commits happen before the agent is done — the transcript is incomplete. We solve this with a provisional-then-finalize pattern:

  1. At commit time (PostCommit hook): Condense whatever transcript exists so far to entire/checkpoints/v1 with a fresh checkpoint ID. This is provisional — the transcript is partial. Then carry forward any remaining uncommitted files to a new shadow branch so the next commit gets its own checkpoint.

  2. At turn end (Stop hook): HandleTurnEnd iterates over all checkpoint IDs recorded during the turn (TurnCheckpointIDs on session state) and replaces each provisional transcript with the complete one via UpdateCommitted.

This means every checkpoint on entire/checkpoints/v1 ends up with the full prompt-to-stop transcript, while each commit still gets its own unique ID for precise linking.

Key changes

New: UpdateCommitted on checkpoint store

  • Replace semantics (not append) — swaps transcript, prompts, and context blobs for a checkpoint's session folder
  • Chunks large transcripts (matching WriteCommitted behavior)
  • Redacts secrets before writing (matching WriteCommitted behavior)

New: TurnCheckpointIDs on session state

  • Tracks checkpoint IDs condensed during the current agent turn
  • Populated in PostCommit for ACTIVE sessions after successful condensation
  • Consumed in HandleTurnEnd to finalize all checkpoints
  • Cleared on new prompt (both strategies)

New: HandleTurnEnd finalization

  • TurnEndHandler interface simplified — no longer takes []session.Action parameter since work items come from state, not the action list
  • Called unconditionally when strategy implements the interface (removed dead if len(remaining) > 0 guard)

Phase simplification

  • Removed ACTIVE_COMMITTED phase — the state machine is now 3 phases: ACTIVE, IDLE, ENDED
  • active_committed in persisted state normalizes to ACTIVE (not IDLE) so pending finalization isn't lost on upgrade

PostCommit carry-forward

  • After condensation, uncommitted files are carried forward to a new shadow branch
  • Ensures the next commit gets its own checkpoint ID instead of accumulating into the previous one

Soph added 4 commits February 13, 2026 17:16
Entire-Checkpoint: 49280afe3e31
Entire-Checkpoint: 332f7c352062
Entire-Checkpoint: 7b9bb14c5953
@Soph Soph requested a review from a team as a code owner February 13, 2026 16:38
Copilot AI review requested due to automatic review settings February 13, 2026 16:38
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

// New content exists - will generate new checkpoint ID below
hasNewContent = true
} else {
// No new content - check if any session has a LastCheckpointID to reuse
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amend message drops checkpoint trailer

Medium Severity

PrepareCommitMsg now returns early when sessionsWithContent is empty, so source="message" amend flows never restore Entire-Checkpoint from LastCheckpointID. This drops linkage for git commit --amend -m ... commits that previously had a checkpoint, leaving amended commits unlinked from entire/checkpoints/v1.

Additional Locations (1)

Fix in Cursor Fix in Web

slog.String("session_id", state.SessionID),
slog.String("transcript_path", state.TranscriptPath),
)
state.TurnCheckpointIDs = nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finalization failures silently discard pending checkpoints

Medium Severity

finalizeAllTurnCheckpoints clears TurnCheckpointIDs even when transcript loading or UpdateCommitted setup fails. This permanently drops pending checkpoint IDs, so provisional checkpoint data is never retried or finalized with the full turn transcript after transient failures.

Additional Locations (2)

Fix in Cursor Fix in Web

if err := s.replaceTranscript(opts.Transcript, opts.Agent, sessionPath, entries); err != nil {
return fmt.Errorf("failed to replace transcript: %w", err)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty finalization data never replaces provisional content

Medium Severity

UpdateCommitted only writes Transcript, Prompts, and Context when their lengths are non-zero. With replace semantics, empty finalized values should clear provisional files, but these guards keep old data instead, so checkpoints can retain stale provisional transcript or prompt/context content.

Additional Locations (2)

Fix in Cursor Fix in Web

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates Entire CLI’s checkpointing model to enforce a strict 1:1 mapping between user commits and checkpoint IDs, while still ensuring each checkpoint ultimately contains the full prompt-to-stop transcript via a provisional-at-commit then finalize-at-stop workflow. It also simplifies the session phase state machine by removing the ACTIVE_COMMITTED phase.

Changes:

  • Switch to immediate condensation on PostCommit (even while ACTIVE) so every user commit gets a unique checkpoint ID, plus carry-forward of uncommitted work to a new shadow branch.
  • Add TurnID and TurnCheckpointIDs to session state, and finalize mid-turn checkpoints at stop time by updating committed checkpoint content.
  • Introduce Store.UpdateCommitted to replace committed transcript/prompts/context for already-condensed checkpoints, and update docs/tests accordingly.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
docs/architecture/sessions-and-checkpoints.md Updates metadata lookup guidance to always read from the metadata branch tree at HEAD.
docs/KNOWN_LIMITATIONS.md Updates amend-trailer restoration docs to reference LastCheckpointID only.
cmd/entire/cli/strategy/strategy.go Simplifies TurnEndHandler interface to read work items from state (no action list).
cmd/entire/cli/strategy/phase_wiring_test.go Adjusts phase wiring tests for new turn fields and removed phase.
cmd/entire/cli/strategy/phase_prepare_commit_msg_test.go Updates tests for amend trailer restoration via LastCheckpointID; removes PendingCheckpointID-era tests.
cmd/entire/cli/strategy/phase_postcommit_test.go Reworks PostCommit tests for immediate condensation + carry-forward + TurnCheckpointIDs.
cmd/entire/cli/strategy/mid_turn_commit_test.go Removes tests tied to PendingCheckpointID preservation (no longer applicable).
cmd/entire/cli/strategy/manual_commit_test.go Minor lint/comment cleanup in tests.
cmd/entire/cli/strategy/manual_commit_session.go Initializes a per-turn TurnID when creating a session.
cmd/entire/cli/strategy/manual_commit_hooks.go Implements immediate condensation, carry-forward, TurnCheckpointIDs recording, and stop-time finalization using UpdateCommitted.
cmd/entire/cli/strategy/manual_commit_git.go Updates state comments to reflect LastCheckpointID usage.
cmd/entire/cli/strategy/manual_commit_condensation.go Persists TurnID into committed checkpoint metadata; removes PendingCheckpointID clearing.
cmd/entire/cli/strategy/auto_commit.go Adds TurnID propagation into committed metadata; generates new TurnID per turn.
cmd/entire/cli/session/state.go Adds TurnID and TurnCheckpointIDs; clarifies LastCheckpointID semantics.
cmd/entire/cli/session/phase_test.go Updates state machine expectations for removed ACTIVE_COMMITTED and migration normalization.
cmd/entire/cli/session/phase.go Removes ACTIVE_COMMITTED + migrate action; normalizes legacy active_committed to ACTIVE.
cmd/entire/cli/reset.go Updates active-session description to remove ACTIVE_COMMITTED references.
cmd/entire/cli/phase_wiring_test.go Removes tests specifically exercising ACTIVE_COMMITTED behavior.
cmd/entire/cli/integration_test/phase_transitions_test.go Updates integration flow expectations for mid-turn commits (condense immediately, remain ACTIVE).
cmd/entire/cli/integration_test/last_checkpoint_id_test.go Updates integration tests to enforce one-checkpoint-per-commit behavior.
cmd/entire/cli/hooks_claudecode_handlers.go Always dispatches TurnEndHandler (no action list); ensures state saved after turn end.
cmd/entire/cli/hooks.go Updates TODO wording to remove ACTIVE_COMMITTED references.
cmd/entire/cli/doctor_test.go Removes tests specific to ACTIVE_COMMITTED.
cmd/entire/cli/doctor.go Updates “stuck session” definition to remove ACTIVE_COMMITTED.
cmd/entire/cli/checkpoint/committed_update_test.go Adds unit tests covering UpdateCommitted replace behavior and content hash updates.
cmd/entire/cli/checkpoint/committed.go Adds GitStore.UpdateCommitted and transcript replacement helper.
cmd/entire/cli/checkpoint/checkpoint.go Extends Store interface + adds UpdateCommitted options + adds TurnID to committed metadata.
CLAUDE.md Updates architecture docs to reflect 3-phase model and tree-at-HEAD metadata lookup.

Comment on lines +1083 to +1102
// Replace prompts
if len(opts.Prompts) > 0 {
promptContent := strings.Join(opts.Prompts, "\n\n---\n\n")
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
if err != nil {
return fmt.Errorf("failed to create prompt blob: %w", err)
}
entries[sessionPath+paths.PromptFileName] = object.TreeEntry{
Name: sessionPath + paths.PromptFileName,
Mode: filemode.Regular,
Hash: blobHash,
}
}

// Replace context
if len(opts.Context) > 0 {
contextBlob, err := CreateBlobFromContent(s.repo, opts.Context)
if err != nil {
return fmt.Errorf("failed to create context blob: %w", err)
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateCommitted writes prompt.txt and context.md without running redaction (WriteCommitted redacts via redact.String / redact.Bytes). Even if current callers redact, keeping redaction inside the store helps prevent future call sites from accidentally committing secrets.

Copilot uses AI. Check for mistakes.

// Replace transcript (full replace, not append)
if len(opts.Transcript) > 0 {
if err := s.replaceTranscript(opts.Transcript, opts.Agent, sessionPath, entries); err != nil {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateCommitted currently writes the replacement transcript chunks and updates content_hash.txt without performing secret redaction (unlike WriteCommitted/writeTranscript which runs redact.JSONLBytes first). To avoid accidentally persisting secrets when callers forget to redact, UpdateCommitted should apply the same redaction internally (and compute chunking + content hash from the redacted bytes).

Suggested change
if err := s.replaceTranscript(opts.Transcript, opts.Agent, sessionPath, entries); err != nil {
redactedTranscript, err := redact.JSONLBytes(opts.Transcript)
if err != nil {
return fmt.Errorf("failed to redact transcript: %w", err)
}
if err := s.replaceTranscript(redactedTranscript, opts.Agent, sessionPath, entries); err != nil {

Copilot uses AI. Check for mistakes.
Comment on lines +1083 to +1086
// Replace prompts
if len(opts.Prompts) > 0 {
promptContent := strings.Join(opts.Prompts, "\n\n---\n\n")
blobHash, err := CreateBlobFromContent(s.repo, []byte(promptContent))
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replace semantics in UpdateCommitted can’t currently clear prompt.txt / context.md: the updates are guarded by len(opts.Prompts) > 0 / len(opts.Context) > 0, so passing an empty set leaves whatever was previously written. This also means callers can’t intentionally replace prompts/context with empty content. Consider removing the guards (write empty files) or explicitly deleting these entries when the caller intends a replace (e.g., add ReplacePrompts/ReplaceContext flags or treat nil vs empty distinctly).

Copilot uses AI. Check for mistakes.
Comment on lines 1356 to 1363
if err != nil || len(fullTranscript) == 0 {
logging.Warn(logCtx, "finalize: failed to read transcript, skipping",
slog.String("session_id", state.SessionID),
slog.String("transcript_path", state.TranscriptPath),
)
state.TurnCheckpointIDs = nil
return
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When os.ReadFile(state.TranscriptPath) fails, the warning log omits the underlying error, which makes diagnosing stop-time finalization failures harder. Include err.Error() (and ideally distinguish the empty-file case) in the log attributes before clearing TurnCheckpointIDs.

Suggested change
if err != nil || len(fullTranscript) == 0 {
logging.Warn(logCtx, "finalize: failed to read transcript, skipping",
slog.String("session_id", state.SessionID),
slog.String("transcript_path", state.TranscriptPath),
)
state.TurnCheckpointIDs = nil
return
}
if err != nil {
logging.Warn(logCtx, "finalize: failed to read transcript, skipping",
slog.String("session_id", state.SessionID),
slog.String("transcript_path", state.TranscriptPath),
slog.String("error", err.Error()),
)
state.TurnCheckpointIDs = nil
return
}
if len(fullTranscript) == 0 {
logging.Warn(logCtx, "finalize: transcript is empty, skipping",
slog.String("session_id", state.SessionID),
slog.String("transcript_path", state.TranscriptPath),
slog.String("reason", "empty_transcript"),
)
state.TurnCheckpointIDs = nil
return
}

Copilot uses AI. Check for mistakes.
Soph added 4 commits February 13, 2026 20:26
Entire-Checkpoint: 0bce1f68b21e
Entire-Checkpoint: ca5ddaabc88a
Entire-Checkpoint: 93fa19e317c5
Entire-Checkpoint: 7cf71da0278c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant