Skip to content
Draft
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
106 changes: 98 additions & 8 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
Expand Down Expand Up @@ -632,7 +633,8 @@
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 {
Expand All @@ -648,6 +650,7 @@
)

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,
Expand All @@ -674,6 +677,7 @@
)

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,
Expand Down Expand Up @@ -1014,7 +1018,13 @@
// 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())
}

Expand Down Expand Up @@ -2246,6 +2256,17 @@

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 = ""
Comment on lines +2262 to +2267
}

// Update each checkpoint with the full transcript
for _, cpIDStr := range state.TurnCheckpointIDs {
cpID, parseErr := id.NewCheckpointID(cpIDStr)
Expand All @@ -2265,19 +2286,60 @@
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
Expand All @@ -2290,6 +2352,34 @@
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,

Check failure on line 2372 in cmd/entire/cli/strategy/manual_commit_hooks.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `(*os/exec.Cmd).Output` is not checked (errcheck)
"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
Expand Down
175 changes: 175 additions & 0 deletions cmd/entire/cli/strategy/phase_postcommit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading