Skip to content

Commit 7607b4c

Browse files
authored
feat(cloud): auto-retry errored cloud streams on window focus (#2188)
1 parent 82e07fe commit 7607b4c

3 files changed

Lines changed: 140 additions & 0 deletions

File tree

apps/code/src/renderer/components/GlobalEventHandlers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore";
22
import { useFolders } from "@features/folders/hooks/useFolders";
33
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
4+
import { getSessionService } from "@features/sessions/service/service";
45
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
56
import { useSidebarData } from "@features/sidebar/hooks/useSidebarData";
67
import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder";
@@ -227,6 +228,7 @@ export function GlobalEventHandlers({
227228
useEffect(() => {
228229
const handleFocus = () => {
229230
loadFolders();
231+
getSessionService().retryUnhealthyCloudSessions();
230232
};
231233
window.addEventListener("focus", handleFocus);
232234
return () => window.removeEventListener("focus", handleFocus);

apps/code/src/renderer/features/sessions/service/service.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2300,6 +2300,120 @@ describe("SessionService", () => {
23002300
});
23012301
});
23022302

2303+
describe("retryUnhealthyCloudSessions", () => {
2304+
it("retries every errored cloud session", async () => {
2305+
const service = getSessionService();
2306+
2307+
const erroredCloudA: AgentSession = {
2308+
...createMockSession({
2309+
taskId: "task-a",
2310+
taskRunId: "run-a",
2311+
status: "error",
2312+
}),
2313+
isCloud: true,
2314+
};
2315+
const erroredCloudB: AgentSession = {
2316+
...createMockSession({
2317+
taskId: "task-b",
2318+
taskRunId: "run-b",
2319+
status: "error",
2320+
}),
2321+
isCloud: true,
2322+
};
2323+
2324+
mockSessionStoreSetters.getSessions.mockReturnValue({
2325+
"run-a": erroredCloudA,
2326+
"run-b": erroredCloudB,
2327+
});
2328+
mockSessionStoreSetters.getSessionByTaskId.mockImplementation(
2329+
(taskId: string) => {
2330+
if (taskId === "task-a") return erroredCloudA;
2331+
if (taskId === "task-b") return erroredCloudB;
2332+
return undefined;
2333+
},
2334+
);
2335+
2336+
service.retryUnhealthyCloudSessions();
2337+
2338+
await vi.waitFor(() => {
2339+
expect(mockTrpcCloudTask.retry.mutate).toHaveBeenCalledTimes(2);
2340+
});
2341+
expect(mockTrpcCloudTask.retry.mutate).toHaveBeenCalledWith({
2342+
taskId: "task-a",
2343+
runId: "run-a",
2344+
});
2345+
expect(mockTrpcCloudTask.retry.mutate).toHaveBeenCalledWith({
2346+
taskId: "task-b",
2347+
runId: "run-b",
2348+
});
2349+
});
2350+
2351+
it.each([
2352+
[
2353+
"non-error cloud session (status=connected)",
2354+
{
2355+
...createMockSession({
2356+
taskId: "task-skip",
2357+
taskRunId: "run-skip",
2358+
status: "connected",
2359+
}),
2360+
isCloud: true,
2361+
} as AgentSession,
2362+
],
2363+
[
2364+
"non-error cloud session (status=disconnected)",
2365+
{
2366+
...createMockSession({
2367+
taskId: "task-skip",
2368+
taskRunId: "run-skip",
2369+
status: "disconnected",
2370+
}),
2371+
isCloud: true,
2372+
} as AgentSession,
2373+
],
2374+
[
2375+
"errored local session (isCloud=false)",
2376+
createMockSession({
2377+
taskId: "task-skip",
2378+
taskRunId: "run-skip",
2379+
status: "error",
2380+
}),
2381+
],
2382+
])("skips %s", (_label, session) => {
2383+
const service = getSessionService();
2384+
mockSessionStoreSetters.getSessions.mockReturnValue({
2385+
"run-skip": session,
2386+
});
2387+
2388+
service.retryUnhealthyCloudSessions();
2389+
2390+
expect(mockTrpcCloudTask.retry.mutate).not.toHaveBeenCalled();
2391+
});
2392+
2393+
it("swallows failures so one bad retry doesn't block the rest", async () => {
2394+
const service = getSessionService();
2395+
const errored: AgentSession = {
2396+
...createMockSession({
2397+
taskId: "task-a",
2398+
taskRunId: "run-a",
2399+
status: "error",
2400+
}),
2401+
isCloud: true,
2402+
};
2403+
2404+
mockSessionStoreSetters.getSessions.mockReturnValue({ "run-a": errored });
2405+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(errored);
2406+
mockTrpcCloudTask.retry.mutate.mockRejectedValueOnce(
2407+
new Error("network down"),
2408+
);
2409+
2410+
expect(() => service.retryUnhealthyCloudSessions()).not.toThrow();
2411+
await vi.waitFor(() => {
2412+
expect(mockTrpcCloudTask.retry.mutate).toHaveBeenCalled();
2413+
});
2414+
});
2415+
});
2416+
23032417
describe("reset", () => {
23042418
it("clears connecting tasks", () => {
23052419
const service = getSessionService();

apps/code/src/renderer/features/sessions/service/service.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3166,6 +3166,30 @@ export class SessionService {
31663166
}
31673167
}
31683168

3169+
/**
3170+
* Retries every cloud session whose stream is in the `error` state, i.e. the
3171+
* main process exhausted its SSE reconnect budget and surfaced the manual
3172+
* Retry button. Invoked on window focus so users coming back to the app
3173+
* after a Django deploy, laptop sleep, or network blip don't have to click
3174+
* Retry themselves.
3175+
*/
3176+
public retryUnhealthyCloudSessions(): void {
3177+
const sessions = sessionStoreSetters.getSessions();
3178+
for (const session of Object.values(sessions)) {
3179+
if (!session.isCloud) continue;
3180+
if (session.status !== "error") continue;
3181+
log.info("Auto-retrying errored cloud session on focus", {
3182+
taskId: session.taskId,
3183+
});
3184+
this.retryCloudTaskWatch(session.taskId).catch((error) => {
3185+
log.warn("Auto-retry of errored cloud session failed", {
3186+
taskId: session.taskId,
3187+
error,
3188+
});
3189+
});
3190+
}
3191+
}
3192+
31693193
public updateSessionTaskTitle(taskId: string, taskTitle: string): void {
31703194
const session = sessionStoreSetters.getSessionByTaskId(taskId);
31713195
if (!session) return;

0 commit comments

Comments
 (0)