Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b3e97c4
perf: force condence ended orphaned ended sessions
peyton-alt Mar 4, 2026
7d9a370
fix after rebase
peyton-alt Mar 17, 2026
97083b6
fix: mark empty ENDED sessions as FullyCondensed
peyton-alt Mar 17, 2026
8044841
condense on session stop for sessions with no FilesTouched
peyton-alt Mar 17, 2026
3b91478
feat: add force-condense time gate for stale ENDED sessions
peyton-alt Mar 17, 2026
6563a2b
resolve lint error
peyton-alt Mar 17, 2026
617a790
test: add integration test for subagent accumulation
peyton-alt Mar 17, 2026
6da78a8
test: make accumulation benchmarks model stale ended sessions
peyton-alt Mar 17, 2026
ccb8c67
test: add issue #591 scale regression test (76 sessions, timed second…
peyton-alt Mar 18, 2026
ef991a8
pr feedback
peyton-alt Mar 18, 2026
8834108
simplify tests
peyton-alt Mar 18, 2026
ffb8daa
Merge branch 'main' into perf/force-condense-ended-sessions
peyton-alt Mar 18, 2026
efaf0d0
Merge branch 'main' into perf/force-condense-ended-sessions
peyton-alt Mar 20, 2026
ba5b4f7
perf: remove force-condense, keep eager condense on session stop
peyton-alt Mar 21, 2026
94f515f
Merge branch 'main' into perf/force-condense-ended-sessions
peyton-alt Mar 24, 2026
e597930
Merge remote-tracking branch 'origin/main' into perf/force-condense-e…
peyton-alt Mar 31, 2026
deaacdf
Merge branch 'main' into perf/force-condense-ended-sessions
peyton-alt Mar 31, 2026
3498498
Merge remote-tracking branch 'origin/main' into perf/force-condense-e…
peyton-alt Apr 1, 2026
52baba0
Merge branch 'main' into perf/force-condense-ended-sessions
peyton-alt Apr 2, 2026
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
132 changes: 45 additions & 87 deletions cmd/entire/cli/integration_test/carry_forward_overlap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,27 @@ package integration

import (
"testing"
"time"

"github.com/entireio/cli/cmd/entire/cli/session"
)

// 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.
// TestCarryForward_EndedSession_NotCondensedOnUnrelatedCommit verifies that an
// ENDED session with carry-forward files is NOT condensed when an unrelated file
// is committed (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.
//
// This integration test complements the unit tests in phase_postcommit_test.go by
// testing the full hook invocation path with multiple sessions interacting.
// Without eager-condense, sessions with FilesTouched must wait for those specific
// files to be committed. They should NOT be condensed into unrelated commits.
// The eager-condense-on-stop path (CondenseAndMarkFullyCondensed) handles sessions
// with no FilesTouched; sessions with FilesTouched wait for the overlap path.
//
// Scenario:
// 1. Session 1 creates file1.txt and file2.txt
// 2. User commits only file1.txt (partial commit)
// 3. Session 1 gets carry-forward: FilesTouched = ["file2.txt"]
// 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)
func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) {
// 2. User commits only file1.txt (partial commit) — session 1 carries forward file2.txt
// 3. Session 1 ends (FilesTouched = ["file2.txt"])
// 4. Session 2 commits unrelated file6.txt — session 1 NOT condensed (no overlap)
// 5. Session 2 IS condensed normally
func TestCarryForward_EndedSession_NotCondensedOnUnrelatedCommit(t *testing.T) {
t.Parallel()
env := NewTestEnv(t)
defer env.Cleanup()
Expand Down Expand Up @@ -71,40 +67,29 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) {
env.GitAdd("file1.txt")
env.GitCommitWithShadowHooks("Partial commit: only file1", "file1.txt")

// End session 1
// End session 1 (simulating user ending session while file2.txt is uncommitted)
state1, err := env.GetSessionState(session1.ID)
if err != nil {
t.Fatalf("GetSessionState for session1 failed: %v", err)
}
state1.Phase = session.PhaseEnded
endedAt := time.Now().Add(-2 * time.Hour)
state1.EndedAt = &endedAt
if err := env.WriteSessionState(session1.ID, state1); err != nil {
t.Fatalf("WriteSessionState for session1 failed: %v", err)
}

// Verify carry-forward
state1, err = env.GetSessionState(session1.ID)
if err != nil {
t.Fatalf("GetSessionState for session1 failed: %v", err)
}
t.Logf("Session1 (ENDED) FilesTouched: %v", state1.FilesTouched)

session1StepCount := state1.StepCount

// ========================================
// Phase 2: Make some unrelated commits (simulating time passing)
// ========================================
t.Log("Phase 2: Making unrelated commits")

for _, fileName := range []string{"file3.txt", "file4.txt"} {
env.WriteFile(fileName, "unrelated content")
env.GitAdd(fileName)
env.GitCommitWithShadowHooks("Add "+fileName, fileName)
}
originalStepCount := state1.StepCount

// ========================================
// Phase 3: NEW session 2 starts and creates file6.txt
// Phase 2: NEW session 2 starts and creates file6.txt
// ========================================
t.Log("Phase 3: Session 2 starts and creates file6.txt")
t.Log("Phase 2: Session 2 starts and creates file6.txt")

session2 := env.NewSession()
if err := env.SimulateUserPromptSubmit(session2.ID); err != nil {
Expand All @@ -120,7 +105,7 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) {
t.Fatalf("SimulateStop for session2 failed: %v", err)
}

// Set session2 to ACTIVE
// Set session2 to ACTIVE so PostCommit condenses it
state2, err := env.GetSessionState(session2.ID)
if err != nil {
t.Fatalf("GetSessionState for session2 failed: %v", err)
Expand All @@ -131,50 +116,51 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) {
}

// ========================================
// Phase 4: Commit file6.txt (session 2's file)
// Phase 3: Commit file6.txt (session 2's file, unrelated to session 1)
// ========================================
t.Log("Phase 4: Committing file6.txt from session 2")
t.Log("Phase 3: Committing file6.txt from session 2")

env.GitAdd("file6.txt")
env.GitCommitWithShadowHooks("Add file6 from session 2", "file6.txt")

finalHead := env.GetHeadHash()

// ========================================
// Phase 5: Verify session 1 was NOT condensed
// Phase 4: Verify session 1 was NOT condensed (no overlap with file2.txt)
// ========================================
t.Log("Phase 5: Verifying session 1 (with carry-forward) was NOT condensed")
t.Log("Phase 4: Verifying session 1 (ENDED, no overlap) was NOT condensed")

state1After, err := env.GetSessionState(session1.ID)
if err != nil {
t.Fatalf("GetSessionState for session1 after session2 commit failed: %v", err)
}
if state1After == nil {
t.Fatal("session1 state file should still exist — was not condensed")
}

// 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)
// Session 1 should still have its carry-forward FilesTouched intact
if len(state1After.FilesTouched) == 0 {
t.Errorf("Session 1 FilesTouched should still contain carry-forward files (file2.txt was not committed)")
}

// FilesTouched should still have file2.txt
hasFile2 := false
for _, f := range state1After.FilesTouched {
if f == "file2.txt" {
hasFile2 = true
break
}
// Session 1 should NOT be FullyCondensed
if state1After.FullyCondensed {
t.Errorf("Session 1 should NOT be FullyCondensed — file2.txt has not been committed")
}
if !hasFile2 {
t.Errorf("Session 1 FilesTouched was cleared! Expected file2.txt, got: %v", state1After.FilesTouched)

// StepCount should be unchanged — session was not condensed
if state1After.StepCount != originalStepCount {
t.Errorf("Session 1 StepCount changed from %d to %d — should be unchanged (not condensed)",
originalStepCount, state1After.StepCount)
}

t.Logf("Session 1 correctly preserved: StepCount=%d, FilesTouched=%v", state1After.StepCount, state1After.FilesTouched)
t.Logf("Session 1 correctly not condensed: StepCount=%d, FilesTouched=%v, FullyCondensed=%v",
state1After.StepCount, state1After.FilesTouched, state1After.FullyCondensed)

// ========================================
// Phase 6: Verify session 2 WAS condensed
// Phase 5: Verify session 2 WAS condensed
// ========================================
t.Log("Phase 6: Verifying session 2 WAS condensed")
t.Log("Phase 5: Verifying session 2 WAS condensed")

finalHead := env.GetHeadHash()
state2After, err := env.GetSessionState(session2.ID)
if err != nil {
t.Fatalf("GetSessionState for session2 after commit failed: %v", err)
Expand All @@ -185,35 +171,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 NOT condensed when unrelated file6.txt committed (carry-forward intact)")
t.Log(" - Session 2 condensed normally")
}
170 changes: 170 additions & 0 deletions cmd/entire/cli/integration_test/subagent_accumulation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//go:build integration

package integration

import (
"fmt"
"testing"

"github.com/entireio/cli/cmd/entire/cli/session"
)

// TestSubagentAccumulation_Issue591 reproduces the issue #591 shape: multiple
// subagent sessions accumulate as ENDED. The fix is eager-condense-on-stop:
// subagents with no uncommitted files at stop time are marked FullyCondensed
// immediately, so PostCommit skips them entirely on all subsequent commits.
//
// Scenario:
// 1. Parent session spawns 4 subagents; each creates + commits its own file
// 2. Each subagent ends (stop hook runs → CondenseAndMarkFullyCondensed marks them FullyCondensed)
// 3. Parent commits parent_work.go — PostCommit skips all FullyCondensed subagents
// 4. Follow-up commit — subagents remain FullyCondensed and skipped
func TestSubagentAccumulation_Issue591(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)

type subagentInfo struct {
SessionID string
File string
}

t.Log("Phase 1: start parent session and create subagent sessions with committed files")

parent := env.NewSession()
if err := env.SimulateUserPromptSubmit(parent.ID); err != nil {
t.Fatalf("SimulateUserPromptSubmit for parent failed: %v", err)
}
parent.TranscriptBuilder.AddUserMessage("Use subagents to create files, then create the parent file.")

const numSubagents = 4
subagents := make([]subagentInfo, 0, numSubagents)

for i := 0; i < numSubagents; i++ {
sub := env.NewSession()
file := fmt.Sprintf("subagent_work_%d.go", i)
content := fmt.Sprintf("package main\n\nfunc SubagentWork%d() {}\n", i)

if err := env.SimulateUserPromptSubmit(sub.ID); err != nil {
t.Fatalf("SimulateUserPromptSubmit for subagent %d failed: %v", i, err)
}

env.WriteFile(file, content)
sub.CreateTranscript("Create "+file, []FileChange{
{Path: file, Content: content},
})

// Commit the subagent's file BEFORE stopping, so FilesTouched is empty at stop time.
// This allows CondenseAndMarkFullyCondensed to eagerly condense at stop.
env.GitAdd(file)
env.GitCommitWithShadowHooks("Add "+file+" from subagent", file)

if err := env.SimulateStop(sub.ID, sub.TranscriptPath); err != nil {
t.Fatalf("SimulateStop for subagent %d failed: %v", i, err)
}
if err := env.SimulateSessionEnd(sub.ID); err != nil {
t.Fatalf("SimulateSessionEnd for subagent %d failed: %v", i, err)
}

// Verify eager-condense-on-stop marked the subagent FullyCondensed
state, err := env.GetSessionState(sub.ID)
if err != nil {
t.Fatalf("GetSessionState for subagent %d failed: %v", i, err)
}
if state == nil {
t.Fatalf("subagent %d session state missing after stop", i)
}
if state.Phase != session.PhaseEnded {
t.Fatalf("subagent %d phase = %s, want ended", i, state.Phase)
}
if !state.FullyCondensed {
t.Fatalf("subagent %d should be FullyCondensed after eager-condense-on-stop (FilesTouched was empty)", i)
}

taskToolUseID := fmt.Sprintf("toolu_parent_subagent_%d", i)
parent.TranscriptBuilder.AddTaskToolUse(taskToolUseID, "Create "+file)
parent.TranscriptBuilder.AddTaskToolResult(taskToolUseID, sub.ID)

subagents = append(subagents, subagentInfo{
SessionID: sub.ID,
File: file,
})
}

t.Log("Phase 2: parent commits its own work — PostCommit should skip all FullyCondensed subagents")

parentFile := "parent_work.go"
parentContent := "package main\n\nfunc ParentWork() {}\n"
env.WriteFile(parentFile, parentContent)
parentToolUseID := parent.TranscriptBuilder.AddToolUse("mcp__acp__Write", parentFile, parentContent)
parent.TranscriptBuilder.AddToolResult(parentToolUseID)
parent.TranscriptBuilder.AddAssistantMessage("Done.")
if err := parent.TranscriptBuilder.WriteToFile(parent.TranscriptPath); err != nil {
t.Fatalf("failed to write parent transcript: %v", err)
}

if err := env.SimulateStop(parent.ID, parent.TranscriptPath); err != nil {
t.Fatalf("SimulateStop for parent failed: %v", err)
}

env.GitCommitWithShadowHooks("Parent commit", parentFile)

t.Log("Phase 3: verify FullyCondensed subagents were skipped or cleaned up by PostCommit")

for i, sub := range subagents {
state, err := env.GetSessionState(sub.SessionID)
if err != nil {
t.Fatalf("GetSessionState after parent commit for subagent %d failed: %v", i, err)
}
// State may be nil: listAllSessionStates cleans up ENDED sessions whose
// shadow branch was deleted and LastCheckpointID is empty. This is expected
// for sessions that were eagerly condensed at stop time (shadow branch cleaned
// up before PostCommit could set LastCheckpointID).
if state == nil {
t.Logf("subagent %d state cleaned up after parent commit — OK (eagerly condensed)", i)
continue
}
if state.Phase != session.PhaseEnded {
t.Fatalf("subagent %d phase after parent commit = %s, want ended", i, state.Phase)
}
if !state.FullyCondensed {
t.Fatalf("subagent %d should remain FullyCondensed after parent commit", i)
}
}

t.Log("Phase 4: make another unrelated commit and verify fully-condensed subagents stay skipped")

followUp := env.NewSession()
if err := env.SimulateUserPromptSubmit(followUp.ID); err != nil {
t.Fatalf("SimulateUserPromptSubmit for follow-up session failed: %v", err)
}

followUpFile := "follow_up.go"
followUpContent := "package main\n\nfunc FollowUp() {}\n"
env.WriteFile(followUpFile, followUpContent)
followUp.CreateTranscript("Create "+followUpFile, []FileChange{
{Path: followUpFile, Content: followUpContent},
})
if err := env.SimulateStop(followUp.ID, followUp.TranscriptPath); err != nil {
t.Fatalf("SimulateStop for follow-up session failed: %v", err)
}

env.GitCommitWithShadowHooks("Follow-up commit", followUpFile)

for i, sub := range subagents {
state, err := env.GetSessionState(sub.SessionID)
if err != nil {
t.Fatalf("GetSessionState after follow-up commit for subagent %d failed: %v", i, err)
}
if state == nil {
// Already cleaned up in Phase 3 — still gone, which is correct
continue
}
if !state.FullyCondensed {
t.Fatalf("subagent %d should remain FullyCondensed after follow-up commit", i)
}
if state.StepCount != 0 {
t.Fatalf("subagent %d StepCount = %d after follow-up commit, want 0", i, state.StepCount)
}
}
}
Loading
Loading