authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 21:13:00-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 21:26:35-08:00
logf9e6d1290d56f5ae63f63cca4fdd231090d79a39
tree887a9a9e4d37976af20e373afd2f516d58057bda
parent8ef653fcc0270855a7e39140664e584083e759b4

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

2 files changed, 67 insertions(+), 1 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+58
......@@ -304,6 +304,64 @@ describe("ProviderCommandReactor", () => {
304304 expect(thread?.session?.sandboxMode).toBe("workspace-write");
305305 });
306306
307 it("does not stop the active session when restart fails before rebind", async () => {
308 const harness = await createHarness();
309 const now = new Date().toISOString();
310
311 await Effect.runPromise(
312 harness.engine.dispatch({
313 type: "thread.turn.start",
314 commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-1"),
315 threadId: ThreadId.makeUnsafe("thread-1"),
316 message: {
317 messageId: asMessageId("user-message-restart-failure-1"),
318 role: "user",
319 text: "first",
320 attachments: [],
321 },
322 approvalPolicy: "never",
323 sandboxMode: "danger-full-access",
324 createdAt: now,
325 }),
326 );
327
328 await waitFor(() => harness.startSession.mock.calls.length === 1);
329 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
330
331 harness.startSession.mockImplementationOnce((_: unknown, __: unknown) =>
332 Effect.fail(new Error("simulated restart failure")) as never,
333 );
334
335 await Effect.runPromise(
336 harness.engine.dispatch({
337 type: "thread.turn.start",
338 commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"),
339 threadId: ThreadId.makeUnsafe("thread-1"),
340 message: {
341 messageId: asMessageId("user-message-restart-failure-2"),
342 role: "user",
343 text: "second",
344 attachments: [],
345 },
346 approvalPolicy: "on-request",
347 sandboxMode: "workspace-write",
348 createdAt: now,
349 }),
350 );
351
352 await waitFor(() => harness.startSession.mock.calls.length === 2);
353 await Effect.runPromise(Effect.sleep("30 millis"));
354
355 expect(harness.stopSession.mock.calls.length).toBe(0);
356 expect(harness.sendTurn.mock.calls.length).toBe(1);
357
358 const readModel = await Effect.runPromise(harness.engine.getReadModel());
359 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
360 expect(thread?.session?.providerSessionId).toBe("sess-1");
361 expect(thread?.session?.approvalPolicy).toBe("never");
362 expect(thread?.session?.sandboxMode).toBe("danger-full-access");
363 });
364
307365 it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => {
308366 const harness = await createHarness();
309367 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+9-1
......@@ -198,9 +198,17 @@ const make = Effect.gen(function* () {
198198 return existingSessionId;
199199 }
200200
201 yield* providerService.stopSession({ sessionId: existingSessionId });
202201 const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null);
203202 yield* bindSessionToThread(restartedSession);
203 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
204 Effect.catchCause((cause) =>
205 Effect.logWarning("provider command reactor failed to stop superseded provider session", {
206 threadId,
207 sessionId: existingSessionId,
208 cause: Cause.pretty(cause),
209 }),
210 ),
211 );
204212 return restartedSession.sessionId;
205213 }
206214