diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 1f1386445c8..d1ceb36cb3c 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1884,6 +1884,12 @@ does not prove that the provider stopped or that a retry is safe; it remains non-recoverable until the existing ownership and recovery checks establish the next permitted action. +The same applies when the shared session notification stream closes +(`notification_transport_failed`). The stream can close because of host-side +runner supervision, such as a stopped sandbox plugin worker, as well as because +of the provider. The terminal failure message therefore keeps the redacted, +bounded cause. It does not name the provider unless the cause does. + If a failed sidecar control closes an ACPX session before the event pump observes the failure, its terminal retains the original closed-code diagnostic. Later polling must not replace it with a generic closed-session message or infer a diff --git a/packages/paperclip-runner/src/drivers/codex/codex-protocol-integrity.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-protocol-integrity.test.ts index b3932975dfd..20dbb749a2a 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-protocol-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-protocol-integrity.test.ts @@ -1111,6 +1111,43 @@ describe("Codex protocol integrity propagation", () => { await expect(session.startTurn({ message: { role: "user", text: "Work" } })).rejects.toMatchObject({ code: "native_provider_terminal_failed", providerCode: "thread_binding_mismatch", recoverable: false }); await session.close({ reason: "test complete" }); }); + it("keeps the redacted, bounded transport cause on the terminal failure", async () => { + // A host-side supervision loss (here, a stopped sandbox plugin worker) + // closed the notification stream. The run's terminal error must explain + // it instead of a bare "failed closed" that reads like a provider fault. + const transport = new FakeCodexTransport(); + const session = await makeDriver([transport]).openSession({ + runId: "run-cause", + normalizedSessionId: "session-cause", + workingDirectory: WORKSPACE, + }); + const events: PrpEvent[] = []; + const consumed = (async () => { + for await (const event of session.events()) events.push(event); + })(); + try { + await session.startTurn({ message: { role: "user", text: "Work." } }); + transport.queue.fail(new Error( + `runner_process_termination_unconfirmed: runnerd process supervision failed: Cannot call "environmentExecute" — worker for "plugin-1" is stopped token=sandbox-secret ${"x".repeat(4_000)}`, + )); + await consumed; + const failed = events.filter((event) => event.eventType === "session.failed"); + expect(failed).toHaveLength(1); + const message = String(failed[0]!.payload.message); + expect(failed[0]!.payload).toMatchObject({ code: "notification_transport_failed", recoverable: false }); + expect(message).toMatch(/^Notification transport failed closed: /); + expect(message).toContain("runner_process_termination_unconfirmed"); + expect(message).toContain('worker for "plugin-1" is stopped'); + expect(message).not.toContain("Error: "); + expect(message).not.toContain("sandbox-secret"); + expect(message.length).toBeLessThanOrEqual(1_200); + const turnFailed = events.find((event) => event.eventType === "turn.failed"); + expect((turnFailed?.payload.error as { message?: string } | undefined)?.message).toBe(message); + } finally { + await session.close({ reason: "test cleanup" }); + await consumed; + } + }); it("does not promote a message-and-field lookalike transport error", async () => { const transport = new FakeCodexTransport(); const session = await makeDriver([transport]).openSession({ diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts index 326d249e929..03ed995c121 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-notifications.ts @@ -74,13 +74,29 @@ export async function pumpNotifications(state: CodexSessionState): Promise message: redactCodexDiagnostic(String(error)), }); state.expirePendingInputRequestsAfterProviderLoss(); + // The terminal error is what the run shows. Keep the redacted cause on it: + // the stream can close because of host-side runner supervision, not only + // the provider, and a bare "failed closed" hides which one it was. state.failProtocol( "notification_transport_failed", - "Provider notification transport failed closed.", + notificationTransportFailureMessage(error), ); } } +const NOTIFICATION_TRANSPORT_CAUSE_LIMIT = 1_000; + +function notificationTransportFailureMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + // Redact before bounding so truncation cannot split a credential pattern. + const cause = redactCodexDiagnostic(raw).replace(/\s+/g, " ").trim(); + if (!cause) return "Notification transport failed closed."; + const bounded = cause.length > NOTIFICATION_TRANSPORT_CAUSE_LIMIT + ? `${cause.slice(0, NOTIFICATION_TRANSPORT_CAUSE_LIMIT)}…` + : cause; + return `Notification transport failed closed: ${bounded}`; +} + async function mapNotification(state: CodexSessionState, notification: CodexRpcNotification): Promise { const sourceSequenceBefore = state.sourceSequence; let rejected = false;