diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index c90c3bd6e..440add114 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "os" "os/exec" @@ -730,6 +731,46 @@ func (h *postCommitActionHandler) shouldCondenseWithOverlapCheck(isActive bool, }) } +const ( + staleEndedSessionWarnThreshold = 3 // warn when ≥ this many stale sessions + staleEndedSessionWarnInterval = 24 * time.Hour // rate-limit window + staleEndedSessionWarnFile = ".warn-stale-ended" // sentinel file name in entire-sessions/ +) + +// stderrWriter is the destination for user-facing warnings emitted outside of +// Cobra command output (e.g. from PostCommit). Tests can swap this to capture +// output without mutating the process-global os.Stderr. +var stderrWriter io.Writer = os.Stderr + +// warnStaleEndedSessions emits a rate-limited warning to stderr when too many +// non-FullyCondensed ENDED sessions are accumulating. +func warnStaleEndedSessions(ctx context.Context, count int) { + warnStaleEndedSessionsTo(ctx, count, stderrWriter) +} + +func warnStaleEndedSessionsTo(ctx context.Context, count int, w io.Writer) { + commonDir, err := GetGitCommonDir(ctx) + if err != nil { + return // fail-open + } + warnDir := filepath.Join(commonDir, session.SessionStateDirName) + warnFile := filepath.Join(warnDir, staleEndedSessionWarnFile) + if info, statErr := os.Stat(warnFile); statErr == nil { + if time.Since(info.ModTime()) < staleEndedSessionWarnInterval { + return // rate-limited + } + } + //nolint:errcheck,gosec // G104: Best-effort warning — fail-open if file ops fail + os.MkdirAll(warnDir, 0o750) + //nolint:errcheck,gosec // G104: Best-effort sentinel file write + os.WriteFile(warnFile, []byte{}, 0o644) + fmt.Fprintf(w, + "\nentire: %d ended session(s) are accumulating and slowing down commits.\n"+ + "Run 'entire doctor' to condense them and restore commit performance.\n\n", + count, + ) +} + // activeSessionInteractionThreshold is the maximum age of LastInteractionTime // for an ACTIVE session to be considered genuinely active. 24h is generous // because LastInteractionTime only updates at TurnStart, not per-tool-call. @@ -895,6 +936,10 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint: } cleanupBranchesSpan.End() + if stale := countWarnableStaleEndedSessions(repo, sessions); stale >= staleEndedSessionWarnThreshold { + warnStaleEndedSessions(ctx, stale) + } + return nil } diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index 9658bc499..ad4d852fd 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -9,6 +9,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint" "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/versioninfo" "github.com/go-git/go-git/v6" @@ -101,6 +102,36 @@ func (s *ManualCommitStrategy) listAllSessionStates(ctx context.Context) ([]*Ses return states, nil } +// isWarnableStaleEndedSession reports whether an ENDED session is still both +// expensive in PostCommit and actionable via 'entire doctor'. +func isWarnableStaleEndedSession(repo *git.Repository, state *SessionState) bool { + if state.Phase != session.PhaseEnded || state.FullyCondensed || state.StepCount <= 0 { + return false + } + + // Re-check shadow branch existence even though listAllSessionStates already + // filters orphaned sessions. This is intentional: PostCommit deletes shadow + // branches during condensation, so a branch that existed at list-load time + // may be gone by the time we reach the warning check. Without this re-check + // we would warn about sessions that this commit just cleaned up. + shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) + refName := plumbing.NewBranchReferenceName(shadowBranch) + _, err := repo.Reference(refName, true) + return err == nil +} + +// countWarnableStaleEndedSessions returns the number of ENDED sessions that +// still remain slow and fixable after PostCommit finishes processing. +func countWarnableStaleEndedSessions(repo *git.Repository, sessions []*SessionState) int { + n := 0 + for _, state := range sessions { + if isWarnableStaleEndedSession(repo, state) { + n++ + } + } + return n +} + // findSessionsForWorktree finds all sessions for the given worktree path. func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, worktreePath string) ([]*SessionState, error) { allStates, err := s.listAllSessionStates(ctx) diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 4b4e9294e..e1587beb3 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -1501,10 +1501,7 @@ func TestShadowStrategy_CondenseSession_EphemeralBranchTrailer(t *testing.T) { t.Fatalf("failed to create metadata dir: %v", err) } - transcript := `{"type":"human","message":{"content":"test prompt"}} -{"type":"assistant","message":{"content":"test response"}} -` - if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(testTranscript), 0o644); err != nil { t.Fatalf("failed to write transcript: %v", err) } diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 9f1d0135c..ef96fcb31 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -1,6 +1,7 @@ package strategy import ( + "bytes" "context" "os" "path/filepath" @@ -1386,12 +1387,9 @@ func setupSessionWithCheckpoint(t *testing.T, s *ManualCommitStrategy, _ *git.Re metadataDirAbs := filepath.Join(dir, metadataDir) require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) - transcript := `{"type":"human","message":{"content":"test prompt"}} -{"type":"assistant","message":{"content":"test response"}} -` require.NoError(t, os.WriteFile( filepath.Join(metadataDirAbs, paths.TranscriptFileName), - []byte(transcript), 0o644)) + []byte(testTranscript), 0o644)) // SaveStep creates the shadow branch and checkpoint // Include test.txt as a modified file so it's saved to the shadow branch @@ -1409,6 +1407,38 @@ func setupSessionWithCheckpoint(t *testing.T, s *ManualCommitStrategy, _ *git.Re require.NoError(t, err, "SaveStep should succeed to create shadow branch content") } +// setupSessionWithCheckpointAndFile initializes a session with a checkpoint for +// a caller-provided new file. This lets tests create multiple independent +// sessions that all overlap with the same commit. +func setupSessionWithCheckpointAndFile(t *testing.T, s *ManualCommitStrategy, dir, sessionID, fileName string) { + t.Helper() + + filePath := filepath.Join(dir, fileName) + fileContent := "agent content for " + fileName + require.NoError(t, os.WriteFile(filePath, []byte(fileContent), 0o644)) + + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(metadataDirAbs, paths.TranscriptFileName), + []byte(testTranscript), 0o644)) + + err := s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{}, + NewFiles: []string{fileName}, + DeletedFiles: []string{}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + require.NoError(t, err, "SaveStep should succeed to create shadow branch content") +} + // shadowTranscriptSize returns the byte size of the transcript blob on the shadow branch. // Used in tests to set CheckpointTranscriptSize without hardcoding sizes. func shadowTranscriptSize(t *testing.T, repo *git.Repository, state *SessionState) int64 { @@ -2390,3 +2420,143 @@ func TestPostCommit_ActiveSession_DifferentFilesThanCommit_ShouldCondense(t *tes require.NoError(t, err, "entire/checkpoints/v1 should exist — ACTIVE session with different files must still condense") } + +// TestCountWarnableStaleEndedSessions verifies that the warning only counts the +// same ENDED sessions that 'entire doctor' can actually condense. +// Uses t.Chdir — do NOT add t.Parallel(). +func TestCountWarnableStaleEndedSessions(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + setupSessionWithCheckpoint(t, s, repo, dir, "warnable-session") + + warnableState, err := s.loadSessionState(context.Background(), "warnable-session") + require.NoError(t, err) + warnableState.Phase = session.PhaseEnded + warnableState.FullyCondensed = false + require.NoError(t, s.saveSessionState(context.Background(), warnableState)) + + sessions := []*SessionState{ + warnableState, + { + SessionID: "no-shadow-branch", + BaseCommit: "1234567890abcdef1234567890abcdef12345678", + WorktreeID: warnableState.WorktreeID, + Phase: session.PhaseEnded, + FullyCondensed: false, + StepCount: 3, + }, + { + SessionID: "zero-steps", + BaseCommit: warnableState.BaseCommit, + WorktreeID: warnableState.WorktreeID, + Phase: session.PhaseEnded, + FullyCondensed: false, + StepCount: 0, + }, + { + SessionID: "fully-condensed", + BaseCommit: warnableState.BaseCommit, + WorktreeID: warnableState.WorktreeID, + Phase: session.PhaseEnded, + FullyCondensed: true, + StepCount: 3, + }, + { + SessionID: "idle-session", + BaseCommit: warnableState.BaseCommit, + WorktreeID: warnableState.WorktreeID, + Phase: session.PhaseIdle, + FullyCondensed: false, + StepCount: 3, + }, + } + + assert.Equal(t, 1, countWarnableStaleEndedSessions(repo, sessions)) +} + +// TestPostCommit_WarnStaleEndedSessions_AfterProcessing verifies that the +// warning is emitted only for sessions that remain stale AFTER the current +// commit is processed. +// Uses t.Chdir — do NOT add t.Parallel(). +func TestPostCommit_WarnStaleEndedSessions_AfterProcessing(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + type sessionFile struct { + sessionID string + fileName string + } + sessionFiles := []sessionFile{ + {"ended-a", "stale-a.txt"}, + {"ended-b", "stale-b.txt"}, + {"ended-c", "stale-c.txt"}, + } + + filesToCommit := make([]string, 0, len(sessionFiles)) + for _, sf := range sessionFiles { + setupSessionWithCheckpointAndFile(t, s, dir, sf.sessionID, sf.fileName) + + state, loadErr := s.loadSessionState(context.Background(), sf.sessionID) + require.NoError(t, loadErr) + now := time.Now() + state.Phase = session.PhaseEnded + state.EndedAt = &now + state.FilesTouched = []string{sf.fileName} + require.NoError(t, s.saveSessionState(context.Background(), state)) + + filesToCommit = append(filesToCommit, sf.fileName) + } + + commitFilesWithTrailer(t, repo, dir, "abc123def456", filesToCommit...) + + // Capture warning output via the injectable stderrWriter instead of + // mutating the process-global os.Stderr. + var buf bytes.Buffer + oldWriter := stderrWriter + stderrWriter = &buf + defer func() { stderrWriter = oldWriter }() + + err = s.PostCommit(context.Background()) + require.NoError(t, err) + + assert.NotContains(t, buf.String(), "entire doctor", + "warning should be suppressed when this commit already condensed the stale ended sessions") +} + +// TestWarnStaleEndedSessions_RateLimit verifies the 24h sentinel file gate. +// Uses t.Chdir — do NOT add t.Parallel(). +func TestWarnStaleEndedSessions_RateLimit(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + ctx := context.Background() + + // First call: no sentinel file → should write to stderr + var buf bytes.Buffer + warnStaleEndedSessionsTo(ctx, 5, &buf) + assert.Contains(t, buf.String(), "entire doctor") + + // Sentinel file now exists with current mtime → second call suppressed + buf.Reset() + warnStaleEndedSessionsTo(ctx, 5, &buf) + assert.Empty(t, buf.String(), "second call within window must be suppressed") + + // Backdate sentinel file by 25h → call should warn again + commonDir, err := GetGitCommonDir(ctx) + require.NoError(t, err) + warnFile := filepath.Join(commonDir, session.SessionStateDirName, staleEndedSessionWarnFile) + past := time.Now().Add(-25 * time.Hour) + require.NoError(t, os.Chtimes(warnFile, past, past)) + + buf.Reset() + warnStaleEndedSessionsTo(ctx, 5, &buf) + assert.Contains(t, buf.String(), "entire doctor") +}