diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index a48d2e94d..3337d15c0 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -2649,6 +2649,96 @@ describe("SessionService", () => { ); }); + const mockPreBootFailedSession = (overrides: Partial = {}) => + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "failed", + status: "disconnected", + ...overrides, + }), + ); + + it("refuses to resume when the previous run failed before the agent booted", async () => { + const service = getSessionService(); + mockPreBootFailedSession({ + cloudErrorMessage: "Sandbox could not be provisioned", + }); + + await expect(service.sendPrompt("task-123", "retry?")).rejects.toThrow( + "Sandbox could not be provisioned", + ); + expect(mockAuthenticatedClient.runTaskInCloud).not.toHaveBeenCalled(); + }); + + it("falls back to a generic message when the failed run has no error", async () => { + const service = getSessionService(); + mockPreBootFailedSession(); + + await expect(service.sendPrompt("task-123", "retry?")).rejects.toThrow( + /Cloud run couldn't start/, + ); + expect(mockAuthenticatedClient.runTaskInCloud).not.toHaveBeenCalled(); + }); + + it("still resumes when a previously running agent failed mid-execution", async () => { + const service = getSessionService(); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "failed", + status: "connected", + cloudBranch: "feature/mid-run", + }), + ); + mockAuthenticatedClient.getTaskRun.mockResolvedValue({ + id: "run-123", + task: "task-123", + team: 123, + branch: "feature/mid-run", + runtime_adapter: "claude", + model: "claude-sonnet-4-20250514", + reasoning_effort: null, + environment: "cloud", + status: "failed", + log_url: "https://example.com/logs/run-123", + error_message: "agent crashed", + output: {}, + state: {}, + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + completed_at: "2026-04-14T00:05:00Z", + }); + mockAuthenticatedClient.getTask.mockResolvedValue(createMockTask()); + mockAuthenticatedClient.runTaskInCloud.mockResolvedValue( + createMockTask({ + latest_run: { + id: "run-456", + task: "task-123", + team: 123, + branch: "feature/mid-run", + runtime_adapter: "claude", + model: "claude-sonnet-4-20250514", + reasoning_effort: null, + environment: "cloud", + status: "queued", + log_url: "https://example.com/logs/run-456", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:06:00Z", + updated_at: "2026-04-14T00:06:00Z", + completed_at: null, + }, + }), + ); + + const result = await service.sendPrompt("task-123", "try again"); + + expect(result.stopReason).toBe("queued"); + expect(mockAuthenticatedClient.runTaskInCloud).toHaveBeenCalledTimes(1); + }); + it("attempts automatic recovery on fatal error", async () => { const service = getSessionService(); const mockSession = createMockSession({ diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4a8953a9a..8a42c9ff7 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -1660,6 +1660,15 @@ export class SessionService { } if (isTerminalStatus(session.cloudStatus)) { + // If the agent never booted (no `run_started`), resuming spins another + // sandbox that hits the same provisioning failure — surface the error + // instead of looping. + if (session.cloudStatus === "failed" && session.status !== "connected") { + throw new Error( + session.cloudErrorMessage ?? + "Cloud run couldn't start. Check that GitHub is connected for this project, then try again.", + ); + } return this.resumeCloudRun(session, prompt); } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 1590b396a..ab2fa468a 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -76,6 +76,7 @@ export function TaskInput({ trpcReact.folders.getMostRecentlyAccessedRepository.queryOptions(), ); const { + lastUsedLocalWorkspaceMode, setLastUsedLocalWorkspaceMode, lastUsedWorkspaceMode, setLastUsedWorkspaceMode, @@ -115,7 +116,6 @@ export function TaskInput({ ); const [selectedDirectory, setSelectedDirectory] = useState(""); - const workspaceMode = lastUsedWorkspaceMode || "local"; const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -167,12 +167,6 @@ export function TaskInput({ } }, [mostRecentRepo?.path, selectedDirectory]); - const setWorkspaceMode = (mode: WorkspaceMode) => { - setLastUsedWorkspaceMode(mode); - if (mode !== "cloud") { - setLastUsedLocalWorkspaceMode(mode); - } - }; const setAdapter = (newAdapter: AgentAdapter) => setLastUsedAdapter(newAdapter); @@ -185,6 +179,20 @@ export function TaskInput({ refreshRepositories, hasGithubIntegration, } = useUserRepositoryIntegration(); + + // Stay optimistic while the integration list resolves to avoid flicker. + const cloudAvailable = isLoadingRepos || hasGithubIntegration; + const workspaceMode: WorkspaceMode = + !cloudAvailable && lastUsedWorkspaceMode === "cloud" + ? lastUsedLocalWorkspaceMode + : lastUsedWorkspaceMode || "local"; + + const setWorkspaceMode = (mode: WorkspaceMode) => { + setLastUsedWorkspaceMode(mode); + if (mode !== "cloud") { + setLastUsedLocalWorkspaceMode(mode); + } + }; const { repositories: visibleCloudRepositories, isPending: cloudRepositoriesLoading, @@ -606,6 +614,7 @@ export function TaskInput({ onChange={setWorkspaceMode} selectedCloudEnvironmentId={selectedCloudEnvId} onCloudEnvironmentChange={setSelectedCloudEnvId} + cloudAvailable={cloudAvailable} size="1" /> {workspaceMode === "worktree" && ( diff --git a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx b/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx index 08c8c8b77..ed0579027 100644 --- a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx +++ b/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx @@ -36,6 +36,7 @@ interface WorkspaceModeSelectProps { overrideModes?: WorkspaceMode[]; selectedCloudEnvironmentId?: string | null; onCloudEnvironmentChange?: (envId: string | null) => void; + cloudAvailable?: boolean; } const LOCAL_MODES: { @@ -67,6 +68,7 @@ export function WorkspaceModeSelect({ overrideModes, selectedCloudEnvironmentId, onCloudEnvironmentChange, + cloudAvailable = true, }: WorkspaceModeSelectProps) { const cloudModeEnabled = useFeatureFlag("twig-cloud-mode-toggle") || import.meta.env.DEV; @@ -80,9 +82,9 @@ export function WorkspaceModeSelect({ openSettings("cloud-environments", "create"); }, [openSettings]); - const showCloud = overrideModes - ? overrideModes.includes("cloud") - : cloudModeEnabled; + const showCloud = + cloudAvailable && + (overrideModes ? overrideModes.includes("cloud") : cloudModeEnabled); const localModes = useMemo( () =>