Skip to content

Commit 174edba

Browse files
authored
feat: auto recover on disconnect (#1687)
1 parent 11f451d commit 174edba

File tree

2 files changed

+319
-31
lines changed

2 files changed

+319
-31
lines changed

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

Lines changed: 131 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,12 @@ describe("SessionService", () => {
296296
hasCodeAccess: true,
297297
needsScopeReauth: false,
298298
});
299+
mockTrpcAgent.onSessionEvent.subscribe.mockReturnValue({
300+
unsubscribe: vi.fn(),
301+
});
302+
mockTrpcAgent.onPermissionRequest.subscribe.mockReturnValue({
303+
unsubscribe: vi.fn(),
304+
});
299305
mockTrpcCloudTask.onUpdate.subscribe.mockReturnValue({
300306
unsubscribe: vi.fn(),
301307
});
@@ -1024,29 +1030,56 @@ describe("SessionService", () => {
10241030
);
10251031
});
10261032

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

10381075
await expect(service.sendPrompt("task-123", "Hello")).rejects.toThrow();
1039-
1040-
// Check that one of the updateSession calls set status to error
1041-
const updateCalls = mockSessionStoreSetters.updateSession.mock.calls as [
1042-
string,
1043-
{ status?: string },
1044-
][];
1045-
const errorCall = updateCalls.find(
1046-
([, updates]) => updates.status === "error",
1076+
expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith(
1077+
"run-123",
1078+
expect.objectContaining({
1079+
status: "disconnected",
1080+
errorMessage: expect.stringContaining("Reconnecting"),
1081+
}),
10471082
);
1048-
expect(errorCall).toBeDefined();
1049-
expect(errorCall?.[0]).toBe("run-123");
10501083
});
10511084
});
10521085

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

0 commit comments

Comments
 (0)