From 7da585ab464b30ad9afa2f04cb1737abe104aec2 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 3 Mar 2026 21:22:53 -0800 Subject: [PATCH] perf: force condence ended orphaned ended sessions --- .../carry_forward_overlap_test.go | 78 ++--- .../cli/strategy/accumulation_bench_test.go | 313 ++++++++++++++++++ .../cli/strategy/manual_commit_hooks.go | 59 +++- .../cli/strategy/phase_postcommit_test.go | 45 +-- 4 files changed, 400 insertions(+), 95 deletions(-) create mode 100644 cmd/entire/cli/strategy/accumulation_bench_test.go diff --git a/cmd/entire/cli/integration_test/carry_forward_overlap_test.go b/cmd/entire/cli/integration_test/carry_forward_overlap_test.go index e27e2cf09..4ca52aeec 100644 --- a/cmd/entire/cli/integration_test/carry_forward_overlap_test.go +++ b/cmd/entire/cli/integration_test/carry_forward_overlap_test.go @@ -9,11 +9,12 @@ import ( ) // TestCarryForward_NewSessionCommitDoesNotCondenseOldSession verifies that when -// an old session has carry-forward files and a NEW session commits unrelated files, -// the old session is NOT condensed into the new session's commit. +// an old ENDED session has carry-forward files and a NEW session commits unrelated +// files, the old session IS force-condensed to prevent unbounded accumulation +// (GitHub issue #591). // -// This is a regression test for the bug where sessions with carry-forward files -// would be re-condensed into every subsequent commit indefinitely. +// Without force-condensation, ENDED sessions that fail the file overlap check +// persist forever, re-processed on every future commit at ~73-103ms each. // // This integration test complements the unit tests in phase_postcommit_test.go by // testing the full hook invocation path with multiple sessions interacting. @@ -25,9 +26,8 @@ import ( // 4. Session 1 ends // 5. Make some unrelated commits (simulating time passing) // 6. New session 2 creates and commits file6.txt -// 7. Verify: Session 1 was NOT condensed into session 2's commit -// 8. Finally commit file2.txt -// 9. Verify: Session 1 IS condensed (carry-forward consumed) +// 7. Verify: Session 1 WAS force-condensed and marked FullyCondensed +// 8. Verify: Session 2 WAS condensed normally func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) { t.Parallel() env := NewTestEnv(t) @@ -88,8 +88,6 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) { } t.Logf("Session1 (ENDED) FilesTouched: %v", state1.FilesTouched) - session1StepCount := state1.StepCount - // ======================================== // Phase 2: Make some unrelated commits (simulating time passing) // ======================================== @@ -141,34 +139,32 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) { finalHead := env.GetHeadHash() // ======================================== - // Phase 5: Verify session 1 was NOT condensed + // Phase 5: Verify session 1 WAS force-condensed (no overlap, but ENDED) // ======================================== - t.Log("Phase 5: Verifying session 1 (with carry-forward) was NOT condensed") + t.Log("Phase 5: Verifying session 1 (ENDED, no overlap) was force-condensed") state1After, err := env.GetSessionState(session1.ID) if err != nil { t.Fatalf("GetSessionState for session1 after session2 commit failed: %v", err) } - // StepCount should be unchanged - if state1After.StepCount != session1StepCount { - t.Errorf("Session 1 StepCount changed! Expected %d, got %d (incorrectly condensed into session 2's commit)", - session1StepCount, state1After.StepCount) + // StepCount should be reset to 0 (force-condensation happened) + if state1After.StepCount != 0 { + t.Errorf("Session 1 StepCount should be 0 after force-condensation, got %d", state1After.StepCount) } - // FilesTouched should still have file2.txt - hasFile2 := false - for _, f := range state1After.FilesTouched { - if f == "file2.txt" { - hasFile2 = true - break - } + // FilesTouched should be empty (no carry-forward for force-condensed sessions) + if len(state1After.FilesTouched) != 0 { + t.Errorf("Session 1 FilesTouched should be empty after force-condensation, got: %v", state1After.FilesTouched) } - if !hasFile2 { - t.Errorf("Session 1 FilesTouched was cleared! Expected file2.txt, got: %v", state1After.FilesTouched) + + // FullyCondensed should be true + if !state1After.FullyCondensed { + t.Errorf("Session 1 should be marked FullyCondensed after force-condensation") } - t.Logf("Session 1 correctly preserved: StepCount=%d, FilesTouched=%v", state1After.StepCount, state1After.FilesTouched) + t.Logf("Session 1 correctly force-condensed: StepCount=%d, FilesTouched=%v, FullyCondensed=%v", + state1After.StepCount, state1After.FilesTouched, state1After.FullyCondensed) // ======================================== // Phase 6: Verify session 2 WAS condensed @@ -185,35 +181,7 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) { finalHead[:7], state2After.BaseCommit[:7]) } - // ======================================== - // Phase 7: Finally commit file2.txt (session 1's carry-forward file) - // ======================================== - t.Log("Phase 7: Committing file2.txt (session 1's carry-forward file)") - - env.GitAdd("file2.txt") - env.GitCommitWithShadowHooks("Add file2 (session 1 carry-forward)", "file2.txt") - - // ======================================== - // Phase 8: Verify session 1 WAS condensed this time - // ======================================== - t.Log("Phase 8: Verifying session 1 WAS condensed when its carry-forward file was committed") - - state1Final, err := env.GetSessionState(session1.ID) - if err != nil { - t.Fatalf("GetSessionState for session1 after file2 commit failed: %v", err) - } - - // StepCount should be reset to 0 (condensation happened) - if state1Final.StepCount != 0 { - t.Errorf("Session 1 StepCount should be 0 after condensation, got %d", state1Final.StepCount) - } - - // FilesTouched should be empty (carry-forward consumed) - if len(state1Final.FilesTouched) != 0 { - t.Errorf("Session 1 FilesTouched should be empty after condensation, got: %v", state1Final.FilesTouched) - } - t.Log("Test completed successfully:") - t.Log(" - Session 1 NOT condensed into session 2's commit (file6.txt)") - t.Log(" - Session 1 WAS condensed when its own file (file2.txt) was committed") + t.Log(" - Session 1 force-condensed into session 2's commit (ENDED, no overlap)") + t.Log(" - Session 2 condensed normally") } diff --git a/cmd/entire/cli/strategy/accumulation_bench_test.go b/cmd/entire/cli/strategy/accumulation_bench_test.go new file mode 100644 index 000000000..a85f4c75b --- /dev/null +++ b/cmd/entire/cli/strategy/accumulation_bench_test.go @@ -0,0 +1,313 @@ +package strategy + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/trailers" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// BenchmarkPostCommit_EndedSessionAccumulation measures the PostCommit overhead +// caused by accumulated ENDED sessions whose files don't overlap with the committed +// files. This is the exact scenario from GitHub issue #591. +// +// On main (before fix): each ENDED session adds ~73-103ms per commit, forever. +// After fix: first commit force-condenses all, subsequent commits skip them. +func BenchmarkPostCommit_EndedSessionAccumulation(b *testing.B) { + for _, count := range []int{10, 50, 100, 200} { + b.Run(fmt.Sprintf("EndedSessions_%d", count), benchEndedSessionAccumulation(count, repoProfileSmall)) + } +} + +// BenchmarkPostCommit_RepoProfiles measures PostCommit with 50 accumulated ENDED +// sessions across different repository profiles. +func BenchmarkPostCommit_RepoProfiles(b *testing.B) { + for _, profile := range []struct { + name string + fn repoProfile + }{ + {"SmallRepo_100files", repoProfileSmall}, + {"LargeFiles_50x20MB", repoProfileLargeFiles}, + // 500k files takes too long to set up for benchmarks; use 1000 as proxy + {"ManyFiles_1000", repoProfileManyFiles}, + } { + b.Run(profile.name, benchEndedSessionAccumulation(50, profile.fn)) + } +} + +// BenchmarkPostCommit_SecondCommitAfterFix measures the key metric: how fast is +// the SECOND commit after force-condensation cleaned up all ENDED sessions? +// On main: same as first commit (sessions persist). After fix: near-zero overhead. +func BenchmarkPostCommit_SecondCommitAfterFix(b *testing.B) { + for _, count := range []int{50, 100, 200} { + b.Run(fmt.Sprintf("SecondCommit_%dEndedSessions", count), func(b *testing.B) { + for range b.N { + b.StopTimer() + dir := setupAccumulationRepo(b, count, repoProfileSmall) + b.Chdir(dir) + paths.ClearWorktreeRootCache() + + // First PostCommit: processes all ENDED sessions + s := &ManualCommitStrategy{} + if err := s.PostCommit(context.Background()); err != nil { + b.Fatalf("PostCommit 1: %v", err) + } + + // Create another commit for the second PostCommit + repo, err := git.PlainOpen(dir) + if err != nil { + b.Fatalf("open: %v", err) + } + wt, err := repo.Worktree() + if err != nil { + b.Fatalf("worktree: %v", err) + } + newFile := filepath.Join(dir, "second_commit.txt") + if err := os.WriteFile(newFile, []byte("second commit content"), 0o644); err != nil { + b.Fatalf("write: %v", err) + } + if _, err := wt.Add("second_commit.txt"); err != nil { + b.Fatalf("add: %v", err) + } + cpID, err := id.Generate() + if err != nil { + b.Fatalf("generate ID: %v", err) + } + commitMsg := fmt.Sprintf("second commit\n\n%s: %s\n", trailers.CheckpointTrailerKey, cpID) + if _, err := wt.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{Name: "Bench", Email: "bench@test.com", When: time.Now()}, + }); err != nil { + b.Fatalf("commit: %v", err) + } + paths.ClearWorktreeRootCache() + + b.StartTimer() + + // Second PostCommit: should be fast (all ENDED sessions FullyCondensed) + s2 := &ManualCommitStrategy{} + if err := s2.PostCommit(context.Background()); err != nil { + b.Fatalf("PostCommit 2: %v", err) + } + } + }) + } +} + +type repoProfile func(b *testing.B, dir string, wt *git.Worktree) + +// repoProfileSmall creates 100 small files (~1KB each). +func repoProfileSmall(b *testing.B, dir string, wt *git.Worktree) { + b.Helper() + for i := range 100 { + name := fmt.Sprintf("src/file_%d.go", i) + abs := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + b.Fatalf("mkdir: %v", err) + } + content := fmt.Sprintf("package main\n\nfunc f%d() {\n\treturn\n}\n", i) + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + b.Fatalf("write: %v", err) + } + if _, err := wt.Add(name); err != nil { + b.Fatalf("add: %v", err) + } + } +} + +// repoProfileLargeFiles creates 50 files at ~20MB each. +func repoProfileLargeFiles(b *testing.B, dir string, wt *git.Worktree) { + b.Helper() + // 20MB of repeated content + chunk := make([]byte, 20*1024*1024) + for i := range chunk { + chunk[i] = byte('A' + (i % 26)) + } + for i := range 50 { + name := fmt.Sprintf("data/large_%d.bin", i) + abs := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + b.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(abs, chunk, 0o644); err != nil { + b.Fatalf("write: %v", err) + } + if _, err := wt.Add(name); err != nil { + b.Fatalf("add: %v", err) + } + } +} + +// repoProfileManyFiles creates 1000 tiny files (~100 bytes each). +// (Proxy for 500k files — full 500k is too slow for benchmarks.) +func repoProfileManyFiles(b *testing.B, dir string, wt *git.Worktree) { + b.Helper() + for i := range 1000 { + subdir := fmt.Sprintf("pkg/d%d", i/100) + name := fmt.Sprintf("%s/f%d.go", subdir, i) + abs := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + b.Fatalf("mkdir: %v", err) + } + content := fmt.Sprintf("package d%d\nvar x%d = %d\n", i/100, i, i) + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + b.Fatalf("write: %v", err) + } + if _, err := wt.Add(name); err != nil { + b.Fatalf("add: %v", err) + } + } +} + +func benchEndedSessionAccumulation(sessionCount int, profile repoProfile) func(*testing.B) { + return func(b *testing.B) { + for range b.N { + b.StopTimer() + dir := setupAccumulationRepo(b, sessionCount, profile) + b.Chdir(dir) + paths.ClearWorktreeRootCache() + b.StartTimer() + + s := &ManualCommitStrategy{} + if err := s.PostCommit(context.Background()); err != nil { + b.Fatalf("PostCommit: %v", err) + } + } + } +} + +// setupAccumulationRepo creates a repo with the given profile, then creates N +// ENDED sessions whose files DON'T overlap with the final committed file. +// This is the exact accumulation scenario from issue #591. +func setupAccumulationRepo(b *testing.B, sessionCount int, profile repoProfile) string { + b.Helper() + + dir := b.TempDir() + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + repo, err := git.PlainInit(dir, false) + if err != nil { + b.Fatalf("git init: %v", err) + } + + cfg, err := repo.Config() + if err != nil { + b.Fatalf("config: %v", err) + } + cfg.User.Name = "Bench User" + cfg.User.Email = "bench@example.com" + if err := repo.SetConfig(cfg); err != nil { + b.Fatalf("set config: %v", err) + } + + wt, err := repo.Worktree() + if err != nil { + b.Fatalf("worktree: %v", err) + } + + // Apply repo profile (creates files and adds them) + profile(b, dir, wt) + + if _, err := wt.Commit("initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Bench", Email: "bench@test.com", When: time.Now()}, + }); err != nil { + b.Fatalf("commit: %v", err) + } + + s := &ManualCommitStrategy{} + b.Chdir(dir) + paths.ClearWorktreeRootCache() + + // Create N ENDED sessions, each touching a UNIQUE file that won't be in the + // final commit. This simulates sessions that ended without their files being committed. + for i := range sessionCount { + sessionID := fmt.Sprintf("ended-session-%d", i) + agentFile := fmt.Sprintf("agent_work_%d.txt", i) + agentFileAbs := filepath.Join(dir, agentFile) + + // Create the file the agent "modified" + if err := os.WriteFile(agentFileAbs, []byte(fmt.Sprintf("agent work %d", i)), 0o644); err != nil { + b.Fatalf("write: %v", err) + } + + // Create metadata with transcript + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil { + b.Fatalf("mkdir: %v", err) + } + transcript := fmt.Sprintf(`{"type":"human","message":{"content":"do task %d"}} +{"type":"assistant","message":{"content":"Done with task %d."}} +`, i, i) + if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil { + b.Fatalf("write transcript: %v", err) + } + + paths.ClearWorktreeRootCache() + + if err := s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{}, + NewFiles: []string{agentFile}, + DeletedFiles: []string{}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: fmt.Sprintf("Checkpoint: task %d", i), + AuthorName: "Agent", + AuthorEmail: "agent@test.com", + }); err != nil { + b.Fatalf("SaveStep: %v", err) + } + + // Set session to ENDED with FilesTouched = the agent's file (NOT in final commit) + state, err := s.loadSessionState(context.Background(), sessionID) + if err != nil { + b.Fatalf("load state: %v", err) + } + state.Phase = session.PhaseEnded + now := time.Now() + state.EndedAt = &now + state.FilesTouched = []string{agentFile} + state.CheckpointTranscriptStart = 0 // so sessionHasNewContent returns true + if err := s.saveSessionState(context.Background(), state); err != nil { + b.Fatalf("save state: %v", err) + } + + // Clean up the agent file from worktree (user discarded changes) + os.Remove(agentFileAbs) + } + + // Create the final commit that PostCommit will process. + // This commit touches an UNRELATED file — no overlap with any session's files. + unrelatedFile := filepath.Join(dir, "user_commit.txt") + if err := os.WriteFile(unrelatedFile, []byte("user's own work"), 0o644); err != nil { + b.Fatalf("write: %v", err) + } + if _, err := wt.Add("user_commit.txt"); err != nil { + b.Fatalf("add: %v", err) + } + + cpID, err := id.Generate() + if err != nil { + b.Fatalf("generate ID: %v", err) + } + commitMsg := fmt.Sprintf("user commit\n\n%s: %s\n", trailers.CheckpointTrailerKey, cpID) + if _, err := wt.Commit(commitMsg, &git.CommitOptions{ + Author: &object.Signature{Name: "User", Email: "user@test.com", When: time.Now()}, + }); err != nil { + b.Fatalf("commit: %v", err) + } + + return dir +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index d68c5d8f7..42d5533b6 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -595,7 +595,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 + forceCondensed bool // true when ENDED session was condensed without file overlap } func (h *postCommitActionHandler) HandleCondense(state *session.State) error { @@ -634,12 +635,27 @@ func (h *postCommitActionHandler) HandleCondenseIfFilesTouched(state *session.St slog.String("shadow_branch", h.shadowBranchName), ) - if shouldCondense { + switch { + case shouldCondense: 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, }) - } else { + case len(state.FilesTouched) > 0 && h.hasNew: + // Force-condense: ENDED session with files but no commit overlap. + // Without this, the session persists indefinitely — re-processed on + // every future commit at ~73-103ms each, causing O(N) accumulation. + // Pass nil committedFiles to preserve all FilesTouched (no filtering). + h.condensed = h.s.condenseAndUpdateState(h.ctx, h.repo, h.checkpointID, state, h.head, h.shadowBranchName, h.shadowBranchesToDelete, nil, condenseOpts{ + shadowRef: h.shadowRef, + headTree: h.headTree, + }) + h.forceCondensed = true + logging.Info(logCtx, "post-commit: force-condensed ended session (no commit overlap)", + slog.String("session_id", state.SessionID), + slog.Int("files_touched", len(state.FilesTouched)), + ) + default: h.s.updateBaseCommitIfChanged(h.ctx, state, h.newHead) } return nil @@ -954,21 +970,28 @@ func (s *ManualCommitStrategy) postCommitProcessSession( // Uses content-aware comparison: if user did `git add -p` and committed // partial changes, the file still has remaining agent changes to carry forward. if handler.condensed { - remainingFiles := filesWithRemainingAgentChanges(ctx, repo, shadowBranchName, commit, filesTouchedBefore, committedFileSet, overlapOpts{ - headTree: headTree, - shadowTree: shadowTree, - }) - state.FilesTouched = remainingFiles - logging.Debug(logCtx, "post-commit: carry-forward decision (content-aware)", - slog.String("session_id", state.SessionID), - slog.Int("files_touched_before", len(filesTouchedBefore)), - slog.Int("committed_files", len(committedFileSet)), - slog.Int("remaining_files", len(remainingFiles)), - slog.Any("remaining", remainingFiles), - slog.Any("committed_files", committedFileSet), - ) - if len(remainingFiles) > 0 { - s.carryForwardToNewShadowBranch(ctx, repo, state, remainingFiles) + if handler.forceCondensed { + state.FilesTouched = nil + logging.Debug(logCtx, "post-commit: skip carry-forward (force-condensed ended session)", + slog.String("session_id", state.SessionID), + ) + } else { + remainingFiles := filesWithRemainingAgentChanges(ctx, repo, shadowBranchName, commit, filesTouchedBefore, committedFileSet, overlapOpts{ + headTree: headTree, + shadowTree: shadowTree, + }) + state.FilesTouched = remainingFiles + logging.Debug(logCtx, "post-commit: carry-forward decision (content-aware)", + slog.String("session_id", state.SessionID), + slog.Int("files_touched_before", len(filesTouchedBefore)), + slog.Int("committed_files", len(committedFileSet)), + slog.Int("remaining_files", len(remainingFiles)), + slog.Any("remaining", remainingFiles), + slog.Any("committed_files", committedFileSet), + ) + if len(remainingFiles) > 0 { + s.carryForwardToNewShadowBranch(ctx, repo, state, remainingFiles) + } } } diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 5c4be7eb2..ec38efa32 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -1550,16 +1550,16 @@ func TestPostCommit_OldEndedSession_BaseCommitNotUpdated(t *testing.T) { "NEW ACTIVE session's BaseCommit should be updated after condensation") } -// TestPostCommit_EndedSessionCarryForward_NotCondensedIntoUnrelatedCommit verifies -// that an ENDED session with carry-forward files is NOT condensed into a commit -// that doesn't touch any of those files. +// TestPostCommit_EndedSessionCarryForward_ForceCondensedWithoutOverlap verifies +// that an ENDED session with carry-forward files IS force-condensed even when the +// commit doesn't touch any of those files. // -// This is the primary bug scenario: ENDED sessions go through HandleCondenseIfFilesTouched, -// which previously only checked len(FilesTouched) > 0 && hasNew — no overlap check. -// Carry-forward would set FilesTouched with remaining uncommitted files, and -// sessionHasNewContent returned true because the shadow branch had content. This -// caused ENDED sessions to be re-condensed into every subsequent commit indefinitely. -func TestPostCommit_EndedSessionCarryForward_NotCondensedIntoUnrelatedCommit(t *testing.T) { +// Without force-condensation, ENDED sessions that fail the overlap check would +// persist indefinitely — re-processed on every future commit at ~73-103ms each, +// causing O(N) accumulation (GitHub issue #591). Force-condensation preserves all +// FilesTouched on the checkpoints branch, then clears them to mark the session +// FullyCondensed so it's skipped on future commits. +func TestPostCommit_EndedSessionCarryForward_ForceCondensedWithoutOverlap(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) @@ -1583,9 +1583,6 @@ func TestPostCommit_EndedSessionCarryForward_NotCondensedIntoUnrelatedCommit(t * endedState.CheckpointTranscriptStart = 0 require.NoError(t, s.saveSessionState(context.Background(), endedState)) - endedOriginalBaseCommit := endedState.BaseCommit - endedOriginalStepCount := endedState.StepCount - // Move HEAD forward with an unrelated commit (no trailer) wt, err := repo.Worktree() require.NoError(t, err) @@ -1651,26 +1648,30 @@ func TestPostCommit_EndedSessionCarryForward_NotCondensedIntoUnrelatedCommit(t * err = s.PostCommit(context.Background()) require.NoError(t, err) - // --- Verify: ENDED session was NOT condensed --- + // --- Verify: ENDED session WAS force-condensed --- endedState, err = s.loadSessionState(context.Background(), endedSessionID) require.NoError(t, err) - // StepCount should be unchanged (not reset by condensation) - assert.Equal(t, endedOriginalStepCount, endedState.StepCount, - "ENDED session StepCount should NOT be reset (no condensation)") + // StepCount should be reset by condensation + assert.Equal(t, 0, endedState.StepCount, + "ENDED session StepCount should be reset by force-condensation") - // BaseCommit should NOT be updated for ENDED sessions (PR #359) - assert.Equal(t, endedOriginalBaseCommit, endedState.BaseCommit, - "ENDED session BaseCommit should NOT be updated") + // BaseCommit should be updated to newHead by condensation + assert.Equal(t, newHead, endedState.BaseCommit, + "ENDED session BaseCommit should be updated after force-condensation") - // FilesTouched should still have the carry-forward files (not cleared by condensation) - assert.Equal(t, []string{"test.txt"}, endedState.FilesTouched, - "ENDED session FilesTouched should be preserved (carry-forward files not consumed)") + // FilesTouched should be nil (no carry-forward for force-condensed sessions) + assert.Empty(t, endedState.FilesTouched, + "ENDED session FilesTouched should be cleared (no carry-forward)") // Phase stays ENDED assert.Equal(t, session.PhaseEnded, endedState.Phase, "ENDED session should remain ENDED") + // FullyCondensed should be true (ENDED + condensed + no FilesTouched) + assert.True(t, endedState.FullyCondensed, + "ENDED session should be marked FullyCondensed after force-condensation") + // --- Verify: new ACTIVE session WAS condensed --- newState, err = s.loadSessionState(context.Background(), newSessionID) require.NoError(t, err)