Skip to content

Commit 56ed7b2

Browse files
jonathanlabvdekrijger
authored andcommitted
feat: auto recover on disconnect (#1687)
1 parent be674e7 commit 56ed7b2

2 files changed

Lines changed: 319 additions & 31 deletions

File tree

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

Lines changed: 131 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,12 @@ describe("SessionService", () => {
299299
hasCodeAccess: true,
300300
needsScopeReauth: false,
301301
});
302+
mockTrpcAgent.onSessionEvent.subscribe.mockReturnValue({
303+
unsubscribe: vi.fn(),
304+
});
305+
mockTrpcAgent.onPermissionRequest.subscribe.mockReturnValue({
306+
unsubscribe: vi.fn(),
307+
});
302308
mockTrpcCloudTask.onUpdate.subscribe.mockReturnValue({
303309
unsubscribe: vi.fn(),
304310
});
@@ -1027,29 +1033,56 @@ describe("SessionService", () => {
10271033
);
10281034
});
10291035

1030-
it("sets session to error state on fatal error", async () => {
1036+
it("attempts automatic recovery on fatal error", async () => {
10311037
const service = getSessionService();
1032-
const mockSession = createMockSession();
1038+
const mockSession = createMockSession({
1039+
logUrl: "https://logs.example.com/run-123",
1040+
});
10331041
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession);
10341042
mockSessionStoreSetters.getSessions.mockReturnValue({
10351043
"run-123": { ...mockSession, isPromptPending: false },
10361044
});
1045+
mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true });
1046+
mockTrpcLogs.readLocalLogs.query.mockResolvedValue("");
1047+
mockTrpcAgent.reconnect.mutate.mockResolvedValue({
1048+
sessionId: "run-123",
1049+
channel: "agent-event:run-123",
1050+
configOptions: [],
1051+
});
1052+
1053+
await service.connectToTask({
1054+
task: createMockTask({
1055+
latest_run: {
1056+
id: "run-123",
1057+
task: "task-123",
1058+
team: 123,
1059+
environment: "local",
1060+
status: "in_progress",
1061+
log_url: "https://logs.example.com/run-123",
1062+
error_message: null,
1063+
output: null,
1064+
state: {},
1065+
branch: null,
1066+
created_at: "2024-01-01T00:00:00Z",
1067+
updated_at: "2024-01-01T00:00:00Z",
1068+
completed_at: null,
1069+
},
1070+
}),
1071+
repoPath: "/repo",
1072+
});
1073+
10371074
mockTrpcAgent.prompt.mutate.mockRejectedValue(
10381075
new Error("Internal error: process exited"),
10391076
);
10401077

10411078
await expect(service.sendPrompt("task-123", "Hello")).rejects.toThrow();
1042-
1043-
// Check that one of the updateSession calls set status to error
1044-
const updateCalls = mockSessionStoreSetters.updateSession.mock.calls as [
1045-
string,
1046-
{ status?: string },
1047-
][];
1048-
const errorCall = updateCalls.find(
1049-
([, updates]) => updates.status === "error",
1079+
expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith(
1080+
"run-123",
1081+
expect.objectContaining({
1082+
status: "disconnected",
1083+
errorMessage: expect.stringContaining("Reconnecting"),
1084+
}),
10501085
);
1051-
expect(errorCall).toBeDefined();
1052-
expect(errorCall?.[0]).toBe("run-123");
10531086
});
10541087
});
10551088

@@ -1366,4 +1399,90 @@ describe("SessionService", () => {
13661399
).resolves.not.toThrow();
13671400
});
13681401
});
1402+
1403+
describe("automatic local recovery", () => {
1404+
it("reconnects automatically after a subscription error", async () => {
1405+
vi.useFakeTimers();
1406+
const service = getSessionService();
1407+
const mockSession = createMockSession({
1408+
status: "connected",
1409+
logUrl: "https://logs.example.com/run-123",
1410+
});
1411+
1412+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession);
1413+
mockSessionStoreSetters.getSessions.mockReturnValue({
1414+
"run-123": mockSession,
1415+
});
1416+
mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true });
1417+
mockTrpcLogs.readLocalLogs.query.mockResolvedValue("");
1418+
mockTrpcAgent.reconnect.mutate.mockResolvedValue({
1419+
sessionId: "run-123",
1420+
channel: "agent-event:run-123",
1421+
configOptions: [],
1422+
});
1423+
1424+
await service.clearSessionError("task-123", "/repo");
1425+
1426+
const onError = mockTrpcAgent.onSessionEvent.subscribe.mock.calls[0]?.[1]
1427+
?.onError as ((error: Error) => void) | undefined;
1428+
expect(onError).toBeDefined();
1429+
1430+
onError?.(new Error("connection dropped"));
1431+
await vi.runAllTimersAsync();
1432+
1433+
expect(mockTrpcAgent.reconnect.mutate).toHaveBeenCalledTimes(2);
1434+
expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith(
1435+
"run-123",
1436+
expect.objectContaining({
1437+
status: "disconnected",
1438+
errorMessage: expect.stringContaining("Reconnecting"),
1439+
}),
1440+
);
1441+
1442+
vi.useRealTimers();
1443+
});
1444+
1445+
it("shows the error screen only after automatic reconnect attempts fail", async () => {
1446+
vi.useFakeTimers();
1447+
const service = getSessionService();
1448+
const mockSession = createMockSession({
1449+
status: "connected",
1450+
logUrl: "https://logs.example.com/run-123",
1451+
});
1452+
1453+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession);
1454+
mockSessionStoreSetters.getSessions.mockReturnValue({
1455+
"run-123": mockSession,
1456+
});
1457+
mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true });
1458+
mockTrpcLogs.readLocalLogs.query.mockResolvedValue("");
1459+
mockTrpcAgent.reconnect.mutate
1460+
.mockResolvedValueOnce({
1461+
sessionId: "run-123",
1462+
channel: "agent-event:run-123",
1463+
configOptions: [],
1464+
})
1465+
.mockResolvedValue(null);
1466+
1467+
await service.clearSessionError("task-123", "/repo");
1468+
1469+
const onError = mockTrpcAgent.onSessionEvent.subscribe.mock.calls[0]?.[1]
1470+
?.onError as ((error: Error) => void) | undefined;
1471+
expect(onError).toBeDefined();
1472+
1473+
onError?.(new Error("connection dropped"));
1474+
await vi.runAllTimersAsync();
1475+
1476+
expect(mockTrpcAgent.reconnect.mutate).toHaveBeenCalledTimes(4);
1477+
expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith(
1478+
expect.objectContaining({
1479+
status: "error",
1480+
errorTitle: "Connection lost",
1481+
errorMessage: expect.any(String),
1482+
}),
1483+
);
1484+
1485+
vi.useRealTimers();
1486+
});
1487+
});
13691488
});

0 commit comments

Comments
 (0)