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
2 changes: 2 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,8 @@ func (h *postCommitActionHandler) HandleWarnStaleSession(_ *session.State) error
func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:unparam // error return is part of the hook contract; callers check it
logCtx := logging.WithComponent(ctx, "checkpoint")

warnIfTooManySessions(ctx)

_, openRepoSpan := perf.Start(ctx, "open_repository_and_head")
repo, err := OpenRepository(ctx)
if err != nil {
Expand Down
65 changes: 65 additions & 0 deletions cmd/entire/cli/strategy/session_warning.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package strategy

import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"

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

const (
// orphanedSessionWarningThreshold is the number of active sessions above
// which a warning is displayed to the user.
orphanedSessionWarningThreshold = 10

// sessionWarningCooldown is the minimum interval between warnings.
sessionWarningCooldown = 1 * time.Hour

// sessionWarningFile is the name of the rate-limit timestamp file.
sessionWarningFile = "last_session_warning"
)

// warnIfTooManySessions checks the number of active session state files and
// prints a warning to stderr when the count exceeds orphanedSessionWarningThreshold.
// The warning is rate-limited to once per sessionWarningCooldown using a
// timestamp file in the session state directory.
func warnIfTooManySessions(ctx context.Context) {
store, err := session.NewStateStore(ctx)
if err != nil {
return
}

states, err := store.List(ctx)
if err != nil || len(states) <= orphanedSessionWarningThreshold {
return
}

stateDir, err := getSessionStateDir(ctx)
if err != nil {
return
}

warningPath := filepath.Join(stateDir, sessionWarningFile)
if info, err := os.Stat(warningPath); err == nil {
if time.Since(info.ModTime()) < sessionWarningCooldown {
return // warned recently
}
}

// Update the timestamp file (best-effort).
_ = os.WriteFile(warningPath, nil, 0o600)

count := len(states)
logCtx := logging.WithComponent(ctx, "session")
logging.Warn(logCtx, "high session count detected",
slog.Int("count", count),
slog.Int("threshold", orphanedSessionWarningThreshold),
)

fmt.Fprintf(os.Stderr, "[entire] Warning: %d active sessions detected. Run 'entire doctor' to check for stuck sessions or 'entire clean' to clean up.\n", count)
}
135 changes: 135 additions & 0 deletions cmd/entire/cli/strategy/session_warning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package strategy

import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/go-git/go-git/v6"
)

func TestWarnIfTooManySessions_AboveThreshold(t *testing.T) {

dir := t.TempDir()
if _, err := git.PlainInit(dir, false); err != nil {
t.Fatalf("git init: %v", err)
}
t.Chdir(dir)

ctx := context.Background()
store, err := session.NewStateStore(ctx)
if err != nil {
t.Fatalf("NewStateStore: %v", err)
}

// Create sessions above the threshold.
for i := 0; i <= orphanedSessionWarningThreshold; i++ {
s := &session.State{
SessionID: fmt.Sprintf("session-%03d", i),
BaseCommit: "abc123",
StartedAt: time.Now(),
Phase: session.PhaseIdle,
}
if err := store.Save(ctx, s); err != nil {
t.Fatalf("Save session %d: %v", i, err)
}
}

// Calling warnIfTooManySessions should create the rate-limit file.
warnIfTooManySessions(ctx)

stateDir := filepath.Join(dir, ".git", session.SessionStateDirName)
warningPath := filepath.Join(stateDir, sessionWarningFile)
if _, err := os.Stat(warningPath); err != nil {
t.Errorf("expected rate-limit file at %s: %v", warningPath, err)
}
}

func TestWarnIfTooManySessions_BelowThreshold(t *testing.T) {

dir := t.TempDir()
if _, err := git.PlainInit(dir, false); err != nil {
t.Fatalf("git init: %v", err)
}
t.Chdir(dir)

ctx := context.Background()
store, err := session.NewStateStore(ctx)
if err != nil {
t.Fatalf("NewStateStore: %v", err)
}

// Create sessions at exactly the threshold (not above).
for i := 0; i < orphanedSessionWarningThreshold; i++ {
s := &session.State{
SessionID: fmt.Sprintf("session-%03d", i),
BaseCommit: "abc123",
StartedAt: time.Now(),
Phase: session.PhaseIdle,
}
if err := store.Save(ctx, s); err != nil {
t.Fatalf("Save session %d: %v", i, err)
}
}

warnIfTooManySessions(ctx)

stateDir := filepath.Join(dir, ".git", session.SessionStateDirName)
warningPath := filepath.Join(stateDir, sessionWarningFile)
if _, err := os.Stat(warningPath); !os.IsNotExist(err) {
t.Errorf("rate-limit file should not exist below threshold, stat err: %v", err)
}
}

func TestWarnIfTooManySessions_RateLimited(t *testing.T) {

dir := t.TempDir()
if _, err := git.PlainInit(dir, false); err != nil {
t.Fatalf("git init: %v", err)
}
t.Chdir(dir)

ctx := context.Background()
store, err := session.NewStateStore(ctx)
if err != nil {
t.Fatalf("NewStateStore: %v", err)
}

for i := 0; i <= orphanedSessionWarningThreshold; i++ {
s := &session.State{
SessionID: fmt.Sprintf("session-%03d", i),
BaseCommit: "abc123",
StartedAt: time.Now(),
Phase: session.PhaseIdle,
}
if err := store.Save(ctx, s); err != nil {
t.Fatalf("Save session %d: %v", i, err)
}
}

// Pre-create the rate-limit file with a recent mtime.
stateDir := filepath.Join(dir, ".git", session.SessionStateDirName)
warningPath := filepath.Join(stateDir, sessionWarningFile)
if err := os.WriteFile(warningPath, nil, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}

info1, _ := os.Stat(warningPath)
mtime1 := info1.ModTime()

// Wait a tiny bit so mtime would differ if the file were rewritten.
time.Sleep(10 * time.Millisecond)

warnIfTooManySessions(ctx)

info2, _ := os.Stat(warningPath)
mtime2 := info2.ModTime()

if !mtime1.Equal(mtime2) {
t.Errorf("rate-limit file was rewritten (mtime changed from %v to %v); expected no update within cooldown", mtime1, mtime2)
}
}