From b01e6abc064684d2de6a57cb5fdf1fa82fbc84bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 4 May 2026 08:45:20 +0100 Subject: [PATCH] fix: retry CLI passthrough launch without resume flag after fast-fail (#810) --- apps/backend/cmd/mock-agent/main.go | 23 +++ .../backend/cmd/mock-agent/mock_agent_test.go | 36 ++++ .../agent/lifecycle/manager_execution.go | 15 +- .../agent/lifecycle/manager_passthrough.go | 166 ++++++++++++++++-- .../lifecycle/manager_passthrough_test.go | 31 +++- .../backend/internal/agent/lifecycle/types.go | 12 ++ .../server/process/interactive_lifecycle.go | 4 +- .../session-resume-cli-fallback.spec.ts | 104 +++++++++++ 8 files changed, 374 insertions(+), 17 deletions(-) create mode 100644 apps/web/e2e/tests/session/session-resume-cli-fallback.spec.ts diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index de6567f4c9..3acc781f59 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -45,6 +45,15 @@ func main() { if parseTUIFlag() { resumeID := parseResumeFlag() resumed := resumeID != "" || parseContinueFlag() + // --fail-on-resume simulates the real Claude CLI's "No conversation + // found to continue" behaviour: when invoked with -c / --resume but + // the local conversation history is gone, the CLI prints the error + // and exits 1. Used by e2e tests to drive the lifecycle manager's + // resume-fallback path. + if resumed && parseFailOnResumeFlag() { + fmt.Print("\033[38;2;255;107;128mNo conversation found to continue\033[39m\r\n") + os.Exit(1) + } runTUI(model, parsePromptFlag(), resumed) return } @@ -283,6 +292,20 @@ func parseContinueFlag() bool { return slices.Contains(os.Args[1:], "-c") } +// parseFailOnResumeFlag checks if --fail-on-resume is present in the +// command-line args. When set together with a resume flag (-c / --resume), +// the mock-agent emits a "No conversation found to continue" message and +// exits 1, matching the real Claude CLI's behaviour and exercising the +// lifecycle manager's resume-fallback path. +func parseFailOnResumeFlag() bool { + return parseFailOnResumeFromArgs(os.Args) +} + +// parseFailOnResumeFromArgs reports whether --fail-on-resume is in args. +func parseFailOnResumeFromArgs(args []string) bool { + return slices.Contains(args[1:], "--fail-on-resume") +} + // mcpConfigPayload is the JSON structure for --mcp-config. type mcpConfigPayload struct { MCPServers map[string]mcpServerDef `json:"mcpServers"` diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index cb84c0f153..be0f4bf106 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -336,3 +336,39 @@ func TestParseResumeFromArgs(t *testing.T) { }) } } + +func TestParseFailOnResumeFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + { + name: "absent", + args: []string{"mock-agent", "--tui"}, + want: false, + }, + { + name: "present alone", + args: []string{"mock-agent", "--tui", "--fail-on-resume"}, + want: true, + }, + { + name: "present with -c", + args: []string{"mock-agent", "--tui", "-c", "--fail-on-resume"}, + want: true, + }, + { + name: "present interleaved with other flags", + args: []string{"mock-agent", "--fail-on-resume", "--tui", "--model", "mock-fast"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseFailOnResumeFromArgs(tt.args); got != tt.want { + t.Errorf("parseFailOnResumeFromArgs(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index e716301645..c3c856f4bf 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -233,12 +233,21 @@ func (m *Manager) GetExecutionIDForSession(_ context.Context, sessionID string) // // Returns the execution with a running passthrough process, or an error. func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) { - // Check if execution already exists with a running passthrough process + // Check if execution already exists with a running passthrough process. + // PassthroughProcessID is not cleared on exit, so a stale ID can point at + // a dead process; verify the runner still has it before short-circuiting, + // otherwise a fast-failed resume launch would keep returning the dead ID + // and the WS handler's IsProcessReadyOrPending check would 503 forever. if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { if execution.PassthroughProcessID != "" { - return execution, nil + if runner := m.GetInteractiveRunner(); runner != nil && runner.IsProcessReadyOrPending(execution.PassthroughProcessID) { + return execution, nil + } + m.logger.Info("execution has stale passthrough process ID, relaunching", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID), + zap.String("stale_process_id", execution.PassthroughProcessID)) } - // Execution exists but no passthrough process - will try to start it return m.resumeExistingExecution(ctx, sessionID, execution) } diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/lifecycle/manager_passthrough.go index 23cc9cab6d..f1fa782525 100644 --- a/apps/backend/internal/agent/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough.go @@ -313,6 +313,7 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE execution.PassthroughProcessID = processInfo.ID execution.PassthroughStartedAt = time.Now() + execution.passthroughLaunchUsedResume = false m.logger.Info("passthrough session started", zap.String("execution_id", execution.ID), @@ -418,6 +419,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen // 4. Update execution with new process ID execution.PassthroughProcessID = processInfo.ID execution.PassthroughStartedAt = time.Now() + execution.passthroughLaunchUsedResume = false m.logger.Info("passthrough process restarted with fresh context", zap.String("execution_id", execution.ID), @@ -452,10 +454,15 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string return err } - // Build the resume command + // Skip the resume flag if a previous resume already fast-failed for this + // execution: re-attaching `-c` / `--resume` would just reproduce the same + // "No conversation found to continue" exit on every WS reconnect after + // backend restart. Once the sticky flag is set, every subsequent launch + // for this execution starts fresh. + useResume := !execution.passthroughResumeFailed cmd := resolved.agent.BuildPassthroughCommand(agents.PassthroughOptions{ Model: profileModel(resolved.profile), - Resume: true, + Resume: useResume, PermissionValues: profilePermissionValues(resolved.profile), CLIFlagTokens: m.profileCLIFlagTokens(resolved.profile), }) @@ -466,6 +473,7 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string m.logger.Info("resuming passthrough session", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID), + zap.Bool("use_resume", useResume), zap.Strings("command", cmd.Args())) interactiveRunner := m.GetInteractiveRunner() @@ -487,8 +495,9 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string return fmt.Errorf("failed to start passthrough session: %w", err) } - execution.PassthroughProcessID = processInfo.ID execution.PassthroughStartedAt = time.Now() + execution.passthroughLaunchUsedResume = useResume + execution.PassthroughProcessID = processInfo.ID m.logger.Info("passthrough session resumed", zap.String("session_id", sessionID), @@ -594,10 +603,24 @@ func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpd if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed { // Only trigger auto-restart for the passthrough process, not for user shell terminals if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID { - // Snapshot the start time synchronously so the goroutine doesn't - // race the next launch's write to execution.PassthroughStartedAt. + // Snapshot the start time and resume-launch flag synchronously so the + // goroutine doesn't race the next launch's writes to those fields. startedAt := execution.PassthroughStartedAt - go m.handlePassthroughExit(execution, status, startedAt) + usedResume := execution.passthroughLaunchUsedResume + // Detect fast-fail synchronously so we can flip the resume-failed + // flag before the next WS reconnect arrives. The goroutine below + // would otherwise miss this race: when the PTY exits the WS bridge + // closes too, and by the time the goroutine runs (after a 100ms + // cleanup delay) HasActiveWebSocketBySession returns false and it + // bails without setting any flags. Scoped to fast-fail+usedResume + // so a healthy resumed session that exits cleanly or crashes long + // after launch keeps its resume intent for auto-restart. + if usedResume && passthroughExitIsFastFail(startedAt, status) { + execution.passthroughResumeFailed = true + execution.isResumedSession = false + execution.passthroughLaunchUsedResume = false + } + go m.handlePassthroughExit(execution, status, startedAt, usedResume) } else { m.logger.Debug("process exited but not the passthrough process, skipping auto-restart", zap.String("session_id", status.SessionID), @@ -609,10 +632,10 @@ func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpd // handlePassthroughExit handles auto-restart logic when a passthrough process exits. // This function is called asynchronously to allow the old process to be cleaned up first. -// startedAt is the snapshot of execution.PassthroughStartedAt taken synchronously -// at the call site — passed in rather than re-read here to avoid racing with -// the next launch's write to that field. -func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate, startedAt time.Time) { +// startedAt and usedResume are snapshots of the matching execution fields taken +// synchronously at the call site — passed in rather than re-read here to avoid +// racing with the next launch's writes to those fields. +func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agentctltypes.ProcessStatusUpdate, startedAt time.Time, usedResume bool) { const restartDelay = 500 * time.Millisecond const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup // fastFailWindow is short enough to catch launch-time failures (bad CLI @@ -672,13 +695,98 @@ func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agent // auth failure). Restarting just thrashes — the next run hits the same // failure at the same speed. Surface the failure to the user instead. if isFastFailExit(startedAt, exitedAt, exitCode, fastFailWindow) { - m.notifyFastFailExit(interactiveRunner, sessionID, exitedAt.Sub(startedAt), exitCode, fastFailWindow) + uptime := exitedAt.Sub(startedAt) + // If the failed launch was a resume (e.g. `--resume ` or `-c`), + // the most likely cause is a stale conversation ID after a backend + // restart — "No conversation found to continue". Retry once with a + // fresh command (no resume flag) before giving up. + if usedResume { + // Resume-failed flags have already been flipped synchronously in + // handlePassthroughStatus (see passthroughExitIsFastFail) so any + // concurrent WS reconnect that races this goroutine sees the new + // values immediately. + m.attemptResumeFallback(execution, interactiveRunner, sessionID, exitCode, uptime) + return + } + m.notifyFastFailExit(interactiveRunner, sessionID, uptime, exitCode, fastFailWindow) return } m.attemptPassthroughRestart(execution, interactiveRunner, sessionID, exitCode, restartDelay) } +// attemptResumeFallback recovers from a fast-failed resume launch by relaunching +// once with a fresh command (no resume flag). This handles the common case where +// the local CLI's conversation history is gone after a backend restart and +// `claude -c` / `claude --resume ` exits with "No conversation found to +// continue". On a successful fallback the user keeps a working session; on +// continued failure we surface the existing red banner so they can fix their +// profile. +func (m *Manager) attemptResumeFallback(execution *AgentExecution, runner *process.InteractiveRunner, sessionID string, exitCode int, uptime time.Duration) { + m.logger.Info("passthrough resume launch fast-failed, retrying without resume flag", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID), + zap.Int("exit_code", exitCode), + zap.Duration("uptime", uptime)) + + if m.IsShuttingDown() { + m.logger.Debug("skipping passthrough resume fallback during shutdown", + zap.String("session_id", sessionID)) + return + } + if !runner.HasActiveWebSocketBySession(sessionID) { + m.logger.Debug("no active WebSocket, skipping passthrough resume fallback", + zap.String("session_id", sessionID)) + return + } + + banner := "\r\n\x1b[33m[No prior conversation to resume — starting a fresh session...]\x1b[0m\r\n" + if err := runner.WriteToDirectOutputBySession(sessionID, []byte(banner)); err != nil { + m.logger.Debug("failed to write resume-fallback banner to terminal", + zap.String("session_id", sessionID), + zap.Error(err)) + } + + ctx := context.Background() + pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution) + if err != nil { + m.notifyFallbackInfrastructureFailure(runner, sessionID, "build fresh command", err) + return + } + + env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv) + startReq := buildInteractiveStartRequest(sessionID, execution, pt, env, cmd, true) + + processInfo, err := runner.Start(ctx, startReq) + if err != nil { + m.notifyFallbackInfrastructureFailure(runner, sessionID, "start fresh process", err) + return + } + + execution.PassthroughStartedAt = time.Now() + execution.passthroughLaunchUsedResume = false + execution.PassthroughProcessID = processInfo.ID + + if runner.ConnectSessionWebSocket(processInfo.ID) { + m.logger.Info("passthrough resume fallback succeeded", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID), + zap.String("new_process_id", processInfo.ID)) + } else { + m.logger.Warn("passthrough resume fallback started but failed to reconnect WebSocket", + zap.String("session_id", sessionID), + zap.String("new_process_id", processInfo.ID)) + } + + // Mirror ResumePassthroughSession's post-launch bootstrap so right-panel + // shell/git/file features come back too — without this the main terminal + // works but the user's shell session and workspace stream stay torn down. + m.startPassthroughShell(ctx, execution, "failed to start shell after passthrough resume fallback") + if m.streamManager != nil && execution.agentctl != nil && execution.GetWorkspaceStream() == nil { + go m.streamManager.connectWorkspaceStream(execution, nil) + } +} + // attemptPassthroughRestart announces the restart on the terminal, waits the // restart delay, re-checks shutdown/WebSocket, and resumes the session. // Reconnects the existing WebSocket to the new process on success. @@ -735,6 +843,26 @@ func (m *Manager) attemptPassthroughRestart(execution *AgentExecution, runner *p } } +// notifyFallbackInfrastructureFailure surfaces an attemptResumeFallback +// failure that originated from kandev's own machinery (could not build the +// fresh command, could not start the new process) rather than from the +// agent CLI itself. The existing fast-fail banner blames a "bad CLI flag, +// missing binary, or auth failure" — wrong copy for these paths, which +// the user can't fix by editing their profile. +func (m *Manager) notifyFallbackInfrastructureFailure(runner *process.InteractiveRunner, sessionID, stage string, err error) { + m.logger.Error("passthrough resume fallback failed", + zap.String("session_id", sessionID), + zap.String("stage", stage), + zap.Error(err)) + failMsg := fmt.Sprintf("\r\n\x1b[31m[Resume fallback failed: could not %s — %s. Please reconnect to retry.]\x1b[0m\r\n", + stage, err.Error()) + if writeErr := runner.WriteToDirectOutputBySession(sessionID, []byte(failMsg)); writeErr != nil { + m.logger.Debug("failed to write resume-fallback infra-failure banner to terminal", + zap.String("session_id", sessionID), + zap.Error(writeErr)) + } +} + // notifyFastFailExit logs the fast-fail decision and writes a one-shot // banner to the terminal explaining why the auto-restart was skipped. // uptime is the measured process lifetime (status timestamp minus start @@ -753,6 +881,22 @@ func (m *Manager) notifyFastFailExit(runner *process.InteractiveRunner, sessionI } } +// passthroughExitIsFastFail wraps isFastFailExit for the synchronous +// status-callback path that doesn't yet have the unpacked exit code or +// timestamp. Same window/semantics as the goroutine path. +func passthroughExitIsFastFail(startedAt time.Time, status *agentctltypes.ProcessStatusUpdate) bool { + const fastFailWindow = 2 * time.Second + exitCode := 0 + if status.ExitCode != nil { + exitCode = *status.ExitCode + } + exitedAt := status.Timestamp + if exitedAt.IsZero() { + exitedAt = time.Now() + } + return isFastFailExit(startedAt, exitedAt, exitCode, fastFailWindow) +} + // isFastFailExit reports whether a passthrough process exit looks like a // launch failure rather than a runtime exit worth restarting. A zero start // time disables the check (e.g. recovered executions where the start time diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go b/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go index ec393db7df..5f4f72acd1 100644 --- a/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough_test.go @@ -276,13 +276,42 @@ func TestManager_HandlePassthroughExit_SkipsDuringShutdown(t *testing.T) { status := &agentctltypes.ProcessStatusUpdate{SessionID: "sess-1"} start := time.Now() - mgr.handlePassthroughExit(execution, status, start) + mgr.handlePassthroughExit(execution, status, start, false) if elapsed := time.Since(start); elapsed != 0 { t.Errorf("handlePassthroughExit advanced fake time by %v — did not short-circuit during shutdown", elapsed) } }) } +// TestManager_HandlePassthroughExit_ResumeFallback_SkipsDuringShutdown is the +// companion to TestManager_HandlePassthroughExit_SkipsDuringShutdown for the +// new fast-fail-with-resume branch — verifies that the resume-fallback path +// also short-circuits cleanly during graceful shutdown rather than racing the +// teardown. +func TestManager_HandlePassthroughExit_ResumeFallback_SkipsDuringShutdown(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + mgr := newTestManager() + if err := mgr.StopAllAgents(context.Background()); err != nil { + t.Fatalf("StopAllAgents returned error: %v", err) + } + + execution := &AgentExecution{ID: "exec-1", SessionID: "sess-1"} + exitCode := 1 + now := time.Now() + status := &agentctltypes.ProcessStatusUpdate{ + SessionID: "sess-1", + ExitCode: &exitCode, + Timestamp: now.Add(100 * time.Millisecond), + } + + start := time.Now() + mgr.handlePassthroughExit(execution, status, now, true /* usedResume */) + if elapsed := time.Since(start); elapsed != 0 { + t.Errorf("handlePassthroughExit(usedResume=true) advanced fake time by %v — did not short-circuit during shutdown", elapsed) + } + }) +} + // TestIsFastFailExit covers the predicate that decides whether a passthrough // exit looks like a launch failure (bad CLI flag, missing binary, auth // rejection) and should bypass the auto-restart loop. diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 84ad80bf9b..d2054ad600 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -61,6 +61,18 @@ type AgentExecution struct { // Passthrough mode info (CLI passthrough without ACP) PassthroughProcessID string // Process ID in the interactive runner (empty if not in passthrough mode) PassthroughStartedAt time.Time // When the current passthrough process was launched; used to detect fast-fail exits and skip auto-restart loops + // passthroughLaunchUsedResume is true if the current passthrough process was + // launched via ResumePassthroughSession with the resume flag attached. The + // fast-fail handler reads this to decide whether to retry once with a fresh + // command (no resume flag) — covers the "No conversation found to continue" + // case where the CLI's local conversation history is gone after a backend + // restart. + passthroughLaunchUsedResume bool + // passthroughResumeFailed sticks once a resume launch fast-fails, so that + // subsequent ResumePassthroughSession calls (e.g. from EnsurePassthroughExecution + // when the frontend reconnects its terminal WS) build a fresh command + // instead of thrashing on the same broken resume flag. + passthroughResumeFailed bool // isResumedSession is true when this execution was created as part of a session resume // (e.g., after backend restart). Used by StartAgentProcess to route passthrough sessions diff --git a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go index 09a1cce292..dfe1a0aa37 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go +++ b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go @@ -507,7 +507,7 @@ func (r *InteractiveRunner) wait(proc *interactiveProcess) { ) // Log buffer contents if process exited with error (helps debug startup failures) - // Bump to Warn for early-exit failures (process died within 5s of start) — those + // Bump to Error for early-exit failures (process died within 5s of start) — those // are almost always real problems (bad CLI flag, missing binary, auth failure) // rather than the expected user-closes-terminal case. Keep Debug for late exits. if status == types.ProcessStatusFailed && proc.buffer != nil { @@ -530,7 +530,7 @@ func (r *InteractiveRunner) wait(proc *interactiveProcess) { zap.String("output", combinedOutput), } if lifetime < 5*time.Second { - r.logger.Warn("interactive process exited early — likely startup failure", fields...) + r.logger.Error("interactive process exited early — likely startup failure", fields...) } else { r.logger.Debug("interactive process output before exit", fields...) } diff --git a/apps/web/e2e/tests/session/session-resume-cli-fallback.spec.ts b/apps/web/e2e/tests/session/session-resume-cli-fallback.spec.ts new file mode 100644 index 0000000000..b07c24bb35 --- /dev/null +++ b/apps/web/e2e/tests/session/session-resume-cli-fallback.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +/** + * Regression: a CLI passthrough agent that fast-fails on resume (e.g. real + * Claude CLI exits "No conversation found to continue") should auto-recover + * by relaunching once with a fresh command (no resume flag) instead of + * leaving the user stuck with a red banner. + * + * Driven via the mock-agent's --fail-on-resume flag: when invoked with + * -c / --resume the mock prints the canonical error and exits 1; otherwise + * it boots normally. The lifecycle manager's resume-fallback path should + * detect the fast-fail and relaunch fresh. + */ + +async function createTUIProfileWithFailOnResume(apiClient: ApiClient, name: string) { + const { agents } = await apiClient.listAgents(); + // Resolve the mock agent by identity rather than position — this spec + // depends on mock-agent-only behaviour (--fail-on-resume + the "Mock Agent" + // header), so picking agents[0] would silently exercise the wrong agent if + // listAgents() ever changes order. + const mockAgent = agents.find((a) => a.name === "mock-agent"); + if (!mockAgent) { + throw new Error( + `mock-agent not found in listAgents() (got ${agents.map((a) => `${a.id}=${a.name}`).join(", ")})`, + ); + } + return apiClient.createAgentProfile(mockAgent.id, name, { + model: "mock-fast", + cli_passthrough: true, + cli_flags: [{ description: "fail on resume", flag: "--fail-on-resume", enabled: true }], + }); +} + +test.describe("Session resume — CLI fallback after fast-fail", () => { + test.describe.configure({ retries: 1 }); + + test("relaunches without resume flag when CLI exits fast on -c", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const profile = await createTUIProfileWithFailOnResume(apiClient, "TUI Resume Fallback"); + + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "TUI Resume Fallback Task", + profile.id, + { + description: "hello from resume fallback test", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardByTitle("TUI Resume Fallback Task"); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForPassthroughLoad(); + await session.waitForPassthroughLoaded(); + + // Initial fresh launch: --fail-on-resume is present but no resume flag, + // so the mock-agent boots normally and renders the standard header. + await session.expectPassthroughHasText("Mock Agent"); + + // Wait for the workflow step to advance so the session is in + // WAITING_FOR_INPUT — the precondition for resume on next launch. + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 30_000, + }); + + // Restart the backend, then reload the page to trigger a session resume. + // The first launch attempt will pass -c → mock prints "No conversation + // found to continue" and exits 1 → backend clears the resume intent and + // the next launch (whether triggered inline by the fallback goroutine or + // by the next WS reconnect) goes through the fresh-launch path. + await backend.restart(); + await testPage.reload(); + + await session.waitForPassthroughLoad(); + // Skip waitForPassthroughLoaded — with --fail-on-resume the first PTY + // exits before the terminal WS settles, so the loading overlay can stay + // up briefly while the backend pivots to the fresh launch. Poll on the + // fresh-launch header directly: it only renders when PTY2 is alive and + // the WS is connected, which is a stronger signal than the loading flag. + await session.expectPassthroughHasText("Mock Agent", 60_000); + + // The (RESUMED) header is reserved for runs where the resume flag was + // accepted. Asserting its absence confirms the second launch dropped -c. + await session.expectPassthroughNotHasText("RESUMED"); + }); +});