From 1d061485235893c6bbf12a392dde9080eed3c063 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 5 May 2026 14:47:32 +0200 Subject: [PATCH 1/2] feat(sessions): add auto-retry logic for failed connections Generated-By: PostHog Code Task-Id: 84fbe425-c84e-4f1b-94d4-72773db32287 --- .../features/sessions/service/service.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4636440a2..0e0c38a44 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,49 @@ 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; + 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), + ); + 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 currentTaskRunId = + sessionStoreSetters.getSessionByTaskId(taskId)?.taskRunId ?? taskRunId; + sessionStoreSetters.updateSession(currentTaskRunId, { + status: "error", + errorTitle: "Failed to connect", + errorMessage: lastRetryMessage || message, + }); } } From f1bc50c09a3bf014f10d345e195ff9c03a7279c7 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 5 May 2026 14:55:49 +0200 Subject: [PATCH 2/2] Address PR review feedback (#2030) - Re-check getIsOnline() inside the retry loop so a mid-retry disconnect short-circuits cleanly with a "disconnected" state instead of consuming retry slots with misleading errors. - Guard the final updateSession against a missing session (user dismissed the task during the retry gap). - Add tests covering the auto-retry path: success on retry, failure after both retries, going offline mid-retry, and dismissed session. Generated-By: PostHog Code Task-Id: 84fbe425-c84e-4f1b-94d4-72773db32287 --- .../features/sessions/service/service.test.ts | 142 ++++++++++++++++++ .../features/sessions/service/service.ts | 23 ++- 2 files changed, 159 insertions(+), 6 deletions(-) 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 0e0c38a44..f7ac2facd 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -472,6 +472,7 @@ export class SessionService { 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, @@ -481,6 +482,14 @@ export class SessionService { 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; @@ -497,12 +506,14 @@ export class SessionService { } } - const currentTaskRunId = - sessionStoreSetters.getSessionByTaskId(taskId)?.taskRunId ?? taskRunId; - sessionStoreSetters.updateSession(currentTaskRunId, { - status: "error", - errorTitle: "Failed to connect", - errorMessage: lastRetryMessage || message, + 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, }); } }