diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index a3012c8c02476..40f1d7171d1cb 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2288,6 +2288,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 } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index cd311efae0bfd..9675ef41a7a4a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1080,4 +1080,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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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"]); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 365bf7f15a419..8ffe78394448c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -72,6 +72,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 | undefined): boolean { return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; } @@ -186,6 +196,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly resumeFailurePolicy?: CodexThreadResumeFailurePolicy; readonly appServerArgs?: ReadonlyArray; /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ readonly mcpCapabilities?: ReadonlySet; @@ -746,8 +757,10 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly resumeFailurePolicy?: CodexThreadResumeFailurePolicy; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; + const resumeFailurePolicy = input.resumeFailurePolicy ?? "fallback-to-new-thread"; const startParams = buildThreadStartParams({ cwd: input.cwd, runtimeMode: input.runtimeMode, @@ -781,13 +794,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))), ), ); }; @@ -2540,6 +2564,9 @@ export const makeCodexSessionRuntime = ( resumeThreadId: options.governedSkills ? undefined : readResumeCursorThreadId(options.resumeCursor), + ...(options.resumeFailurePolicy !== undefined + ? { resumeFailurePolicy: options.resumeFailurePolicy } + : {}), }); const providerThreadId = opened.thread.id; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 23d4abfb74675..daa341cfbc6b4 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -116,6 +116,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ shadowHomePath: "", modelCatalogMode: "app-server", launchArgs: "", + resumeFailurePolicy: "fallback-to-new-thread", customModels: [], ...overrides, }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9a26d9bcea728..78084ef5e75a0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -234,6 +234,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { shadowHomePath: "", modelCatalogMode: "app-server", launchArgs: "", + resumeFailurePolicy: "fallback-to-new-thread", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { @@ -944,6 +945,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { shadowHomePath: "", modelCatalogMode: "app-server", launchArgs: "", + resumeFailurePolicy: "fallback-to-new-thread", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 665ccc3d53223..8ffe207fed73f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -22,6 +22,32 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownSync(CodexSettings); +describe("Codex resume failure policy", () => { + it("preserves fallback for existing settings without a policy", () => { + expect(decodeCodexSettings({}).resumeFailurePolicy).toBe("fallback-to-new-thread"); + }); + + it.each(["fallback-to-new-thread", "fail-closed"] as const)( + "accepts %s in settings and patches", + (resumeFailurePolicy) => { + expect(decodeCodexSettings({ resumeFailurePolicy }).resumeFailurePolicy).toBe( + resumeFailurePolicy, + ); + expect( + decodeServerSettingsPatch({ providers: { codex: { resumeFailurePolicy } } }).providers + ?.codex?.resumeFailurePolicy, + ).toBe(resumeFailurePolicy); + }, + ); + + it("rejects unknown policies at settings and patch boundaries", () => { + expect(() => decodeCodexSettings({ resumeFailurePolicy: "unknown" })).toThrow(); + expect(() => + decodeServerSettingsPatch({ providers: { codex: { resumeFailurePolicy: "unknown" } } }), + ).toThrow(); + }); +}); + describe("storage cleanup settings", () => { it("keeps cleanup disabled for existing installations", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 843a981d8b37c..b89064d2db308 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -659,6 +659,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 } }), @@ -1469,6 +1478,9 @@ const CodexSettingsPatch = Schema.Struct({ shadowHomePath: Schema.optionalKey(TrimmedString), modelCatalogMode: Schema.optionalKey(CodexModelCatalogMode), launchArgs: Schema.optionalKey(TrimmedString), + resumeFailurePolicy: Schema.optionalKey( + Schema.Literals(["fallback-to-new-thread", "fail-closed"]), + ), customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), });