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 1408ed18e..55c4239b6 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -566,6 +566,148 @@ describe("SessionService", () => { }), ); }); + + describe("auto-retry on connect failure", () => { + const setupFailingConnect = () => { + const createTaskRun = vi + .fn() + .mockRejectedValue(new Error("Internal error")); + mockBuildAuthenticatedClient.mockReturnValue({ + ...mockAuthenticatedClient, + createTaskRun, + appendTaskRunLog: vi.fn(), + }); + return { createTaskRun }; + }; + + it("parks the session in 'connecting' and auto-retries via clearSessionError", async () => { + vi.useFakeTimers(); + try { + setupFailingConnect(); + const service = getSessionService(); + const clearSpy = vi + .spyOn(service, "clearSessionError") + .mockResolvedValue(undefined); + + const promise = service.connectToTask({ + task: createMockTask(), + repoPath: "/repo", + }); + + await vi.advanceTimersByTimeAsync(0); + expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith( + expect.objectContaining({ status: "connecting" }), + ); + + await vi.advanceTimersByTimeAsync(10_000); + await promise; + + expect(clearSpy).toHaveBeenCalledTimes(1); + expect(clearSpy).toHaveBeenCalledWith("task-123", "/repo"); + expect(mockSessionStoreSetters.setSession).not.toHaveBeenCalledWith( + expect.objectContaining({ status: "error" }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("flips to error after both auto-retries fail", async () => { + vi.useFakeTimers(); + try { + setupFailingConnect(); + const service = getSessionService(); + const clearSpy = vi + .spyOn(service, "clearSessionError") + .mockRejectedValue(new Error("retry failed")); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue({ + taskRunId: "error-task-123", + taskId: "task-123", + }); + + const promise = service.connectToTask({ + task: createMockTask(), + repoPath: "/repo", + }); + + await vi.advanceTimersByTimeAsync(25_000); + await promise; + + expect(clearSpy).toHaveBeenCalledTimes(2); + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "error-task-123", + expect.objectContaining({ + status: "error", + errorTitle: "Failed to connect", + errorMessage: "retry failed", + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("stops retrying and sets disconnected when device goes offline", async () => { + vi.useFakeTimers(); + try { + setupFailingConnect(); + const service = getSessionService(); + const clearSpy = vi + .spyOn(service, "clearSessionError") + .mockResolvedValue(undefined); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue({ + taskRunId: "error-task-123", + taskId: "task-123", + }); + + const promise = service.connectToTask({ + task: createMockTask(), + repoPath: "/repo", + }); + + await vi.advanceTimersByTimeAsync(0); + mockGetIsOnline.mockReturnValue(false); + await vi.advanceTimersByTimeAsync(10_000); + await promise; + + expect(clearSpy).not.toHaveBeenCalled(); + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "error-task-123", + expect.objectContaining({ + status: "disconnected", + errorMessage: expect.stringContaining("No internet connection"), + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("skips final update when session was dismissed during retry gap", async () => { + vi.useFakeTimers(); + try { + setupFailingConnect(); + const service = getSessionService(); + const clearSpy = vi + .spyOn(service, "clearSessionError") + .mockRejectedValue(new Error("retry failed")); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(undefined); + + const promise = service.connectToTask({ + task: createMockTask(), + repoPath: "/repo", + }); + + await vi.advanceTimersByTimeAsync(25_000); + await promise; + + expect(clearSpy).toHaveBeenCalled(); + expect(mockSessionStoreSetters.updateSession).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + }); }); describe("disconnectFromTask", () => { diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4636440a2..f7ac2facd 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -93,6 +93,8 @@ const LOCAL_SESSION_RECOVERY_MESSAGE = const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE = "Connecting to to the agent has been lost. Retry, or start a new session."; const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required"; +const AUTO_RETRY_MAX_ATTEMPTS = 2; +const AUTO_RETRY_DELAY_MS = 10_000; class GitHubAuthorizationRequiredForCloudHandoffError extends Error { constructor( @@ -443,13 +445,9 @@ export class SessionService { const taskRunId = latestRun?.id ?? `error-${taskId}`; const session = this.createBaseSession(taskRunId, taskId, taskTitle); - session.status = "error"; - session.errorTitle = "Failed to connect"; - session.errorMessage = message; if (initialPrompt?.length) { session.initialPrompt = initialPrompt; } - if (latestRun?.log_url) { try { const { rawEntries } = await this.fetchSessionLogs( @@ -463,7 +461,60 @@ export class SessionService { } } + const shouldAutoRetry = getIsOnline(); + session.status = shouldAutoRetry ? "connecting" : "error"; + if (!shouldAutoRetry) { + session.errorTitle = "Failed to connect"; + session.errorMessage = message; + } sessionStoreSetters.setSession(session); + + if (!shouldAutoRetry) return; + + let lastRetryMessage = message; + let wentOffline = false; + for (let attempt = 1; attempt <= AUTO_RETRY_MAX_ATTEMPTS; attempt++) { + log.warn("Auto-retrying failed connection", { + taskId, + attempt, + delayMs: AUTO_RETRY_DELAY_MS, + }); + await new Promise((resolve) => + setTimeout(resolve, AUTO_RETRY_DELAY_MS), + ); + if (!getIsOnline()) { + log.warn("Skipping retry — device went offline", { + taskId, + attempt, + }); + wentOffline = true; + break; + } + try { + await this.clearSessionError(taskId, repoPath); + return; + } catch (retryError) { + lastRetryMessage = + retryError instanceof Error + ? retryError.message + : String(retryError); + log.error("Auto-retry via clearSessionError failed", { + taskId, + attempt, + error: lastRetryMessage, + }); + } + } + + const currentSession = sessionStoreSetters.getSessionByTaskId(taskId); + if (!currentSession) return; + sessionStoreSetters.updateSession(currentSession.taskRunId, { + status: wentOffline ? "disconnected" : "error", + errorTitle: wentOffline ? undefined : "Failed to connect", + errorMessage: wentOffline + ? "No internet connection. Connect when you're back online." + : lastRetryMessage || message, + }); } }