Skip to content
Open
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
45 changes: 45 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
31 changes: 31 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 1 addition & 4 deletions cmd/entire/cli/strategy/manual_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
178 changes: 174 additions & 4 deletions cmd/entire/cli/strategy/phase_postcommit_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package strategy

import (
"bytes"
"context"
"os"
"path/filepath"
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
Loading