Skip to content

Commit 40c0d9f

Browse files
ml12580claude
andcommitted
fix(codex): backoff startup connection-close retries and surface exhaustion (#83959)
The Codex app-server startup retry loop re-acquired the shared client immediately after a connection-close, before the replacement app-server process had time to bind, and exhausted a 3-attempt budget in the same failing window. Scheduled/background turns then failed even though the route became viable moments later. Add a bounded, abortable backoff between startup retries, raise the budget from 3 to 5, and throw a distinct CodexAppServerStartupExhausted Error (original error preserved as cause) when the window is exhausted, so callers can distinguish startup lifecycle exhaustion from a mid-turn client close. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 3c048ef commit 40c0d9f

2 files changed

Lines changed: 132 additions & 2 deletions

File tree

extensions/codex/src/app-server/attempt-startup.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1111
import { startCodexAttemptThread } from "./attempt-startup.js";
1212
import { CodexAppServerClient } from "./client.js";
1313
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
14+
import { threadStartResult } from "./run-attempt-test-harness.js";
1415
import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js";
1516
import {
1617
clearSharedCodexAppServerClient,
@@ -391,4 +392,88 @@ describe("startCodexAttemptThread", () => {
391392
expect((error as Error).message).toBe("plugin/list timed out");
392393
expect(harness.process.stdin.destroyed).toBe(true);
393394
});
395+
396+
it("retries startup across transient connection-close failures with a backoff (#83959)", async () => {
397+
// The app-server connection closes during startup on the first few attempts
398+
// while the replacement process is still warming up. Startup must survive
399+
// those transient closes (with a bounded backoff between attempts) instead
400+
// of exhausting the retry budget before the server is ready.
401+
const closeFailuresBeforeSuccess = 4;
402+
let factoryInvocations = 0;
403+
const backoffDelays: number[] = [];
404+
const backoffModule = await import("openclaw/plugin-sdk/runtime-env");
405+
const sleepSpy = vi
406+
.spyOn(backoffModule, "sleepWithAbort")
407+
.mockImplementation(async (ms: number) => {
408+
backoffDelays.push(ms);
409+
});
410+
411+
const { harness, run } = startThreadWithHarness(30_000, new AbortController().signal, {
412+
attemptClientFactory:
413+
() =>
414+
async (...args) => {
415+
factoryInvocations += 1;
416+
if (factoryInvocations <= closeFailuresBeforeSuccess) {
417+
throw new Error("codex app-server client is closed");
418+
}
419+
// On the recovering attempt, delegate to the default leased factory so
420+
// the shared-client + initialize handshake path matches a real startup.
421+
return getLeasedSharedCodexAppServerClient(...args);
422+
},
423+
});
424+
425+
const settled = run.then(
426+
(result) => result,
427+
(error: unknown) => error,
428+
);
429+
// The successful attempt still needs to answer the initialize handshake.
430+
await answerInitialize(harness);
431+
const threadStart = await waitForThreadStart(harness);
432+
harness.send({ id: threadStart.id, result: threadStartResult("recovered-thread") });
433+
434+
const result = await settled;
435+
expect(result).not.toBeInstanceOf(Error);
436+
expect(factoryInvocations).toBe(closeFailuresBeforeSuccess + 1);
437+
// A backoff was awaited before every retry (one less than the number of
438+
// failed attempts, since the last failure is followed by the successful attempt).
439+
expect(backoffDelays.length).toBe(closeFailuresBeforeSuccess);
440+
// Backoff is bounded and non-negative.
441+
for (const delay of backoffDelays) {
442+
expect(delay).toBeGreaterThanOrEqual(0);
443+
expect(delay).toBeLessThanOrEqual(4_000);
444+
}
445+
sleepSpy.mockRestore();
446+
});
447+
448+
it("surfaces a distinct startup-exhausted error when connection-close persists (#83959)", async () => {
449+
// When the app-server connection keeps closing through the entire bounded
450+
// retry window, startup must fail with a distinguishable exhaustion error
451+
// (not the raw "client is closed" message) so callers can tell a startup
452+
// lifecycle failure apart from a mid-turn client close.
453+
const backoffModule = await import("openclaw/plugin-sdk/runtime-env");
454+
const sleepSpy = vi.spyOn(backoffModule, "sleepWithAbort").mockImplementation(async () => {});
455+
456+
let factoryInvocations = 0;
457+
const { run } = startThreadWithHarness(30_000, new AbortController().signal, {
458+
attemptClientFactory: () => async () => {
459+
factoryInvocations += 1;
460+
throw new Error("codex app-server client is closed");
461+
},
462+
});
463+
464+
const error = await run.then(
465+
() => undefined,
466+
(err: unknown) => err as Error,
467+
);
468+
expect(error).toBeInstanceOf(Error);
469+
// Distinct exhaustion marker, not the raw connection-closed message.
470+
expect(error?.message).not.toBe("codex app-server client is closed");
471+
expect(error?.message.toLowerCase()).toContain("startup");
472+
expect(error?.message.toLowerCase()).toContain("exhaust");
473+
// The original connection-closed cause is preserved for diagnostics.
474+
expect((error as { cause?: unknown })?.cause).toBeInstanceOf(Error);
475+
// Bounded: the loop did not retry forever.
476+
expect(factoryInvocations).toBeLessThanOrEqual(8);
477+
sleepSpy.mockRestore();
478+
});
394479
});

extensions/codex/src/app-server/attempt-startup.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
type EmbeddedRunAttemptParams,
1111
type resolveSandboxContext,
1212
} from "openclaw/plugin-sdk/agent-harness-runtime";
13+
import {
14+
computeBackoff,
15+
sleepWithAbort,
16+
type BackoffPolicy,
17+
} from "openclaw/plugin-sdk/runtime-env";
1318
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
1419
import {
1520
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
@@ -75,7 +80,40 @@ import {
7580
} from "./turn-router.js";
7681
import type { CodexNativeWebSearchSupport } from "./web-search.js";
7782

78-
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
83+
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 5;
84+
85+
/**
86+
* Bounded backoff between startup connection-close retries. The replacement
87+
* app-server process needs a short warm-up window to bind and become ready
88+
* before the next startup attempt re-acquires the shared client; retrying
89+
* immediately re-enters the same failing window and exhausts the budget
90+
* before the server is viable. See issue #83959.
91+
*/
92+
const CODEX_APP_SERVER_STARTUP_RETRY_BACKOFF: BackoffPolicy = {
93+
initialMs: 500,
94+
maxMs: 4_000,
95+
factor: 2,
96+
jitter: 0.2,
97+
};
98+
99+
/**
100+
* Terminal error raised when the Codex app-server connection keeps closing
101+
* throughout the bounded startup retry window. Carries the original
102+
* connection-closed error as `cause` so callers can distinguish a startup
103+
* lifecycle exhaustion from a mid-turn client close. See issue #83959.
104+
*/
105+
export class CodexAppServerStartupExhaustedError extends Error {
106+
constructor(
107+
public readonly attempts: number,
108+
cause: unknown,
109+
) {
110+
super(
111+
`codex app-server startup exhausted after ${attempts} connection-close retries`,
112+
cause !== undefined ? { cause } : undefined,
113+
);
114+
this.name = "CodexAppServerStartupExhaustedError";
115+
}
116+
}
79117

80118
type CodexSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
81119

@@ -509,18 +547,25 @@ export async function startCodexAttemptThread(params: {
509547
error: formatErrorMessage(error),
510548
},
511549
);
512-
throw error;
550+
throw new CodexAppServerStartupExhaustedError(attempt, error);
513551
}
552+
const backoffMs = computeBackoff(CODEX_APP_SERVER_STARTUP_RETRY_BACKOFF, attempt);
514553
embeddedAgentLog.warn(
515554
"codex app-server connection closed during startup; restarting app-server and retrying",
516555
{
517556
attempt,
518557
nextAttempt: attempt + 1,
519558
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
559+
backoffMs,
520560
clearedSharedClient,
521561
error: formatErrorMessage(error),
522562
},
523563
);
564+
// Give the replacement app-server process a bounded warm-up window
565+
// before re-acquiring the shared client, so the next attempt does
566+
// not re-enter the same failing window. Abortable so a cancelled
567+
// run does not stall on the backoff. See issue #83959.
568+
await sleepWithAbort(backoffMs, params.signal);
524569
}
525570
}
526571
throw new Error("codex app-server startup retry loop exited unexpectedly");

0 commit comments

Comments
 (0)