From 9fc65abab76341dc55286a2e649a49fcef52edda Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 22:56:52 -0600 Subject: [PATCH] fix(codex): explain a writer-locked conversation instead of the raw refusal Codex allows one writer per conversation across every app on the machine. When another Codex host holds that lock, `thread/resume` fails with `thread already has an active writer`, which Pylon passed straight through to the thread as a provider adapter error. Nothing told the user which app held the conversation or that closing it there was the fix. Detect that refusal at the resume site and replace it with a message naming the conversation and the action that releases it. The error stays a failure on purpose: falling back to `thread/start` here would strand the conversation's history behind a fresh one, so the recoverable-resume path must keep ignoring it. Adds a triage entry for the same symptom. --- .../Layers/CodexSessionRuntime.test.ts | 68 +++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 27 ++++++++ docs/user/triage.md | 9 +++ 3 files changed, 104 insertions(+) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 3cbf840237..3a509c4f08 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -16,6 +16,7 @@ import { describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, + isThreadWriterLockedError, makeMemoryConsolidationNotificationFilter, openCodexThread, readCodexThread, @@ -871,6 +872,44 @@ describe("isRecoverableThreadResumeError", () => { }); }); +describe("isThreadWriterLockedError", () => { + it("matches the app-server writer-lock refusal", () => { + NodeAssert.equal( + isThreadWriterLockedError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread 01a0ccd7-23c1-7c00-9cbd-eafe286d36b5 already has an active writer", + }), + ), + true, + ); + }); + + it("ignores other resume failures", () => { + NodeAssert.equal( + isThreadWriterLockedError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ), + false, + ); + }); + + it("is not treated as a recoverable resume error", () => { + NodeAssert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread 01a0ccd7-23c1-7c00-9cbd-eafe286d36b5 already has an active writer", + }), + ), + false, + ); + }); +}); + describe("openCodexThread", () => { it.effect("resumes metadata when historical turns contain unknown error values", () => Effect.gen(function* () { @@ -1097,4 +1136,33 @@ describe("openCodexThread", () => { NodeAssert.equal(error.errorMessage, "timed out waiting for server"); }), ); + + it.effect("explains a writer-locked conversation without starting a fresh thread", () => + Effect.gen(function* () { + const refusal = new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread 01a0ccd7-23c1-7c00-9cbd-eafe286d36b5 already has an active writer", + }); + const client = { + request: () => Effect.die("A writer-locked conversation must not start a fresh thread"), + raw: { request: () => Effect.fail(refusal) }, + }; + + const error = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "01a0ccd7-23c1-7c00-9cbd-eafe286d36b5", + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexAppServerRequestError(error)); + NodeAssert.ok(error.errorMessage.includes("01a0ccd7-23c1-7c00-9cbd-eafe286d36b5")); + NodeAssert.match(error.errorMessage, /writer lock/i); + NodeAssert.match(error.errorMessage, /close/i); + NodeAssert.equal(error.cause, refusal); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 81ca7b2cb6..ade2386346 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -78,6 +78,11 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "no rollout found", ]; +// The app-server refuses a resume while another Codex host holds the conversation's +// writer lock. Resuming is the only correct outcome, so this must not fall back to a +// fresh thread: that would strand the conversation's history behind a new one. +const THREAD_WRITER_LOCKED_ERROR_SNIPPET = "already has an active writer"; + export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; } @@ -717,6 +722,19 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string } return { message: line }; } +export function isThreadWriterLockedError(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return message.includes(THREAD_WRITER_LOCKED_ERROR_SNIPPET); +} + +function describeThreadWriterLock(resumeThreadId: string): string { + return ( + `Codex conversation ${resumeThreadId} is open in another Codex app, which holds its writer lock. ` + + "Close that conversation there, most often in the Codex desktop app, then send again. " + + "This thread keeps its history instead of continuing in a new conversation." + ); +} + export function isRecoverableThreadResumeError(error: unknown): boolean { const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); if (!message.includes("thread")) { @@ -801,6 +819,15 @@ export const openCodexThread = (input: { ), ), ), + Effect.mapError((error) => + isThreadWriterLockedError(error) + ? CodexErrors.CodexAppServerRequestError.internalError( + describeThreadWriterLock(resumeThreadId), + undefined, + { method: "thread/resume", operation: "receive-response", cause: error }, + ) + : error, + ), Effect.catchIf( (error) => !input.strictResume && isRecoverableThreadResumeError(error), (error) => diff --git a/docs/user/triage.md b/docs/user/triage.md index 3ad5ccdb60..093eda33b7 100644 --- a/docs/user/triage.md +++ b/docs/user/triage.md @@ -69,3 +69,12 @@ can retry the message after checking that the selected provider is signed in and If a late provider response arrives after that timeout, Pylon ignores it. The late response cannot revive the failed turn. If retries keep timing out, run the triage command above and include the provider name and the time of the failed attempt. + +## A Codex thread says it already has an active writer + +Codex allows one writer per conversation across every app on your machine. When the same +conversation is already open somewhere else, most often the Codex desktop app, Pylon cannot resume +it and the turn fails with a message naming that conversation. + +Close the conversation in the other app and send your message again. Pylon does not silently start a +fresh conversation here, so the thread keeps its history.