From 9d89d4152628a258018da6b4e5730ab2e54e6663 Mon Sep 17 00:00:00 2001 From: Snowy Date: Sat, 21 Mar 2026 03:26:34 +0300 Subject: [PATCH 1/3] 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 8d205bbe2f..bc70d9d624 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1668,7 +1668,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(); @@ -1695,6 +1695,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 f9a662b84f..05845d9903 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1182,7 +1182,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"), From 047586f99ded83632b5949ff741a62e9cbf1f322 Mon Sep 17 00:00:00 2001 From: Snowy Date: Sat, 28 Mar 2026 01:49:10 +0300 Subject: [PATCH 2/3] revert(threads): remove active-turn runtime error guard --- .../Layers/ProviderRuntimeIngestion.test.ts | 49 +------------------ .../Layers/ProviderRuntimeIngestion.ts | 8 +-- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index bc70d9d624..8d205bbe2f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1668,7 +1668,7 @@ describe("ProviderRuntimeIngestion", () => { expect(resolvedPayload?.requestType).toBe("command_execution_approval"); }); - it("maps runtime.error into errored session state when no turn is active", async () => { + it("maps runtime.error into errored session state", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1695,53 +1695,6 @@ 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 05845d9903..f9a662b84f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1182,13 +1182,7 @@ const make = Effect.gen(function* () { ? true : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); - 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) { + if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ type: "thread.session.set", commandId: providerCommandId(event, "runtime-error-session-set"), From df84b765bbc7a79b91760b06045ef9e8c180c66c Mon Sep 17 00:00:00 2001 From: Snowy Date: Sat, 28 Mar 2026 01:49:10 +0300 Subject: [PATCH 3/3] fix(codex): treat process stderr as runtime warning --- apps/server/src/codexAppServerManager.test.ts | 36 +++++++++++++++++++ apps/server/src/codexAppServerManager.ts | 18 +++++++++- .../src/provider/Layers/CodexAdapter.test.ts | 34 ++++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 13 +++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 680d9d9608..f614b92302 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -207,6 +207,42 @@ describe("classifyCodexStderrLine", () => { }); }); +describe("process stderr events", () => { + it("emits classified stderr lines as notifications", () => { + const manager = new CodexAppServerManager(); + const emitEvent = vi + .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") + .mockImplementation(() => {}); + + ( + manager as unknown as { + emitNotificationEvent: ( + context: { session: { threadId: ThreadId } }, + method: string, + message: string, + ) => void; + } + ).emitNotificationEvent( + { + session: { + threadId: asThreadId("thread-1"), + }, + }, + "process/stderr", + "fatal: permission denied", + ); + + expect(emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "notification", + method: "process/stderr", + threadId: "thread-1", + message: "fatal: permission denied", + }), + ); + }); +}); + describe("normalizeCodexModelSlug", () => { it("maps 5.3 aliases to gpt-5.3-codex", () => { expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 991a9783df..1f0abd6d73 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1046,7 +1046,7 @@ export class CodexAppServerManager extends EventEmitter { }), ); + it.effect("maps process stderr notifications to runtime.warning", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-process-stderr"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "process/stderr", + turnId: asTurnId("turn-1"), + message: "The filename or extension is too long. (os error 206)", + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "runtime.warning"); + if (firstEvent.value.type !== "runtime.warning") { + return; + } + assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal( + firstEvent.value.payload.message, + "The filename or extension is too long. (os error 206)", + ); + }), + ); + it.effect("preserves request type when mapping serverRequest/resolved", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 9af5aac19d..4fcc7eddd1 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1267,6 +1267,19 @@ function mapToRuntimeEvents( ]; } + if (event.method === "process/stderr") { + return [ + { + type: "runtime.warning", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message: event.message ?? "Codex process stderr", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + if (event.method === "windows/worldWritableWarning") { return [ {