Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions apps/code/src/renderer/features/sessions/service/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
59 changes: 55 additions & 4 deletions apps/code/src/renderer/features/sessions/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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,
});
}
}
Comment thread
jonathanlab marked this conversation as resolved.

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,
});
Comment thread
jonathanlab marked this conversation as resolved.
Comment thread
jonathanlab marked this conversation as resolved.
}
}

Expand Down
Loading