Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
describeMcpElicitation,
hasConfiguredMcpServer,
isRecoverableThreadResumeError,
isThreadWriterLockedError,
makeMemoryConsolidationNotificationFilter,
openCodexThread,
readCodexThread,
Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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);
}),
);
});
27 changes: 27 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | undefined): boolean {
return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;
}
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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) =>
Expand Down
9 changes: 9 additions & 0 deletions docs/user/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading