From f9e6d1290d56f5ae63f63cca4fdd231090d79a39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Feb 2026 21:13:00 -0800 Subject: [PATCH] Handle session restart failures without dropping active thread session - Reorder restart flow to bind the new provider session before stopping the old one - Log (and swallow) stop-session failures for superseded sessions - Add regression test ensuring failed restart does not stop the active session --- .../Layers/ProviderCommandReactor.test.ts | 58 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 10 +++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index eba9836a3c503e496b67fc500495d21abf75da07..6bbf951b7eafe67c8543d6aa908b02f0f684408f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -304,6 +304,64 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.sandboxMode).toBe("workspace-write"); }); + it("does not stop the active session when restart fails before rebind", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-restart-failure-1"), + role: "user", + text: "first", + attachments: [], + }, + approvalPolicy: "never", + sandboxMode: "danger-full-access", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + harness.startSession.mockImplementationOnce((_: unknown, __: unknown) => + Effect.fail(new Error("simulated restart failure")) as never, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-restart-failure-2"), + role: "user", + text: "second", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await Effect.runPromise(Effect.sleep("30 millis")); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(1); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session?.providerSessionId).toBe("sess-1"); + expect(thread?.session?.approvalPolicy).toBe("never"); + expect(thread?.session?.sandboxMode).toBe("danger-full-access"); + }); + it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3ae4dc99b5f59695da16e22a2e656d67f428e386..1a206ff646150175caa9be49dce7001992e47aeb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -198,9 +198,17 @@ const make = Effect.gen(function* () { return existingSessionId; } - yield* providerService.stopSession({ sessionId: existingSessionId }); const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null); yield* bindSessionToThread(restartedSession); + yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to stop superseded provider session", { + threadId, + sessionId: existingSessionId, + cause: Cause.pretty(cause), + }), + ), + ); return restartedSession.sessionId; } -- 2.54.0