From f14859a6e938c2a1cf8a6031e25cd2e1c38ddd3a Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 19 Mar 2026 15:52:28 -0700 Subject: [PATCH 1/4] feat: add entire stop command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `entire stop` to manually mark one or more active sessions as ended, preventing future checkpoint leakage without waiting for the agent to stop naturally or for a SessionStop lifecycle hook. Pure state mutation — no writes to the checkpoints branch, no condensation. The existing PostCommit hook handles any remaining condensation on the next commit. Flags: --session , --all (worktree-scoped), --force/-f TUI multi-select for 2+ sessions; confirmation prompt for single session. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 1 + cmd/entire/cli/root.go | 1 + cmd/entire/cli/stop.go | 279 +++++++++++++++++++++++++ cmd/entire/cli/stop_test.go | 397 ++++++++++++++++++++++++++++++++++++ 5 files changed, 679 insertions(+) create mode 100644 cmd/entire/cli/stop.go create mode 100644 cmd/entire/cli/stop_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db5fd1629..17d014d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `entire stop` command to manually mark one or more active sessions as ended, preventing future checkpoint leakage - Sparse metadata fetch with on-demand blob resolution for reduced memory and network cost ([#680](https://github.com/entireio/cli/pull/680), [#721](https://github.com/entireio/cli/pull/721)) - `entire trace` command for diagnosing slow performance hooks and lifecycle events ([#652](https://github.com/entireio/cli/pull/652)) - Opt-in PII redaction with typed tokens ([#397](https://github.com/entireio/cli/pull/397)) diff --git a/README.md b/README.md index a31a170b6..c9760519d 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ go test -tags=integration ./cmd/entire/cli/integration_test -run TestLogin | `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | | `entire rewind` | Rewind to a previous checkpoint | | `entire status` | Show current session info | +| `entire stop` | Mark one or more active sessions as ended | | `entire version` | Show Entire CLI version | ### `entire enable` Flags diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 445fbf820..404aff0f9 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -83,6 +83,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newResumeCmd()) cmd.AddCommand(newCleanCmd()) cmd.AddCommand(newResetCmd()) + cmd.AddCommand(newStopCmd()) cmd.AddCommand(newSetupCmd()) cmd.AddCommand(newEnableCmd()) cmd.AddCommand(newDisableCmd()) diff --git a/cmd/entire/cli/stop.go b/cmd/entire/cli/stop.go new file mode 100644 index 000000000..b640391dc --- /dev/null +++ b/cmd/entire/cli/stop.go @@ -0,0 +1,279 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/spf13/cobra" +) + +func newStopCmd() *cobra.Command { + var sessionFlag string + var allFlag bool + var forceFlag bool + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop one or more active sessions", + Long: `Mark one or more active sessions as ended. + +This is a pure state mutation — no checkpoints are written, no condensation happens. + +Examples: + entire stop Stop the one active session, or show selector if multiple + entire stop --session Stop a specific session by ID + entire stop --all Stop all active sessions in current worktree + entire stop --force Skip confirmation prompt`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + if allFlag && sessionFlag != "" { + return errors.New("--all and --session are mutually exclusive") + } + + // Check if in git repository + if _, err := paths.WorktreeRoot(ctx); err != nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository.") + return NewSilentError(errors.New("not a git repository")) + } + + return runStop(ctx, cmd, sessionFlag, allFlag, forceFlag) + }, + } + + cmd.Flags().StringVar(&sessionFlag, "session", "", "Stop a specific session by ID") + cmd.Flags().BoolVar(&allFlag, "all", false, "Stop all active sessions in current worktree") + cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Skip confirmation prompt") + + return cmd +} + +// runStop is the main logic for the stop command. +func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, force bool) error { + // --session path: stop a specific session + if sessionID != "" { + return runStopSession(ctx, cmd, sessionID, force) + } + + // List all session states + states, err := strategy.ListSessionStates(ctx) + if err != nil { + return fmt.Errorf("failed to list sessions: %w", err) + } + + // Filter to active sessions (Phase != PhaseEnded) + activeSessions := filterActiveSessions(states) + + if len(activeSessions) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No active sessions.") + return nil + } + + // --all path: stop all active sessions in current worktree + if all { + return runStopAll(ctx, cmd, activeSessions, force) + } + + // No flags: one active session → confirm + stop; multiple → TUI selector + if len(activeSessions) == 1 { + return runStopSession(ctx, cmd, activeSessions[0].SessionID, force) + } + + // Multiple active sessions: show TUI multi-select + return runStopMultiSelect(ctx, cmd, activeSessions, force) +} + +// filterActiveSessions returns sessions where Phase != PhaseEnded. +func filterActiveSessions(states []*strategy.SessionState) []*strategy.SessionState { + var active []*strategy.SessionState + for _, s := range states { + if s.Phase != session.PhaseEnded { + active = append(active, s) + } + } + return active +} + +// runStopSession stops a single session by ID, with optional confirmation. +func runStopSession(ctx context.Context, cmd *cobra.Command, sessionID string, force bool) error { + state, err := strategy.LoadSessionState(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to load session: %w", err) + } + if state == nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Session not found.") + return NewSilentError(fmt.Errorf("session not found: %s", sessionID)) + } + + if state.Phase == session.PhaseEnded { + fmt.Fprintf(cmd.OutOrStdout(), "Session %s is already stopped.\n", sessionID) + return nil + } + + if !force { + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Stop session %s?", sessionID)). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + } + + return stopSessionAndPrint(ctx, cmd, state) +} + +// runStopAll stops all active sessions scoped to the current worktree. +func runStopAll(ctx context.Context, cmd *cobra.Command, activeSessions []*strategy.SessionState, force bool) error { + // Scope to current worktree: include sessions where WorktreePath matches + // or WorktreePath is empty (safer than silently excluding). + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("failed to resolve worktree root: %w", err) + } + + var toStop []*strategy.SessionState + for _, s := range activeSessions { + if s.WorktreePath == worktreePath || s.WorktreePath == "" { + toStop = append(toStop, s) + } + } + + if len(toStop) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No active sessions.") + return nil + } + + if !force { + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Stop %d session(s)?", len(toStop))). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + } + + var stopErr error + for _, s := range toStop { + if err := stopSessionAndPrint(ctx, cmd, s); err != nil { + stopErr = err + } + } + return stopErr +} + +// runStopMultiSelect shows a TUI multi-select for multiple active sessions. +func runStopMultiSelect(ctx context.Context, cmd *cobra.Command, activeSessions []*strategy.SessionState, force bool) error { + options := make([]huh.Option[string], len(activeSessions)) + for i, s := range activeSessions { + label := fmt.Sprintf("%s · %s", s.AgentType, s.SessionID) + if s.LastPrompt != "" { + label = fmt.Sprintf("%s · %q", label, s.LastPrompt) + } + options[i] = huh.NewOption(label, s.SessionID) + } + + var selectedIDs []string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select sessions to stop"). + Description("Use space to select, enter to confirm."). + Options(options...). + Value(&selectedIDs), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + + if len(selectedIDs) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + + // Build a map for quick lookup + stateByID := make(map[string]*strategy.SessionState, len(activeSessions)) + for _, s := range activeSessions { + stateByID[s.SessionID] = s + } + + // Confirm only if not forcing + if !force { + var confirmed bool + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Stop %d session(s)?", len(selectedIDs))). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + return handleFormCancellation(cmd.OutOrStdout(), "Stop", err) + } + if !confirmed { + fmt.Fprintln(cmd.OutOrStdout(), "Stop cancelled.") + return nil + } + } + + var stopErr error + for _, id := range selectedIDs { + s, ok := stateByID[id] + if !ok { + continue + } + if err := stopSessionAndPrint(ctx, cmd, s); err != nil { + stopErr = err + } + } + return stopErr +} + +// stopSessionAndPrint stops a session and prints the result. +// It snapshots the needed fields before calling markSessionEnded. +func stopSessionAndPrint(ctx context.Context, cmd *cobra.Command, state *strategy.SessionState) error { + // Snapshot fields needed for output before calling markSessionEnded + sessionID := state.SessionID + lastCheckpointID := state.LastCheckpointID + stepCount := state.StepCount + + if err := markSessionEnded(ctx, nil, sessionID); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ Session %s stopped.\n", sessionID) + switch { + case lastCheckpointID != "": + fmt.Fprintf(cmd.OutOrStdout(), " Checkpoint: %s\n", lastCheckpointID) + case stepCount > 0: + fmt.Fprintln(cmd.OutOrStdout(), " Work will be captured in your next checkpoint.") + default: + fmt.Fprintln(cmd.OutOrStdout(), " No work recorded.") + } + return nil +} diff --git a/cmd/entire/cli/stop_test.go b/cmd/entire/cli/stop_test.go new file mode 100644 index 000000000..4a74ef47b --- /dev/null +++ b/cmd/entire/cli/stop_test.go @@ -0,0 +1,397 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// setupStopTestRepo initializes a temporary git repo, changes to it, and clears +// path/session caches. Must NOT be used with t.Parallel() because it calls t.Chdir. +func setupStopTestRepo(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "f.txt", "init") + testutil.GitAdd(t, tmpDir, "f.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() +} + +// makeSessionState returns a minimal SessionState suitable for test setup. +func makeSessionState(id string, phase session.Phase) *strategy.SessionState { + return &strategy.SessionState{ + SessionID: id, + BaseCommit: "abc123", + StartedAt: time.Now(), + Phase: phase, + } +} + +func TestStopCmd_NoActiveSessions(t *testing.T) { + setupStopTestRepo(t) + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !strings.Contains(stdout.String(), "No active sessions.") { + t.Errorf("expected 'No active sessions.' in output, got: %q", stdout.String()) + } +} + +func TestStopCmd_SingleSession_Force(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-single-1", session.PhaseIdle) + state.StepCount = 0 + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "No work recorded.") { + t.Errorf("expected 'No work recorded.' in output, got: %q", out) + } + + loaded, err := strategy.LoadSessionState(context.Background(), "test-stop-single-1") + if err != nil { + t.Fatalf("LoadSessionState() error = %v", err) + } + if loaded == nil { + t.Fatal("expected session state to still exist after stop") + } + if loaded.Phase != session.PhaseEnded { + t.Errorf("expected Phase=PhaseEnded, got: %v", loaded.Phase) + } +} + +func TestStopCmd_SingleSession_WithCheckpoint(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-checkpoint-1", session.PhaseIdle) + state.LastCheckpointID = "a3b2c4d5e6f7" + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-checkpoint-1", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Checkpoint: a3b2c4d5e6f7") { + t.Errorf("expected 'Checkpoint: a3b2c4d5e6f7' in output, got: %q", out) + } +} + +func TestStopCmd_SingleSession_UncommittedWork(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-uncommitted-1", session.PhaseIdle) + state.StepCount = 2 + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-uncommitted-1", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Work will be captured in your next checkpoint.") { + t.Errorf("expected 'Work will be captured in your next checkpoint.' in output, got: %q", out) + } +} + +func TestStopCmd_AlreadyStopped(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-already-ended-1", session.PhaseEnded) + now := time.Now() + state.EndedAt = &now + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-already-ended-1", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Session test-stop-already-ended-1 is already stopped.") { + t.Errorf("expected 'is already stopped.' in output, got: %q", out) + } + + // State should be unchanged (still ended) + loaded, err := strategy.LoadSessionState(context.Background(), "test-stop-already-ended-1") + if err != nil { + t.Fatalf("LoadSessionState() error = %v", err) + } + if loaded == nil { + t.Fatal("expected session state to still exist") + } + if loaded.Phase != session.PhaseEnded { + t.Errorf("expected Phase=PhaseEnded unchanged, got: %v", loaded.Phase) + } +} + +func TestStopCmd_SessionFlag(t *testing.T) { + setupStopTestRepo(t) + + state1 := makeSessionState("test-stop-target-session", session.PhaseIdle) + state2 := makeSessionState("test-stop-other-session", session.PhaseIdle) + for _, s := range []*strategy.SessionState{state1, state2} { + if err := strategy.SaveSessionState(context.Background(), s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-target-session", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + target, err := strategy.LoadSessionState(context.Background(), "test-stop-target-session") + if err != nil { + t.Fatalf("LoadSessionState(target) error = %v", err) + } + if target == nil { + t.Fatal("expected target session state to exist") + } + if target.Phase != session.PhaseEnded { + t.Errorf("expected target Phase=PhaseEnded, got: %v", target.Phase) + } + + other, err := strategy.LoadSessionState(context.Background(), "test-stop-other-session") + if err != nil { + t.Fatalf("LoadSessionState(other) error = %v", err) + } + if other == nil { + t.Fatal("expected other session state to exist") + } + if other.Phase == session.PhaseEnded { + t.Errorf("expected other session to remain non-ended, got: %v", other.Phase) + } +} + +func TestStopCmd_AllFlag(t *testing.T) { + setupStopTestRepo(t) + + // Resolve the worktree root as the command sees it (handles macOS symlinks like /var -> /private/var). + ctx := context.Background() + worktreePath, wtErr := paths.WorktreeRoot(ctx) + if wtErr != nil { + t.Fatalf("WorktreeRoot() error = %v", wtErr) + } + + state1 := makeSessionState("test-stop-all-sess-1", session.PhaseIdle) + state1.WorktreePath = worktreePath + state2 := makeSessionState("test-stop-all-sess-2", session.PhaseIdle) + state2.WorktreePath = worktreePath + for _, s := range []*strategy.SessionState{state1, state2} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--force"}) + + if err := cmd.ExecuteContext(ctx); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + for _, id := range []string{"test-stop-all-sess-1", "test-stop-all-sess-2"} { + if !strings.Contains(out, id) { + t.Errorf("expected session ID %q in output, got: %q", id, out) + } + + loaded, err := strategy.LoadSessionState(context.Background(), id) + if err != nil { + t.Fatalf("LoadSessionState(%s) error = %v", id, err) + } + if loaded == nil { + t.Fatalf("expected session %s to exist after stop", id) + } + if loaded.Phase != session.PhaseEnded { + t.Errorf("expected session %s Phase=PhaseEnded, got: %v", id, loaded.Phase) + } + } +} + +func TestStopCmd_AllAndSessionMutuallyExclusive(t *testing.T) { + setupStopTestRepo(t) + + state := makeSessionState("test-stop-mutex-sess", session.PhaseIdle) + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--session", "test-stop-mutex-sess", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error for --all and --session together, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected error to mention 'mutually exclusive', got: %v", err) + } + + // State should be unchanged + loaded, err2 := strategy.LoadSessionState(context.Background(), "test-stop-mutex-sess") + if err2 != nil { + t.Fatalf("LoadSessionState() error = %v", err2) + } + if loaded == nil { + t.Fatal("expected session state to still exist") + } + if loaded.Phase == session.PhaseEnded { + t.Error("expected session to remain non-ended after mutual exclusion error") + } +} + +func TestStopCmd_SessionNotFound(t *testing.T) { + setupStopTestRepo(t) + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "doesnotexist", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error for unknown session, got nil") + } + + var silentErr *SilentError + if !errors.As(err, &silentErr) { + t.Errorf("expected SilentError, got: %T %v", err, err) + } + + if !strings.Contains(stderr.String(), "Session not found.") { + t.Errorf("expected 'Session not found.' in stderr, got: %q", stderr.String()) + } +} + +func TestStopCmd_NotGitRepo(t *testing.T) { + // Use a plain temp dir with no git init + tmpDir := t.TempDir() + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{}) + + err := cmd.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected error for non-git directory, got nil") + } + + var silentErr *SilentError + if !errors.As(err, &silentErr) { + t.Errorf("expected SilentError, got: %T %v", err, err) + } + + if !strings.Contains(stderr.String(), "Not a git repository.") { + t.Errorf("expected 'Not a git repository.' in stderr, got: %q", stderr.String()) + } +} + +func TestStopCmd_MultiSession_NoFlags(t *testing.T) { + setupStopTestRepo(t) + + // Create two active sessions. The TUI multi-select would normally hang, + // so we do NOT execute the command. We just verify the session setup is + // consistent: both sessions are non-ended. + state1 := makeSessionState("test-stop-multi-sess-1", session.PhaseIdle) + state2 := makeSessionState("test-stop-multi-sess-2", session.PhaseIdle) + for _, s := range []*strategy.SessionState{state1, state2} { + if err := strategy.SaveSessionState(context.Background(), s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + // Verify both sessions exist and are non-ended, so the multi-select path + // would be triggered by the command (not the no-sessions path). + for _, id := range []string{"test-stop-multi-sess-1", "test-stop-multi-sess-2"} { + loaded, err := strategy.LoadSessionState(context.Background(), id) + if err != nil { + t.Fatalf("LoadSessionState(%s) error = %v", id, err) + } + if loaded == nil { + t.Fatalf("expected session %s to exist", id) + } + if loaded.Phase == session.PhaseEnded { + t.Errorf("expected session %s to be non-ended, got PhaseEnded", id) + } + } +} From ed9223884fe5c607203ea49530345f0f44d955c1 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 19 Mar 2026 16:22:30 -0700 Subject: [PATCH 2/4] pr feedback Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/stop.go | 93 ++++++++++++++++---------- cmd/entire/cli/stop_test.go | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 36 deletions(-) diff --git a/cmd/entire/cli/stop.go b/cmd/entire/cli/stop.go index b640391dc..0ad4f7761 100644 --- a/cmd/entire/cli/stop.go +++ b/cmd/entire/cli/stop.go @@ -22,10 +22,11 @@ func newStopCmd() *cobra.Command { Short: "Stop one or more active sessions", Long: `Mark one or more active sessions as ended. -This is a pure state mutation — no checkpoints are written, no condensation happens. +Fires EventSessionStop through the state machine with a no-op action handler, +so no condensation or checkpoint-writing occurs. To flush pending work, commit first. Examples: - entire stop Stop the one active session, or show selector if multiple + entire stop No sessions: exits. One session: confirm and stop. Multiple: show selector entire stop --session Stop a specific session by ID entire stop --all Stop all active sessions in current worktree entire stop --force Skip confirmation prompt`, @@ -56,7 +57,7 @@ Examples: // runStop is the main logic for the stop command. func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, force bool) error { - // --session path: stop a specific session + // --session path: stop a specific session by explicit ID (no worktree scoping). if sessionID != "" { return runStopSession(ctx, cmd, sessionID, force) } @@ -67,33 +68,48 @@ func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, for return fmt.Errorf("failed to list sessions: %w", err) } - // Filter to active sessions (Phase != PhaseEnded) activeSessions := filterActiveSessions(states) - if len(activeSessions) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No active sessions.") - return nil - } - - // --all path: stop all active sessions in current worktree + // --all path: stop all active sessions in current worktree (scoped inside runStopAll). if all { return runStopAll(ctx, cmd, activeSessions, force) } - // No flags: one active session → confirm + stop; multiple → TUI selector - if len(activeSessions) == 1 { - return runStopSession(ctx, cmd, activeSessions[0].SessionID, force) + // No-flags path: scope to current worktree before presenting options. + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("failed to resolve worktree root: %w", err) + } + var scoped []*strategy.SessionState + for _, s := range activeSessions { + if s.WorktreePath == worktreePath || s.WorktreePath == "" { + scoped = append(scoped, s) + } } - // Multiple active sessions: show TUI multi-select - return runStopMultiSelect(ctx, cmd, activeSessions, force) + if len(scoped) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No active sessions.") + return nil + } + + // One active session: confirm + stop. + if len(scoped) == 1 { + return runStopSession(ctx, cmd, scoped[0].SessionID, force) + } + + // Multiple active sessions: show TUI multi-select. + return runStopMultiSelect(ctx, cmd, scoped, force) } -// filterActiveSessions returns sessions where Phase != PhaseEnded. +// filterActiveSessions returns sessions in PhaseIdle or PhaseActive — all sessions +// that have not been explicitly ended. Both phases are considered stoppable: IDLE +// means the agent finished its last turn but the session is still open. +// Matches the EndedAt == nil check used by status.go to avoid false positives on +// legacy sessions where Phase may have defaulted to Idle despite EndedAt being set. func filterActiveSessions(states []*strategy.SessionState) []*strategy.SessionState { var active []*strategy.SessionState for _, s := range states { - if s.Phase != session.PhaseEnded { + if s.Phase != session.PhaseEnded && s.EndedAt == nil { active = append(active, s) } } @@ -112,7 +128,7 @@ func runStopSession(ctx context.Context, cmd *cobra.Command, sessionID string, f return NewSilentError(fmt.Errorf("session not found: %s", sessionID)) } - if state.Phase == session.PhaseEnded { + if state.Phase == session.PhaseEnded || state.EndedAt != nil { fmt.Fprintf(cmd.OutOrStdout(), "Session %s is already stopped.\n", sessionID) return nil } @@ -140,8 +156,9 @@ func runStopSession(ctx context.Context, cmd *cobra.Command, sessionID string, f // runStopAll stops all active sessions scoped to the current worktree. func runStopAll(ctx context.Context, cmd *cobra.Command, activeSessions []*strategy.SessionState, force bool) error { - // Scope to current worktree: include sessions where WorktreePath matches - // or WorktreePath is empty (safer than silently excluding). + // Scope to current worktree. Sessions with an empty WorktreePath predate + // worktree-path tracking and cannot be attributed to any specific worktree — + // including them here prevents them from being permanently unreachable via --all. worktreePath, err := paths.WorktreeRoot(ctx) if err != nil { return fmt.Errorf("failed to resolve worktree root: %w", err) @@ -177,13 +194,7 @@ func runStopAll(ctx context.Context, cmd *cobra.Command, activeSessions []*strat } } - var stopErr error - for _, s := range toStop { - if err := stopSessionAndPrint(ctx, cmd, s); err != nil { - stopErr = err - } - } - return stopErr + return stopSelectedSessions(ctx, cmd, toStop) } // runStopMultiSelect shows a TUI multi-select for multiple active sessions. @@ -241,23 +252,33 @@ func runStopMultiSelect(ctx context.Context, cmd *cobra.Command, activeSessions } } - var stopErr error + var toStop []*strategy.SessionState for _, id := range selectedIDs { - s, ok := stateByID[id] - if !ok { - continue + if s, ok := stateByID[id]; ok { + toStop = append(toStop, s) } + } + return stopSelectedSessions(ctx, cmd, toStop) +} + +// stopSelectedSessions stops each session in the list and prints a result line. +// Errors from individual sessions are all accumulated and returned as a joined error, +// so a single failure does not prevent remaining sessions from being stopped. +func stopSelectedSessions(ctx context.Context, cmd *cobra.Command, sessions []*strategy.SessionState) error { + var errs []error + for _, s := range sessions { if err := stopSessionAndPrint(ctx, cmd, s); err != nil { - stopErr = err + errs = append(errs, err) } } - return stopErr + return errors.Join(errs...) } -// stopSessionAndPrint stops a session and prints the result. -// It snapshots the needed fields before calling markSessionEnded. +// stopSessionAndPrint stops a session and prints a summary line. +// Fields needed for output are read before calling markSessionEnded because +// markSessionEnded loads and operates on its own copy of the session state by ID — +// it does not update the caller's state pointer. func stopSessionAndPrint(ctx context.Context, cmd *cobra.Command, state *strategy.SessionState) error { - // Snapshot fields needed for output before calling markSessionEnded sessionID := state.SessionID lastCheckpointID := state.LastCheckpointID stepCount := state.StepCount diff --git a/cmd/entire/cli/stop_test.go b/cmd/entire/cli/stop_test.go index 4a74ef47b..3bbaa1b23 100644 --- a/cmd/entire/cli/stop_test.go +++ b/cmd/entire/cli/stop_test.go @@ -395,3 +395,132 @@ func TestStopCmd_MultiSession_NoFlags(t *testing.T) { } } } + +// TestStopCmd_AlreadyStopped_EndedAtOnly verifies that a session with EndedAt set +// is treated as already stopped even when Phase has not been updated to PhaseEnded +// (legacy sessions where the phase field may have defaulted to Idle). +func TestStopCmd_AlreadyStopped_EndedAtOnly(t *testing.T) { + setupStopTestRepo(t) + + // Simulate a legacy session: EndedAt is set but Phase is still PhaseIdle. + state := makeSessionState("test-stop-ended-at-only", session.PhaseIdle) + now := time.Now() + state.EndedAt = &now + if err := strategy.SaveSessionState(context.Background(), state); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--session", "test-stop-ended-at-only", "--force"}) + + err := cmd.ExecuteContext(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "is already stopped.") { + t.Errorf("expected 'is already stopped.' in output, got: %q", out) + } + + // Phase should remain unchanged — we must not overwrite legacy state. + loaded, err := strategy.LoadSessionState(context.Background(), "test-stop-ended-at-only") + if err != nil { + t.Fatalf("LoadSessionState() error = %v", err) + } + if loaded == nil { + t.Fatal("expected session state to still exist") + } + if loaded.Phase != session.PhaseIdle { + t.Errorf("expected Phase to remain PhaseIdle (legacy), got: %v", loaded.Phase) + } +} + +// TestFilterActiveSessions_ExcludesEndedAtSet verifies that filterActiveSessions +// excludes sessions with EndedAt set regardless of Phase, matching the status.go +// invariant that EndedAt is the authoritative "ended" signal. +func TestFilterActiveSessions_ExcludesEndedAtSet(t *testing.T) { + t.Parallel() + + now := time.Now() + + legacyEnded := makeSessionState("legacy-ended", session.PhaseIdle) + legacyEnded.EndedAt = &now + + properEnded := makeSessionState("proper-ended", session.PhaseEnded) + properEnded.EndedAt = &now + + active := makeSessionState("active", session.PhaseIdle) + + result := filterActiveSessions([]*strategy.SessionState{legacyEnded, properEnded, active}) + + if len(result) != 1 { + t.Fatalf("expected 1 active session, got %d", len(result)) + } + if result[0].SessionID != "active" { + t.Errorf("expected active session ID %q, got %q", "active", result[0].SessionID) + } +} + +// TestStopCmd_WorktreeScoping_NoFlags verifies that the no-flags path scopes session +// listing to the current worktree, so sessions from other worktrees are invisible. +func TestStopCmd_WorktreeScoping_NoFlags(t *testing.T) { + setupStopTestRepo(t) + + ctx := context.Background() + worktreePath, err := paths.WorktreeRoot(ctx) + if err != nil { + t.Fatalf("WorktreeRoot() error = %v", err) + } + + // One session in the current worktree, one in a foreign worktree. + inScope := makeSessionState("test-stop-scope-in", session.PhaseIdle) + inScope.WorktreePath = worktreePath + + outOfScope := makeSessionState("test-stop-scope-out", session.PhaseIdle) + outOfScope.WorktreePath = "/some/other/worktree" + + for _, s := range []*strategy.SessionState{inScope, outOfScope} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + // Single in-scope session → confirm + stop path (bypasses TUI selector). + cmd.SetArgs([]string{"--force"}) + + if err := cmd.ExecuteContext(ctx); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // In-scope session must be stopped. + stopped, err := strategy.LoadSessionState(ctx, "test-stop-scope-in") + if err != nil { + t.Fatalf("LoadSessionState(in-scope) error = %v", err) + } + if stopped == nil { + t.Fatal("expected in-scope session to exist") + } + if stopped.Phase != session.PhaseEnded { + t.Errorf("expected in-scope session Phase=PhaseEnded, got: %v", stopped.Phase) + } + + // Out-of-scope session must be untouched. + untouched, err := strategy.LoadSessionState(ctx, "test-stop-scope-out") + if err != nil { + t.Fatalf("LoadSessionState(out-of-scope) error = %v", err) + } + if untouched == nil { + t.Fatal("expected out-of-scope session to exist") + } + if untouched.Phase == session.PhaseEnded { + t.Errorf("expected out-of-scope session to remain non-ended, got PhaseEnded") + } +} From 5d229063a72c27ba29cf82592de1ef2ae856c6ac Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 19 Mar 2026 17:19:26 -0700 Subject: [PATCH 3/4] pr feedback: fix error visibility, silent drops, and test gaps in entire stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stopSelectedSessions: print ✗ per failed session to stderr so batch failures are visible immediately, not just as a joined error at the end - stopSessionAndPrint: wrap markSessionEnded error with session ID for context in both single and batch stop paths - runStopMultiSelect: warn and skip sessions concurrently removed between TUI render and confirmation; guard empty toStop after filtering - filterActiveSessions: add nil guard; document intentional dual-check vs status.go's EndedAt-only filter - handleFormCancellation: treat huh.ErrTimeout same as ErrUserAborted; wrap unexpected errors with action name for context - --session flag description: note it is not scoped to current worktree - tests: add TestStopCmd_AllFlag_ExcludesOtherWorktrees, TestStopCmd_AllFlag_NoActiveSessions, TestStopSelectedSessions_StopsAll (replaces non-executing cop-out multi-select test); add PhaseActive coverage to filter unit test; rename SingleSession_Force to clarify empty-WorktreePath intent; add ErrTimeout case to utils_test Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/stop.go | 29 ++++++-- cmd/entire/cli/stop_test.go | 134 ++++++++++++++++++++++++++++------- cmd/entire/cli/utils.go | 8 +-- cmd/entire/cli/utils_test.go | 9 ++- 4 files changed, 143 insertions(+), 37 deletions(-) diff --git a/cmd/entire/cli/stop.go b/cmd/entire/cli/stop.go index 0ad4f7761..1b00ba921 100644 --- a/cmd/entire/cli/stop.go +++ b/cmd/entire/cli/stop.go @@ -48,7 +48,7 @@ Examples: }, } - cmd.Flags().StringVar(&sessionFlag, "session", "", "Stop a specific session by ID") + cmd.Flags().StringVar(&sessionFlag, "session", "", "Stop a specific session by ID (not scoped to current worktree)") cmd.Flags().BoolVar(&allFlag, "all", false, "Stop all active sessions in current worktree") cmd.Flags().BoolVarP(&forceFlag, "force", "f", false, "Skip confirmation prompt") @@ -76,6 +76,7 @@ func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, for } // No-flags path: scope to current worktree before presenting options. + // RunE already validated the git repo, so this call succeeds in practice. worktreePath, err := paths.WorktreeRoot(ctx) if err != nil { return fmt.Errorf("failed to resolve worktree root: %w", err) @@ -104,11 +105,17 @@ func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, for // filterActiveSessions returns sessions in PhaseIdle or PhaseActive — all sessions // that have not been explicitly ended. Both phases are considered stoppable: IDLE // means the agent finished its last turn but the session is still open. -// Matches the EndedAt == nil check used by status.go to avoid false positives on -// legacy sessions where Phase may have defaulted to Idle despite EndedAt being set. +// +// The dual check (Phase != PhaseEnded AND EndedAt == nil) is intentionally stricter +// than status.go's EndedAt-only check: it ensures sessions where only the state +// machine transition succeeded (Phase=Ended) but EndedAt was never written are still +// treated as ended, avoiding an accidental re-stop of a partially-ended session. func filterActiveSessions(states []*strategy.SessionState) []*strategy.SessionState { var active []*strategy.SessionState for _, s := range states { + if s == nil { + continue + } if s.Phase != session.PhaseEnded && s.EndedAt == nil { active = append(active, s) } @@ -159,6 +166,7 @@ func runStopAll(ctx context.Context, cmd *cobra.Command, activeSessions []*strat // Scope to current worktree. Sessions with an empty WorktreePath predate // worktree-path tracking and cannot be attributed to any specific worktree — // including them here prevents them from being permanently unreachable via --all. + // RunE already validated the git repo, so this call succeeds in practice. worktreePath, err := paths.WorktreeRoot(ctx) if err != nil { return fmt.Errorf("failed to resolve worktree root: %w", err) @@ -256,18 +264,27 @@ func runStopMultiSelect(ctx context.Context, cmd *cobra.Command, activeSessions for _, id := range selectedIDs { if s, ok := stateByID[id]; ok { toStop = append(toStop, s) + } else { + // Session was concurrently stopped between form render and confirmation. + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: session %s no longer found, skipping.\n", id) } } + if len(toStop) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No sessions to stop.") + return nil + } return stopSelectedSessions(ctx, cmd, toStop) } // stopSelectedSessions stops each session in the list and prints a result line. -// Errors from individual sessions are all accumulated and returned as a joined error, -// so a single failure does not prevent remaining sessions from being stopped. +// Errors from individual sessions are accumulated so a single failure does not +// prevent remaining sessions from being stopped. Each failure is printed to stderr +// immediately so the user knows which sessions could not be stopped. func stopSelectedSessions(ctx context.Context, cmd *cobra.Command, sessions []*strategy.SessionState) error { var errs []error for _, s := range sessions { if err := stopSessionAndPrint(ctx, cmd, s); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "✗ %v\n", err) errs = append(errs, err) } } @@ -284,7 +301,7 @@ func stopSessionAndPrint(ctx context.Context, cmd *cobra.Command, state *strateg stepCount := state.StepCount if err := markSessionEnded(ctx, nil, sessionID); err != nil { - return err + return fmt.Errorf("failed to stop session %s: %w", sessionID, err) } fmt.Fprintf(cmd.OutOrStdout(), "✓ Session %s stopped.\n", sessionID) diff --git a/cmd/entire/cli/stop_test.go b/cmd/entire/cli/stop_test.go index 3bbaa1b23..e9e8c9d6d 100644 --- a/cmd/entire/cli/stop_test.go +++ b/cmd/entire/cli/stop_test.go @@ -57,9 +57,13 @@ func TestStopCmd_NoActiveSessions(t *testing.T) { } } -func TestStopCmd_SingleSession_Force(t *testing.T) { +// TestStopCmd_SingleSession_EmptyWorktreePath_Force verifies that a session with an +// empty WorktreePath (legacy session without worktree tracking) is included in the +// current worktree's scope and stopped via the no-flags path. +func TestStopCmd_SingleSession_EmptyWorktreePath_Force(t *testing.T) { setupStopTestRepo(t) + // WorktreePath intentionally left empty — exercises the s.WorktreePath == "" fallback. state := makeSessionState("test-stop-single-1", session.PhaseIdle) state.StepCount = 0 if err := strategy.SaveSessionState(context.Background(), state); err != nil { @@ -279,6 +283,72 @@ func TestStopCmd_AllFlag(t *testing.T) { } } +func TestStopCmd_AllFlag_ExcludesOtherWorktrees(t *testing.T) { + setupStopTestRepo(t) + + ctx := context.Background() + worktreePath, wtErr := paths.WorktreeRoot(ctx) + if wtErr != nil { + t.Fatalf("WorktreeRoot() error = %v", wtErr) + } + + inScope := makeSessionState("test-all-scope-in", session.PhaseIdle) + inScope.WorktreePath = worktreePath + + outOfScope := makeSessionState("test-all-scope-out", session.PhaseIdle) + outOfScope.WorktreePath = "/other/worktree" + + for _, s := range []*strategy.SessionState{inScope, outOfScope} { + if err := strategy.SaveSessionState(ctx, s); err != nil { + t.Fatalf("SaveSessionState() error = %v", err) + } + } + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--force"}) + + if err := cmd.ExecuteContext(ctx); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + stopped, err := strategy.LoadSessionState(ctx, "test-all-scope-in") + if err != nil { + t.Fatalf("LoadSessionState(in-scope) error = %v", err) + } + if stopped == nil || stopped.Phase != session.PhaseEnded { + t.Errorf("expected in-scope session to be PhaseEnded, got: %v", stopped.Phase) + } + + untouched, err := strategy.LoadSessionState(ctx, "test-all-scope-out") + if err != nil { + t.Fatalf("LoadSessionState(out-of-scope) error = %v", err) + } + if untouched == nil || untouched.Phase == session.PhaseEnded { + t.Errorf("expected out-of-scope session to remain non-ended, got: %v", untouched.Phase) + } +} + +func TestStopCmd_AllFlag_NoActiveSessions(t *testing.T) { + setupStopTestRepo(t) + + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--all", "--force"}) + + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !strings.Contains(stdout.String(), "No active sessions.") { + t.Errorf("expected 'No active sessions.' in output, got: %q", stdout.String()) + } +} + func TestStopCmd_AllAndSessionMutuallyExclusive(t *testing.T) { setupStopTestRepo(t) @@ -366,32 +436,42 @@ func TestStopCmd_NotGitRepo(t *testing.T) { } } -func TestStopCmd_MultiSession_NoFlags(t *testing.T) { +// TestStopSelectedSessions_StopsAll exercises stopSelectedSessions directly, +// bypassing the TUI multi-select. Verifies all sessions in the list are ended +// and that success lines are printed for each. +func TestStopSelectedSessions_StopsAll(t *testing.T) { setupStopTestRepo(t) - // Create two active sessions. The TUI multi-select would normally hang, - // so we do NOT execute the command. We just verify the session setup is - // consistent: both sessions are non-ended. - state1 := makeSessionState("test-stop-multi-sess-1", session.PhaseIdle) - state2 := makeSessionState("test-stop-multi-sess-2", session.PhaseIdle) - for _, s := range []*strategy.SessionState{state1, state2} { - if err := strategy.SaveSessionState(context.Background(), s); err != nil { + ctx := context.Background() + s1 := makeSessionState("test-batch-stop-1", session.PhaseIdle) + s2 := makeSessionState("test-batch-stop-2", session.PhaseIdle) + for _, s := range []*strategy.SessionState{s1, s2} { + if err := strategy.SaveSessionState(ctx, s); err != nil { t.Fatalf("SaveSessionState() error = %v", err) } } - // Verify both sessions exist and are non-ended, so the multi-select path - // would be triggered by the command (not the no-sessions path). - for _, id := range []string{"test-stop-multi-sess-1", "test-stop-multi-sess-2"} { - loaded, err := strategy.LoadSessionState(context.Background(), id) + cmd := newStopCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := stopSelectedSessions(ctx, cmd, []*strategy.SessionState{s1, s2}); err != nil { + t.Fatalf("stopSelectedSessions() error = %v", err) + } + + out := stdout.String() + for _, id := range []string{"test-batch-stop-1", "test-batch-stop-2"} { + if !strings.Contains(out, id) { + t.Errorf("expected session ID %q in output, got: %q", id, out) + } + + loaded, err := strategy.LoadSessionState(ctx, id) if err != nil { t.Fatalf("LoadSessionState(%s) error = %v", id, err) } - if loaded == nil { - t.Fatalf("expected session %s to exist", id) - } - if loaded.Phase == session.PhaseEnded { - t.Errorf("expected session %s to be non-ended, got PhaseEnded", id) + if loaded == nil || loaded.Phase != session.PhaseEnded { + t.Errorf("expected session %s to be PhaseEnded after batch stop", id) } } } @@ -440,8 +520,8 @@ func TestStopCmd_AlreadyStopped_EndedAtOnly(t *testing.T) { } // TestFilterActiveSessions_ExcludesEndedAtSet verifies that filterActiveSessions -// excludes sessions with EndedAt set regardless of Phase, matching the status.go -// invariant that EndedAt is the authoritative "ended" signal. +// excludes sessions with EndedAt set regardless of Phase, and includes sessions in +// both PhaseIdle and PhaseActive. func TestFilterActiveSessions_ExcludesEndedAtSet(t *testing.T) { t.Parallel() @@ -453,15 +533,17 @@ func TestFilterActiveSessions_ExcludesEndedAtSet(t *testing.T) { properEnded := makeSessionState("proper-ended", session.PhaseEnded) properEnded.EndedAt = &now - active := makeSessionState("active", session.PhaseIdle) + activeIdle := makeSessionState("active-idle", session.PhaseIdle) + activeWorking := makeSessionState("active-working", session.PhaseActive) - result := filterActiveSessions([]*strategy.SessionState{legacyEnded, properEnded, active}) + result := filterActiveSessions([]*strategy.SessionState{legacyEnded, properEnded, activeIdle, activeWorking}) - if len(result) != 1 { - t.Fatalf("expected 1 active session, got %d", len(result)) + if len(result) != 2 { + t.Fatalf("expected 2 active sessions, got %d", len(result)) } - if result[0].SessionID != "active" { - t.Errorf("expected active session ID %q, got %q", "active", result[0].SessionID) + ids := map[string]bool{result[0].SessionID: true, result[1].SessionID: true} + if !ids["active-idle"] || !ids["active-working"] { + t.Errorf("expected active-idle and active-working in result, got: %v", result) } } diff --git a/cmd/entire/cli/utils.go b/cmd/entire/cli/utils.go index fbde35738..d27334988 100644 --- a/cmd/entire/cli/utils.go +++ b/cmd/entire/cli/utils.go @@ -40,14 +40,14 @@ func NewAccessibleForm(groups ...*huh.Group) *huh.Form { } // handleFormCancellation handles cancellation from huh form prompts. -// If err is a user abort (Ctrl+C), it prints "[action] cancelled." to w and returns nil. -// Otherwise it returns the error as-is. +// User abort (Ctrl+C) and timeout both print a cancelled message and return nil. +// Other errors are wrapped with the action name for context. func handleFormCancellation(w io.Writer, action string, err error) error { - if errors.Is(err, huh.ErrUserAborted) { + if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) { fmt.Fprintf(w, "%s cancelled.\n", action) return nil } - return err + return fmt.Errorf("%s prompt failed: %w", action, err) } // printSessionCommand writes a single session resume command line to w. diff --git a/cmd/entire/cli/utils_test.go b/cmd/entire/cli/utils_test.go index 147775320..c15b2576e 100644 --- a/cmd/entire/cli/utils_test.go +++ b/cmd/entire/cli/utils_test.go @@ -37,7 +37,14 @@ func TestHandleFormCancellation(t *testing.T) { wantErr: false, }, { - name: "non-abort error is returned as-is", + name: "timeout prints cancelled and returns nil", + action: "Stop", + err: huh.ErrTimeout, + wantOut: "Stop cancelled.\n", + wantErr: false, + }, + { + name: "unexpected error is wrapped with action name", action: "Reset", err: errors.New("form exploded"), wantOut: "", From f4e108dec1234be1511cdf14092bce73902f98e6 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Fri, 20 Mar 2026 11:41:22 -0700 Subject: [PATCH 4/4] fix: skip confirmation when --session flag is used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicitly passing a session ID is already a deliberate action — no confirmation prompt needed. Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/stop.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/stop.go b/cmd/entire/cli/stop.go index 1b00ba921..1df98ecee 100644 --- a/cmd/entire/cli/stop.go +++ b/cmd/entire/cli/stop.go @@ -58,8 +58,9 @@ Examples: // runStop is the main logic for the stop command. func runStop(ctx context.Context, cmd *cobra.Command, sessionID string, all, force bool) error { // --session path: stop a specific session by explicit ID (no worktree scoping). + // Explicit ID is already a deliberate action — no confirmation needed. if sessionID != "" { - return runStopSession(ctx, cmd, sessionID, force) + return runStopSession(ctx, cmd, sessionID, true) } // List all session states