Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 17 additions & 34 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,23 +380,15 @@ func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source str
}

if hasNewContent {
// New content: check PendingCheckpointID first (set during previous condensation),
// otherwise generate a new one. This ensures idempotent IDs across hook invocations.
for _, state := range sessionsWithContent {
if state.PendingCheckpointID != "" {
if cpID, err := id.NewCheckpointID(state.PendingCheckpointID); err == nil {
checkpointID = cpID
break
}
}
}
if checkpointID.IsEmpty() {
cpID, err := id.Generate()
if err != nil {
return fmt.Errorf("failed to generate checkpoint ID: %w", err)
}
checkpointID = cpID
// New content: generate a fresh checkpoint ID.
// Idempotency for hook re-runs is handled by the trailer existence check above.
// NOTE: Do NOT reuse PendingCheckpointID here - it's from a previous commit's
// PostCommit and would cause duplicate IDs across different commits.
cpID, err := id.Generate()
if err != nil {
return fmt.Errorf("failed to generate checkpoint ID: %w", err)
}
checkpointID = cpID
Copy link

Choose a reason for hiding this comment

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

Earlier commit IDs become unreachable

High Severity

PrepareCommitMsg now always generates a new checkpoint ID for new content, but session state still keeps only one PendingCheckpointID. In multi-commit active turns, later PostCommit calls overwrite earlier IDs, and turn-end condensation uses only the last one. Earlier commits keep trailers pointing to checkpoint IDs that never get condensed.

Additional Locations (2)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

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

Message commits skip confirmation unexpectedly

Medium Severity

After switching to fresh IDs, hasNewContent commits are still marked as restoring when any session has PendingCheckpointID. For source == "message", this routes to the non-confirmation path even though a brand-new checkpoint is being created, so trailer insertion can bypass the intended interactive confirmation.

Additional Locations (1)

Fix in Cursor Fix in Web

Comment on lines +383 to +391
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

This hunk correctly stops reusing PendingCheckpointID when generating a checkpoint ID for new content, but later in PrepareCommitMsg the isRestoringExisting logic is still influenced by PendingCheckpointID when hasNewContent is true. With fresh IDs now always generated here, PendingCheckpointID should no longer cause the commit to be treated as “restoring existing” (it can incorrectly bypass the interactive confirmation path for source == "message"). Consider removing/reworking that PendingCheckpointID check so only actual reuse/restoration paths skip confirmation.

Copilot uses AI. Check for mistakes.
}
// Otherwise checkpointID is already set to LastCheckpointID from above

Expand Down Expand Up @@ -1068,35 +1060,26 @@ func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(repo *git.
// (ACTIVE session + no TTY). Generates a checkpoint ID and adds the trailer
// directly, bypassing content detection and interactive prompts.
func (s *ManualCommitStrategy) addTrailerForAgentCommit(logCtx context.Context, commitMsgFile string, state *SessionState, source string) error {
// Use PendingCheckpointID if set, otherwise generate a new one
var cpID id.CheckpointID
if state.PendingCheckpointID != "" {
var err error
cpID, err = id.NewCheckpointID(state.PendingCheckpointID)
if err != nil {
cpID = "" // fall through to generate
}
}
if cpID.IsEmpty() {
var err error
cpID, err = id.Generate()
if err != nil {
return nil //nolint:nilerr // Hook must be silent on failure
}
}

content, err := os.ReadFile(commitMsgFile) //nolint:gosec // commitMsgFile is provided by git hook
if err != nil {
return nil //nolint:nilerr // Hook must be silent on failure
}

message := string(content)

// Don't add if trailer already exists
// Don't add if trailer already exists (idempotency for hook re-runs)
if _, found := trailers.ParseCheckpoint(message); found {
return nil
}

// Generate a fresh checkpoint ID.
// NOTE: Do NOT reuse PendingCheckpointID here - it's from a previous commit's
// PostCommit and would cause duplicate IDs across different commits.
cpID, err := id.Generate()
if err != nil {
return nil //nolint:nilerr // Hook must be silent on failure
}

message = addCheckpointTrailer(message, cpID)

logging.Info(logCtx, "prepare-commit-msg: agent commit trailer added",
Expand Down
37 changes: 22 additions & 15 deletions cmd/entire/cli/strategy/phase_prepare_commit_msg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,52 +134,59 @@ func TestPrepareCommitMsg_AmendNoTrailerNoPendingID(t *testing.T) {
"commit message should be unchanged when no trailer to restore")
}

// TestPrepareCommitMsg_NormalCommitUsesPendingCheckpointID verifies that during
// a normal commit (source=""), if the session is in ACTIVE_COMMITTED phase with
// a PendingCheckpointID, the pending ID is reused instead of generating a new one.
// This ensures idempotent checkpoint IDs across prepare-commit-msg invocations.
func TestPrepareCommitMsg_NormalCommitUsesPendingCheckpointID(t *testing.T) {
// TestPrepareCommitMsg_ConsecutiveCommits_UniqueCheckpointIDs verifies that
// consecutive commits during an active session each get their own unique
// checkpoint ID. This is a regression test for a bug where PendingCheckpointID
// (set by PostCommit for deferred condensation) was incorrectly reused by
// PrepareCommitMsg for subsequent commits, causing duplicate checkpoint IDs.
//
// The fix: PrepareCommitMsg should always generate a fresh ID for new commits.
// Idempotency for hook re-runs on the same commit is handled by checking if
// the commit message already has a trailer (not by reusing PendingCheckpointID).
func TestPrepareCommitMsg_ConsecutiveCommits_UniqueCheckpointIDs(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

s := &ManualCommitStrategy{}

sessionID := "test-session-normal-pending"
sessionID := "test-session-consecutive-commits"
err := s.InitializeSession(sessionID, agent.AgentTypeClaudeCode, "", "")
require.NoError(t, err)

// Create content on the shadow branch so filterSessionsWithNewContent finds it
createShadowBranchWithTranscript(t, dir, sessionID)

// Set the session to ACTIVE_COMMITTED with a PendingCheckpointID
// This simulates the state after PostCommit ran for a previous commit
state, err := s.loadSessionState(sessionID)
require.NoError(t, err)
require.NotNil(t, state)
state.Phase = session.PhaseActiveCommitted
state.PendingCheckpointID = "fedcba987654"
// Ensure StepCount reflects that a checkpoint exists on the shadow branch
state.PendingCheckpointID = "fedcba987654" // ID from previous commit
state.StepCount = 1
err = s.saveSessionState(state)
require.NoError(t, err)

// Write a commit message file with no trailer (normal editor flow)
// Write a NEW commit message file (simulating a second commit)
commitMsgFile := filepath.Join(t.TempDir(), "COMMIT_EDITMSG")
normalMsg := "Feature: add new functionality\n"
normalMsg := "Feature: second commit\n"
require.NoError(t, os.WriteFile(commitMsgFile, []byte(normalMsg), 0o644))

// Call PrepareCommitMsg with source="" (normal commit, editor flow)
// Call PrepareCommitMsg for the new commit
err = s.PrepareCommitMsg(commitMsgFile, "")
require.NoError(t, err)

// Read the file back - trailer should use PendingCheckpointID
// Read the file back - trailer should have a NEW checkpoint ID
content, err := os.ReadFile(commitMsgFile)
require.NoError(t, err)

cpID, found := trailers.ParseCheckpoint(string(content))
assert.True(t, found,
"trailer should be present for normal commit with active session content")
assert.Equal(t, "fedcba987654", cpID.String(),
"normal commit should reuse PendingCheckpointID instead of generating a new one")
"trailer should be present for commit with active session content")
assert.NotEqual(t, "fedcba987654", cpID.String(),
"consecutive commits should get unique checkpoint IDs, not reuse PendingCheckpointID from previous commit")
assert.Len(t, cpID.String(), 12,
"checkpoint ID should be 12 hex characters")
}

// createShadowBranchWithTranscript creates a shadow branch commit with a minimal
Expand Down
32 changes: 32 additions & 0 deletions docs/architecture/sessions-and-checkpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,38 @@ Location: `.git/entire-sessions/<session-id>.json`

Stored in git common dir (shared across worktrees). Tracks active session info.

#### Checkpoint ID Fields

Two checkpoint ID fields in session state track different lifecycle stages:

| Field | Set By | Cleared By | Purpose |
|-------|--------|------------|---------|
| `PendingCheckpointID` | PostCommit (ACTIVE → ACTIVE_COMMITTED) | `condenseAndUpdateState` | Deferred condensation coordination |
| `LastCheckpointID` | `condenseAndUpdateState` | `InitializeSession` (new prompt) | Amend trailer restoration |
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

LastCheckpointID is documented as only for “Amend trailer restoration”, but in the manual-commit strategy it’s also used to reuse/link an existing condensed checkpoint when there’s no new content (e.g., split Claude’s work across multiple commits). Consider expanding the Purpose (and/or the surrounding text) to reflect both behaviors so readers don’t assume it’s amend-only.

Suggested change
| `LastCheckpointID` | `condenseAndUpdateState` | `InitializeSession` (new prompt) | Amend trailer restoration |
| `LastCheckpointID` | `condenseAndUpdateState` | `InitializeSession` (new prompt) | Amend trailer restoration and reusing/linking an existing condensed checkpoint when new commits have no additional content (e.g., manual-commit strategy) |

Copilot uses AI. Check for mistakes.

**Key invariant:** `PendingCheckpointID` is for **deferred condensation only** — it must NEVER be reused when generating checkpoint IDs for new commits. Each new commit gets a fresh ID; idempotency for hook re-runs is handled by checking if the commit message already has a trailer.

**Deferred condensation flow:**

```
1. Agent is working [ACTIVE phase]
2. User commits mid-turn
→ PostCommit sets PendingCheckpointID
→ Phase transitions to ACTIVE_COMMITTED
→ Condensation deferred (agent still working)
3. Agent continues working [ACTIVE_COMMITTED phase]
4. Agent stops (TurnEnd event)
→ handleTurnEndCondense runs
→ Uses PendingCheckpointID for condensation
→ Clears PendingCheckpointID
→ Sets LastCheckpointID
5. Session idle [IDLE phase]
```

**Why not reuse `PendingCheckpointID` for new commits:**

If a user makes multiple commits during a session, each commit needs a unique checkpoint ID. `PendingCheckpointID` persists from step 2 until step 4 completes. If `PrepareCommitMsg` reused it for a second commit before condensation finished, both commits would share the same checkpoint ID — corrupting the 1:1 link between commits and metadata.

### Temporary Checkpoints

Branch: `entire/<commit[:7]>-<worktreeHash[:6]>`
Expand Down
Loading