Skip to content
Draft
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
3 changes: 3 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2266,6 +2266,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
...(isCodexResumeCursorSchema(input.resumeCursor)
? { resumeCursor: input.resumeCursor }
: {}),
...(codexConfig.resumeFailurePolicy === "fail-closed"
? { resumeFailurePolicy: codexConfig.resumeFailurePolicy }
: {}),
runtimeMode: input.runtimeMode,
...(input.modelSelection?.instanceId === boundInstanceId
? { model: input.modelSelection.model }
Expand Down
154 changes: 154 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,4 +1039,158 @@ describe("openCodexThread", () => {
NodeAssert.equal(error.errorMessage, "timed out waiting for server");
}),
);

it.effect("starts a fresh thread when no resume is requested", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const started = makeThreadOpenResponse("fresh-thread");
const opened = yield* openCodexThread({
client: {
raw: {
request: () => Effect.die("No resume must not call thread/resume"),
},
request: (method) => {
calls.push(method);
return Effect.succeed(started);
},
},
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: undefined,
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/start"]);
}),
);

it.effect("fail-closed refuses a fresh start after a recoverable resume failure", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = {
raw: {
request: (method: "thread/resume") => {
calls.push(method);
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
},
},
request: () => Effect.die("fail-closed must not start a fresh thread"),
};

const error = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
resumeFailurePolicy: "fail-closed",
}).pipe(Effect.flip);

NodeAssert.ok(isCodexAppServerRequestError(error));
NodeAssert.equal(error.errorMessage, "thread not found");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("fail-closed still resumes a valid thread without starting a fresh one", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const opened = yield* openCodexThread({
client: {
request: () => Effect.die("A valid resumed thread must not start fresh"),
raw: {
request: (method: "thread/resume") => {
calls.push(method);
return Effect.succeed(makeThreadOpenResponse("saved-thread"));
},
},
},
threadId: ThreadId.make("thread-1"),
runtimeMode: "auto",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "saved-thread",
resumeFailurePolicy: "fail-closed",
});

NodeAssert.equal(opened.thread.id, "saved-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("fail-closed still starts a fresh thread when no resume is requested", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const started = makeThreadOpenResponse("fresh-thread");
const opened = yield* openCodexThread({
client: {
raw: {
request: () => Effect.die("No resume must not call thread/resume"),
},
request: (method) => {
calls.push(method);
return Effect.succeed(started);
},
},
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: undefined,
resumeFailurePolicy: "fail-closed",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/start"]);
}),
);

it.effect("explicit fallback-to-new-thread matches the stock default", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const started = makeThreadOpenResponse("fresh-thread");
const opened = yield* openCodexThread({
client: {
raw: {
request: (method: "thread/resume") => {
calls.push(method);
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
},
},
request: (method) => {
calls.push(method);
return Effect.succeed(started);
},
},
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
resumeFailurePolicy: "fallback-to-new-thread",
});

NodeAssert.notEqual(opened.thread.id, "stale-thread");
NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);
});
41 changes: 34 additions & 7 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
"no rollout found",
];

/**
* What to do when a requested provider thread cannot be resumed.
*
* `fallback-to-new-thread` is the stock behavior: a recoverable resume failure
* silently starts a fresh provider thread. `fail-closed` propagates the resume
* failure instead, so a caller that requires continuity can never accept a
* replacement thread as the resumed one.
*/
export type CodexThreadResumeFailurePolicy = "fallback-to-new-thread" | "fail-closed";

export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | undefined): boolean {
return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;
}
Expand Down Expand Up @@ -178,6 +188,7 @@ export interface CodexSessionRuntimeOptions {
readonly model?: string;
readonly serviceTier?: CodexServiceTier | undefined;
readonly resumeCursor?: CodexResumeCursor;
readonly resumeFailurePolicy?: CodexThreadResumeFailurePolicy;
readonly appServerArgs?: ReadonlyArray<string>;
/** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */
readonly mcpCapabilities?: ReadonlySet<string>;
Expand Down Expand Up @@ -724,8 +735,10 @@ export const openCodexThread = (input: {
readonly requestedModel: string | undefined;
readonly serviceTier: CodexServiceTier | undefined;
readonly resumeThreadId: string | undefined;
readonly resumeFailurePolicy?: CodexThreadResumeFailurePolicy;
}): Effect.Effect<typeof CodexThreadResumeMetadata.Type, CodexErrors.CodexAppServerError> => {
const resumeThreadId = input.resumeThreadId;
const resumeFailurePolicy = input.resumeFailurePolicy ?? "fallback-to-new-thread";
const startParams = buildThreadStartParams({
cwd: input.cwd,
runtimeMode: input.runtimeMode,
Expand Down Expand Up @@ -759,13 +772,24 @@ export const openCodexThread = (input: {
),
),
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
threadId: input.threadId,
requestedRuntimeMode: input.runtimeMode,
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
resumeFailurePolicy === "fail-closed"
? Effect.logError(
"codex app-server thread resume failed; fail-closed policy refuses a fresh start",
{
threadId: input.threadId,
requestedRuntimeMode: input.runtimeMode,
resumeThreadId,
recoverable: true,
cause: error,
},
).pipe(Effect.andThen(Effect.fail(error)))
: Effect.logWarning("codex app-server thread resume fell back to fresh start", {
threadId: input.threadId,
requestedRuntimeMode: input.runtimeMode,
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
),
);
};
Expand Down Expand Up @@ -2377,6 +2401,9 @@ export const makeCodexSessionRuntime = (
requestedModel,
serviceTier: options.serviceTier,
resumeThreadId: readResumeCursorThreadId(options.resumeCursor),
...(options.resumeFailurePolicy !== undefined
? { resumeFailurePolicy: options.resumeFailurePolicy }
: {}),
});

const providerThreadId = opened.thread.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
homePath: "",
shadowHomePath: "",
launchArgs: "",
resumeFailurePolicy: "fallback-to-new-thread",
customModels: [],
...overrides,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
homePath: "/Users/julius/.codex",
shadowHomePath: "",
launchArgs: "",
resumeFailurePolicy: "fallback-to-new-thread",
customModels: [],
});
assert.deepEqual(next.providers.claudeAgent, {
Expand Down Expand Up @@ -939,6 +940,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
homePath: "",
shadowHomePath: "",
launchArgs: "",
resumeFailurePolicy: "fallback-to-new-thread",
customModels: [],
});
assert.deepEqual(next.providers.claudeAgent, {
Expand Down
12 changes: 12 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,15 @@ export const CodexSettings = makeProviderSettingsSchema(
description: "Additional CLI arguments passed to codex app-server on session start.",
}),
),
resumeFailurePolicy: Schema.Literals(["fallback-to-new-thread", "fail-closed"]).pipe(
Schema.withDecodingDefault(Effect.succeed("fallback-to-new-thread")),
Schema.annotateKey({
title: "Thread resume failure policy",
description:
"What to do when a requested provider thread cannot be resumed. 'fail-closed' refuses to start a replacement thread so a failed resume is never reported as continuity.",
providerSettingsForm: { hidden: true },
}),
),
customModels: Schema.Array(CustomModelSetting).pipe(
Schema.withDecodingDefault(Effect.succeed([])),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
Expand Down Expand Up @@ -1299,6 +1308,9 @@ const CodexSettingsPatch = Schema.Struct({
homePath: Schema.optionalKey(TrimmedString),
shadowHomePath: Schema.optionalKey(TrimmedString),
launchArgs: Schema.optionalKey(TrimmedString),
resumeFailurePolicy: Schema.optionalKey(
Schema.Literals(["fallback-to-new-thread", "fail-closed"]),
),
customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
});

Expand Down
Loading