From db3f20f5c661257b2678faa4a63b54c63c5846a5 Mon Sep 17 00:00:00 2001 From: Snowy Date: Sat, 21 Mar 2026 03:26:34 +0300 Subject: [PATCH] Keep active-turn runtime errors from ending sessions - Treat runtime.error as advisory while an active turn is still running - Add coverage for active-turn runtime.error and no-turn error mapping --- .../Layers/ProviderRuntimeIngestion.test.ts | 49 ++++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 8 ++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c1ba48108f..230ef099a7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1641,7 +1641,7 @@ describe("ProviderRuntimeIngestion", () => { expect(resolvedPayload?.requestType).toBe("command_execution_approval"); }); - it("maps runtime.error into errored session state", async () => { + it("maps runtime.error into errored session state when no turn is active", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1668,6 +1668,53 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime exploded"); }); + it("keeps the session running when runtime.error arrives for the active turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-runtime-error-turn-started"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-runtime-error"), + payload: {}, + }); + + await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "running" && entry.session?.activeTurnId === "turn-runtime-error", + ); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-runtime-error-active-turn"), + provider: "codex", + createdAt: new Date().toISOString(), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-runtime-error"), + payload: { + message: "The filename or extension is too long. (os error 206)", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "running" && + entry.session?.activeTurnId === "turn-runtime-error" && + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-runtime-error-active-turn" && activity.kind === "runtime.error", + ), + ); + expect(thread.session?.status).toBe("running"); + expect(thread.session?.activeTurnId).toBe("turn-runtime-error"); + expect(thread.session?.lastError).toBeNull(); + }); + it("keeps the session running when a runtime.warning arrives during an active turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3df47941af..83bec4057c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1130,7 +1130,13 @@ const make = Effect.gen(function* () { ? true : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); - if (shouldApplyRuntimeError) { + const isActiveTurnRuntimeError = + activeTurnId !== null && (eventTurnId === undefined || sameId(activeTurnId, eventTurnId)); + + // Some provider/runtime errors are advisory while the active turn continues + // streaming. Keep the projected session runnable until a terminal lifecycle + // event arrives. + if (shouldApplyRuntimeError && !isActiveTurnRuntimeError) { yield* orchestrationEngine.dispatch({ type: "thread.session.set", commandId: providerCommandId(event, "runtime-error-session-set"),