diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 7d2f68428..d7ae39fcd 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log/slog" "os" @@ -632,7 +633,8 @@ type postCommitActionHandler struct { shadowTree *object.Tree // Per-session shadow commit tree (nil if branch doesn't exist) // Output: set by handler methods, read by caller after TransitionAndLog. - condensed bool + condensed bool // true if condensation succeeded + condenseAttempted bool // true if condensation was attempted (regardless of outcome) } func (h *postCommitActionHandler) HandleCondense(state *session.State) error { @@ -648,6 +650,7 @@ func (h *postCommitActionHandler) HandleCondense(state *session.State) error { ) if shouldCondense { + h.condenseAttempted = true h.condensed = h.s.condenseAndUpdateState(h.ctx, h.repo, h.checkpointID, state, h.head, h.shadowBranchName, h.shadowBranchesToDelete, h.committedFileSet, condenseOpts{ shadowRef: h.shadowRef, headTree: h.headTree, @@ -674,6 +677,7 @@ func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.St ) if shouldCondense { + h.condenseAttempted = true h.condensed = h.s.condenseAndUpdateState(h.ctx, h.repo, h.checkpointID, state, h.head, h.shadowBranchName, h.shadowBranchesToDelete, h.committedFileSet, condenseOpts{ shadowRef: h.shadowRef, headTree: h.headTree, @@ -1014,7 +1018,13 @@ func (s *ManualCommitStrategy) postCommitProcessSession( // NOTE: This check runs AFTER TransitionAndLog updated the phase. It relies on // ACTIVE + GitCommit → ACTIVE (phase stays ACTIVE). If that state machine // transition ever changed, this guard would silently stop recording IDs. - if handler.condensed && state.Phase.IsActive() { + // + // Track the ID when condensation was attempted (condenseAttempted), even if it + // failed (e.g., empty transcript at commit time). The stop-time finalizer will + // retry failed condensations with the full transcript available. + // Do NOT track when condensation wasn't attempted (shouldCondense was false) — + // that means the commit wasn't eligible for condensation. + if handler.condenseAttempted && state.Phase.IsActive() { state.TurnCheckpointIDs = append(state.TurnCheckpointIDs, checkpointID.String()) } @@ -2246,6 +2256,17 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s store := checkpoint.NewGitStore(repo) + // Get author info and branch name for potential WriteCommitted fallback. + authorName, authorEmail := GetGitAuthorFromRepo(repo) + branchName := GetCurrentBranchName(repo) + repoDir, repoErr := paths.WorktreeRoot(ctx) + if repoErr != nil { + logging.Warn(ctx, "finalize: failed to resolve worktree root, filesTouched fallback unavailable", + slog.String("error", repoErr.Error()), + ) + repoDir = "" + } + // Update each checkpoint with the full transcript for _, cpIDStr := range state.TurnCheckpointIDs { cpID, parseErr := id.NewCheckpointID(cpIDStr) @@ -2265,19 +2286,60 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s Prompts: prompts, Agent: state.AgentType, }) - if updateErr != nil { - logging.Warn(logCtx, "finalize: failed to update checkpoint", + if updateErr == nil { + logging.Info(logCtx, "finalize: checkpoint updated with full transcript", slog.String("checkpoint_id", cpIDStr), - slog.String("error", updateErr.Error()), + slog.String("session_id", state.SessionID), ) - errCount++ continue } - logging.Info(logCtx, "finalize: checkpoint updated with full transcript", + // If the checkpoint doesn't exist (condensation failed at commit time, + // e.g., because the transcript was empty), create it from scratch. + if errors.Is(updateErr, checkpoint.ErrCheckpointNotFound) { + logging.Info(logCtx, "finalize: checkpoint missing, creating from full transcript", + slog.String("checkpoint_id", cpIDStr), + slog.String("session_id", state.SessionID), + ) + + filesTouched := filesTouchedByCheckpointTrailer(ctx, repoDir, cpIDStr) + + writeErr := store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: state.SessionID, + Strategy: StrategyNameManualCommit, + Branch: branchName, + Transcript: fullTranscript, + Prompts: prompts, + FilesTouched: filesTouched, + AuthorName: authorName, + AuthorEmail: authorEmail, + Agent: state.AgentType, + Model: state.ModelName, + TurnID: state.TurnID, + CheckpointTranscriptStart: state.CheckpointTranscriptStart, + TranscriptIdentifierAtStart: state.TranscriptIdentifierAtStart, + }) + if writeErr != nil { + logging.Warn(logCtx, "finalize: failed to create missing checkpoint", + slog.String("checkpoint_id", cpIDStr), + slog.String("error", writeErr.Error()), + ) + errCount++ + } else { + logging.Info(logCtx, "finalize: checkpoint created with full transcript", + slog.String("checkpoint_id", cpIDStr), + slog.String("session_id", state.SessionID), + ) + } + continue + } + + logging.Warn(logCtx, "finalize: failed to update checkpoint", slog.String("checkpoint_id", cpIDStr), - slog.String("session_id", state.SessionID), + slog.String("error", updateErr.Error()), ) + errCount++ } // Clear turn checkpoint IDs. Do NOT update CheckpointTranscriptStart here — it was @@ -2290,6 +2352,34 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s return errCount } +// filesTouchedByCheckpointTrailer finds the commit with a given Entire-Checkpoint trailer +// and returns the files changed in that commit. Used as a fallback when condensation failed +// at commit time and needs to be retried at stop time. +func filesTouchedByCheckpointTrailer(ctx context.Context, repoDir string, cpID string) []string { + if repoDir == "" { + return nil + } + + // Find the commit with this checkpoint trailer on the current branch. + out, err := exec.CommandContext(ctx, "git", "-C", repoDir, + "log", "HEAD", "--grep=Entire-Checkpoint: "+cpID, "--format=%H", "-1").Output() + if err != nil || len(bytes.TrimSpace(out)) == 0 { + return nil + } + hash := strings.TrimSpace(string(out)) + + // Get the parent hash for diff-tree (empty string triggers --root mode for initial commits). + parentOut, _ := exec.CommandContext(ctx, "git", "-C", repoDir, + "rev-parse", "--verify", hash+"~1").Output() + parentHash := strings.TrimSpace(string(parentOut)) + + files, err := gitops.DiffTreeFileList(ctx, repoDir, parentHash, hash) + if err != nil { + return nil + } + return files +} + // filesChangedInCommit returns the set of files changed in a commit using git diff-tree. // Uses the git CLI for faster performance vs go-git tree walks (lower constant factors). // Falls back to go-git tree walk if git diff-tree fails, since an empty result would diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 3f926d15d..cbf0d3275 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -1320,6 +1320,181 @@ func TestHandleTurnEnd_PartialFailure(t *testing.T) { } } +// TestPostCommit_FailedCondensation_StillTracksTurnCheckpointID verifies that when +// condensation is attempted but fails (e.g., empty transcript at commit time), +// the checkpoint ID is still recorded in TurnCheckpointIDs so the stop-time +// finalizer can retry. +// +// This simulates the Cursor race condition: cursor commits before writing its +// transcript, so there's no shadow branch and the live transcript is empty. +func TestPostCommit_FailedCondensation_StillTracksTurnCheckpointID(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + head, err := repo.Head() + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-failed-condense-tracks-id" + + // Create session state directly WITHOUT a shadow branch — simulates Cursor + // which doesn't call SaveStep (no tool-use hooks). Condensation will go + // through extractSessionDataFromLiveTranscript, not the shadow branch path. + emptyTranscript := filepath.Join(dir, ".entire", "metadata", sessionID, "empty.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(emptyTranscript), 0o755)) + require.NoError(t, os.WriteFile(emptyTranscript, []byte(""), 0o644)) + + // Use paths.WorktreeRoot to get the resolved path (macOS /private/var vs /var). + worktreePath, err := paths.WorktreeRoot(context.Background()) + require.NoError(t, err) + + now := time.Now() + state := &SessionState{ + SessionID: sessionID, + BaseCommit: head.Hash().String(), + WorktreePath: worktreePath, + Phase: session.PhaseActive, + AgentType: "cursor", + TranscriptPath: emptyTranscript, + StartedAt: now, + LastInteractionTime: &now, + } + require.NoError(t, s.saveSessionState(context.Background(), state)) + + commitWithCheckpointTrailer(t, repo, dir, "dead0000beef") + + err = s.PostCommit(context.Background()) + require.NoError(t, err) + + // Verify TurnCheckpointIDs was populated even though condensation failed + state, err = s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + assert.Equal(t, []string{"dead0000beef"}, state.TurnCheckpointIDs, + "TurnCheckpointIDs should contain the checkpoint ID even when condensation fails") + + // Verify the checkpoint does NOT exist on the metadata branch (condensation failed) + store := checkpoint.NewGitStore(repo) + cpID := id.MustCheckpointID("dead0000beef") + _, readErr := store.ReadSessionContent(context.Background(), cpID, 0) + assert.Error(t, readErr, + "Checkpoint should NOT exist on metadata branch since condensation failed") +} + +// TestHandleTurnEnd_CreatesCheckpointOnCondensationFailure verifies that when a +// checkpoint ID was tracked but condensation failed at commit time (no checkpoint +// on the metadata branch), HandleTurnEnd creates the checkpoint from scratch +// using the full transcript now available. +// +// This is the second half of the Cursor race condition fix: the stop-time +// finalizer retries the failed condensation with the transcript now written. +func TestHandleTurnEnd_CreatesCheckpointOnCondensationFailure(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + head, err := repo.Head() + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-create-missing-checkpoint" + + // Create session state without shadow branch (simulates Cursor) + emptyTranscript := filepath.Join(dir, ".entire", "metadata", sessionID, "empty.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(emptyTranscript), 0o755)) + require.NoError(t, os.WriteFile(emptyTranscript, []byte(""), 0o644)) + + worktreePath, err := paths.WorktreeRoot(context.Background()) + require.NoError(t, err) + + now := time.Now() + state := &SessionState{ + SessionID: sessionID, + BaseCommit: head.Hash().String(), + WorktreePath: worktreePath, + Phase: session.PhaseActive, + AgentType: "cursor", + TranscriptPath: emptyTranscript, + StartedAt: now, + LastInteractionTime: &now, + } + require.NoError(t, s.saveSessionState(context.Background(), state)) + + // Commit — condensation will fail (empty transcript, no shadow branch) but ID is tracked + commitWithCheckpointTrailer(t, repo, dir, "cafe0000babe") + require.NoError(t, s.PostCommit(context.Background())) + + // Confirm checkpoint is missing and ID was tracked + state, err = s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.Equal(t, []string{"cafe0000babe"}, state.TurnCheckpointIDs) + + // Now write the full transcript (simulating cursor finishing its turn) + fullTranscript := `{"type":"human","message":{"content":"create a file about red"}} +{"type":"assistant","message":{"content":"I created docs/red.md"}} +` + transcriptPath := filepath.Join(dir, ".entire", "metadata", sessionID, "full.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, []byte(fullTranscript), 0o644)) + state.TranscriptPath = transcriptPath + + // HandleTurnEnd should create the missing checkpoint + err = s.HandleTurnEnd(context.Background(), state) + require.NoError(t, err) + + // Verify the checkpoint was created on the metadata branch + store := checkpoint.NewGitStore(repo) + cpID := id.MustCheckpointID("cafe0000babe") + content, readErr := store.ReadSessionContent(context.Background(), cpID, 0) + require.NoError(t, readErr, + "Checkpoint should now exist on metadata branch after HandleTurnEnd") + assert.Contains(t, string(content.Transcript), "create a file about red", + "Checkpoint should contain the full transcript") + + // TurnCheckpointIDs should be cleared + assert.Empty(t, state.TurnCheckpointIDs, + "TurnCheckpointIDs should be cleared after HandleTurnEnd") +} + +// TestFilesTouchedByCheckpointTrailer verifies that filesTouchedByCheckpointTrailer +// finds the correct files changed in a commit identified by its Entire-Checkpoint trailer. +func TestFilesTouchedByCheckpointTrailer(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + // Create a file and commit with a checkpoint trailer + require.NoError(t, os.MkdirAll(filepath.Join(dir, "docs"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "docs/red.md"), []byte("red"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("docs/red.md") + require.NoError(t, err) + commitMsg := "add red\n\n" + trailers.CheckpointTrailerKey + ": abcd1234ef56\n" + _, err = wt.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + files := filesTouchedByCheckpointTrailer(context.Background(), dir, "abcd1234ef56") + assert.Equal(t, []string{"docs/red.md"}, files, + "Should find docs/red.md as the changed file") +} + +// TestFilesTouchedByCheckpointTrailer_NotFound verifies that filesTouchedByCheckpointTrailer +// returns nil when no commit matches the checkpoint ID. +func TestFilesTouchedByCheckpointTrailer_NotFound(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + files := filesTouchedByCheckpointTrailer(context.Background(), dir, "000000000000") + assert.Nil(t, files, "Should return nil when no commit matches") +} + // setupSessionWithCheckpoint initializes a session and creates one checkpoint // on the shadow branch so there is content available for condensation. // Also modifies test.txt to "agent modified content" and includes it in the checkpoint,