Skip to content

Commit 870ec6d

Browse files
fix(codex): close one-shot app server clients
Fix gateway-routed one-shot Codex app-server teardown so owned shared clients are retired after run cleanup. Verified with focused tests, Showboat proof, and green PR CI.
1 parent fef8394 commit 870ec6d

7 files changed

Lines changed: 211 additions & 7 deletions

File tree

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

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,162 @@ describe("runCodexAppServerAttempt", () => {
18101810
expect(request.mock.calls.map(([method]) => method)).not.toContain("app/list");
18111811
});
18121812

1813+
it("retires the shared Codex app-server client after one-shot cleanup turns", async () => {
1814+
const retireSpy = vi.spyOn(sharedClientModule, "retireSharedCodexAppServerClientIfCurrent");
1815+
retireSpy.mockReturnValue({ activeLeases: 0, closed: true });
1816+
const events: string[] = [];
1817+
const closeAndWait = vi.fn(async () => {
1818+
events.push("closeAndWait");
1819+
return true;
1820+
});
1821+
let startedClient: unknown;
1822+
let notify: ((notification: CodexServerNotification) => Promise<void>) | undefined;
1823+
setCodexAppServerClientFactoryForTest(async () => {
1824+
const client = {
1825+
request: vi.fn(async (method: string) => {
1826+
events.push(`request:${method}`);
1827+
if (method === "thread/start") {
1828+
return threadStartResult();
1829+
}
1830+
if (method === "turn/start") {
1831+
return turnStartResult();
1832+
}
1833+
return {};
1834+
}),
1835+
addNotificationHandler: vi.fn((handler) => {
1836+
notify = handler;
1837+
return () => undefined;
1838+
}),
1839+
addRequestHandler: vi.fn(() => () => undefined),
1840+
addCloseHandler: vi.fn(() => () => undefined),
1841+
closeAndWait,
1842+
};
1843+
startedClient = client;
1844+
return client as never;
1845+
});
1846+
const params = createParams(
1847+
path.join(tempDir, "session.jsonl"),
1848+
path.join(tempDir, "workspace"),
1849+
);
1850+
params.cleanupBundleMcpOnRunEnd = true;
1851+
1852+
const run = runCodexAppServerAttempt(params);
1853+
await vi.waitFor(() => expect(notify).toBeDefined(), fastWait);
1854+
if (!notify) {
1855+
throw new Error("expected turn notification handler");
1856+
}
1857+
await notify({
1858+
method: "turn/completed",
1859+
params: {
1860+
threadId: "thread-1",
1861+
turnId: "turn-1",
1862+
turn: { id: "turn-1", status: "completed" },
1863+
},
1864+
});
1865+
await run;
1866+
1867+
expect(retireSpy).toHaveBeenCalledWith(startedClient);
1868+
expect(closeAndWait).toHaveBeenCalledWith({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
1869+
expect(events.indexOf("request:thread/unsubscribe")).toBeGreaterThan(-1);
1870+
expect(events.indexOf("closeAndWait")).toBeGreaterThan(
1871+
events.indexOf("request:thread/unsubscribe"),
1872+
);
1873+
});
1874+
1875+
it("retires the shared Codex app-server client after one-shot turn start failures", async () => {
1876+
const retireSpy = vi.spyOn(sharedClientModule, "retireSharedCodexAppServerClientIfCurrent");
1877+
retireSpy.mockReturnValue({ activeLeases: 0, closed: true });
1878+
const events: string[] = [];
1879+
const closeAndWait = vi.fn(async () => {
1880+
events.push("closeAndWait");
1881+
return true;
1882+
});
1883+
let startedClient: unknown;
1884+
setCodexAppServerClientFactoryForTest(async () => {
1885+
const client = {
1886+
request: vi.fn(async (method: string) => {
1887+
events.push(`request:${method}`);
1888+
if (method === "thread/start") {
1889+
return threadStartResult();
1890+
}
1891+
if (method === "turn/start") {
1892+
throw new Error("turn start failed");
1893+
}
1894+
return {};
1895+
}),
1896+
addNotificationHandler: vi.fn(() => () => undefined),
1897+
addRequestHandler: vi.fn(() => () => undefined),
1898+
addCloseHandler: vi.fn(() => () => undefined),
1899+
closeAndWait,
1900+
};
1901+
startedClient = client;
1902+
return client as never;
1903+
});
1904+
const params = createParams(
1905+
path.join(tempDir, "session.jsonl"),
1906+
path.join(tempDir, "workspace"),
1907+
);
1908+
params.cleanupBundleMcpOnRunEnd = true;
1909+
1910+
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("turn start failed");
1911+
1912+
expect(retireSpy).toHaveBeenCalledWith(startedClient);
1913+
expect(closeAndWait).toHaveBeenCalledWith({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
1914+
expect(events.indexOf("request:thread/unsubscribe")).toBeGreaterThan(-1);
1915+
expect(events.indexOf("closeAndWait")).toBeGreaterThan(
1916+
events.indexOf("request:thread/unsubscribe"),
1917+
);
1918+
});
1919+
1920+
it("keeps the shared Codex app-server client warm without one-shot cleanup", async () => {
1921+
const retireSpy = vi.spyOn(sharedClientModule, "retireSharedCodexAppServerClientIfCurrent");
1922+
const closeAndWait = vi.fn(async () => true);
1923+
let notify: ((notification: CodexServerNotification) => Promise<void>) | undefined;
1924+
setCodexAppServerClientFactoryForTest(
1925+
async () =>
1926+
({
1927+
request: vi.fn(async (method: string) => {
1928+
if (method === "thread/start") {
1929+
return threadStartResult();
1930+
}
1931+
if (method === "turn/start") {
1932+
return turnStartResult();
1933+
}
1934+
return {};
1935+
}),
1936+
addNotificationHandler: vi.fn((handler) => {
1937+
notify = handler;
1938+
return () => undefined;
1939+
}),
1940+
addRequestHandler: vi.fn(() => () => undefined),
1941+
addCloseHandler: vi.fn(() => () => undefined),
1942+
closeAndWait,
1943+
}) as never,
1944+
);
1945+
const params = createParams(
1946+
path.join(tempDir, "session.jsonl"),
1947+
path.join(tempDir, "workspace"),
1948+
);
1949+
1950+
const run = runCodexAppServerAttempt(params);
1951+
await vi.waitFor(() => expect(notify).toBeDefined(), fastWait);
1952+
if (!notify) {
1953+
throw new Error("expected turn notification handler");
1954+
}
1955+
await notify({
1956+
method: "turn/completed",
1957+
params: {
1958+
threadId: "thread-1",
1959+
turnId: "turn-1",
1960+
turn: { id: "turn-1", status: "completed" },
1961+
},
1962+
});
1963+
await run;
1964+
1965+
expect(retireSpy).not.toHaveBeenCalled();
1966+
expect(closeAndWait).not.toHaveBeenCalled();
1967+
});
1968+
18131969
it("keeps searchable Codex dynamic tools canonical in mirrored transcript snapshots", async () => {
18141970
const params = createParams(
18151971
path.join(tempDir, "session.jsonl"),

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ import {
224224
writeCodexAppServerBinding,
225225
type CodexAppServerThreadBinding,
226226
} from "./session-binding.js";
227+
import { retireSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
227228
import { rotateOversizedCodexAppServerStartupBinding } from "./startup-binding.js";
228229
import {
229230
buildDeveloperInstructions,
@@ -1119,6 +1120,7 @@ export async function runCodexAppServerAttempt(
11191120
};
11201121
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
11211122
let releaseSharedClientLease: (() => void) | undefined;
1123+
let sharedCodexClientRetiredForOneShotCleanup = false;
11221124
const releaseSharedClientLeaseOnce = () => {
11231125
const release = releaseSharedClientLease;
11241126
if (!release) {
@@ -1127,6 +1129,31 @@ export async function runCodexAppServerAttempt(
11271129
releaseSharedClientLease = undefined;
11281130
release();
11291131
};
1132+
const retireSharedCodexClientForOneShotCleanup = async () => {
1133+
if (params.cleanupBundleMcpOnRunEnd !== true) {
1134+
return;
1135+
}
1136+
if (sharedCodexClientRetiredForOneShotCleanup) {
1137+
return;
1138+
}
1139+
sharedCodexClientRetiredForOneShotCleanup = true;
1140+
const retired = retireSharedCodexAppServerClientIfCurrent(client);
1141+
embeddedAgentLog.info("codex app-server one-shot cleanup retired shared client", {
1142+
runId: params.runId,
1143+
sessionId: params.sessionId,
1144+
sessionKey: params.sessionKey,
1145+
activeLeases: retired?.activeLeases ?? null,
1146+
closed: retired?.closed ?? false,
1147+
matchedSharedClient: Boolean(retired),
1148+
});
1149+
if (retired?.closed) {
1150+
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
1151+
}
1152+
};
1153+
const releaseSharedClientLeaseAndRetireOneShotClient = async () => {
1154+
releaseSharedClientLeaseOnce();
1155+
await retireSharedCodexClientForOneShotCleanup();
1156+
};
11301157
let sandboxExecEnvironmentAcquired = false;
11311158
const releaseSandboxExecEnvironment = async () => {
11321159
if (sandboxExecEnvironmentAcquired) {
@@ -2302,7 +2329,7 @@ export async function runCodexAppServerAttempt(
23022329
},
23032330
});
23042331
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
2305-
releaseSharedClientLeaseOnce();
2332+
await releaseSharedClientLeaseAndRetireOneShotClient();
23062333
if (usageLimitError) {
23072334
await markCodexAuthProfileBlockedFromRateLimits({
23082335
params,
@@ -2322,7 +2349,7 @@ export async function runCodexAppServerAttempt(
23222349
}
23232350
}
23242351
if (!turn) {
2325-
releaseSharedClientLeaseOnce();
2352+
await releaseSharedClientLeaseAndRetireOneShotClient();
23262353
throw new Error("codex app-server turn/start failed without an error");
23272354
}
23282355
turnIdRef.current = turn.turn.id;
@@ -2772,7 +2799,7 @@ export async function runCodexAppServerAttempt(
27722799
notificationCleanup();
27732800
requestCleanup();
27742801
closeCleanup?.();
2775-
releaseSharedClientLeaseOnce();
2802+
await releaseSharedClientLeaseAndRetireOneShotClient();
27762803
if (nativeHookRelay) {
27772804
if (shouldDelayNativeHookRelayUnregister) {
27782805
// Codex hook subprocesses can outlive a completed app-server turn by a

packages/gateway-protocol/src/schema/agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ export const AgentParamsSchema = Type.Object(
205205
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
206206
bestEffortDeliver: Type.Optional(Type.Boolean()),
207207
lane: Type.Optional(Type.String()),
208-
// Backward-compatible no-op. Older CLI clients sent this field on gateway
209-
// agent requests; the gateway accepts but intentionally ignores it.
208+
// One-shot CLI gateway requests can ask the gateway to close process-wide
209+
// bundle MCP resources after the run instead of keeping them warm.
210210
cleanupBundleMcpOnRunEnd: Type.Optional(Type.Boolean()),
211211
modelRun: Type.Optional(Type.Boolean()),
212212
promptMode: Type.Optional(

src/agents/embedded-agent-runner/run.before-agent-reply-cron.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,20 @@ function firstBeforeAgentReplyCall() {
2323
}
2424

2525
function firstAttemptParams(): {
26+
cleanupBundleMcpOnRunEnd?: boolean;
2627
modelRun?: boolean;
2728
promptMode?: string;
2829
promptCacheKey?: string;
2930
} {
3031
const call = mockedRunEmbeddedAttempt.mock.calls[0] as
31-
| [{ modelRun?: boolean; promptMode?: string; promptCacheKey?: string }]
32+
| [
33+
{
34+
cleanupBundleMcpOnRunEnd?: boolean;
35+
modelRun?: boolean;
36+
promptMode?: string;
37+
promptCacheKey?: string;
38+
},
39+
]
3240
| undefined;
3341
if (!call) {
3442
throw new Error("expected embedded attempt call");
@@ -155,6 +163,17 @@ describe("runEmbeddedAgent cron before_agent_reply seam", () => {
155163
expect(attemptParams.promptMode).toBe("none");
156164
});
157165

166+
it("forwards one-shot bundle MCP cleanup into the embedded attempt", async () => {
167+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
168+
169+
await runEmbeddedAgent({
170+
...overflowBaseRunParams,
171+
cleanupBundleMcpOnRunEnd: true,
172+
});
173+
174+
expect(firstAttemptParams().cleanupBundleMcpOnRunEnd).toBe(true);
175+
});
176+
158177
it("forwards prompt cache identity into the embedded attempt", async () => {
159178
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
160179

src/agents/embedded-agent-runner/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1869,6 +1869,7 @@ async function runEmbeddedAgentInternal(
18691869
bootstrapContextRunKind: params.bootstrapContextRunKind,
18701870
jobId: params.jobId,
18711871
toolsAllow: params.toolsAllow,
1872+
cleanupBundleMcpOnRunEnd: params.cleanupBundleMcpOnRunEnd,
18721873
disableMessageTool: params.disableMessageTool,
18731874
forceMessageTool: params.forceMessageTool,
18741875
enableHeartbeatTool: params.enableHeartbeatTool,

src/commands/agent-via-gateway.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe("agentCliCommand", () => {
301301
expect(request.clientName).toBe("cli");
302302
expect(request.mode).toBe("cli");
303303
expect(request).not.toHaveProperty("scopes");
304-
expect(request.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd");
304+
expect(request.params).toHaveProperty("cleanupBundleMcpOnRunEnd", true);
305305
expect(agentCommand).not.toHaveBeenCalled();
306306
expect(agentModuleLoadCount).not.toHaveBeenCalled();
307307
expect(runtime.log).toHaveBeenCalledWith("hello");

src/commands/agent-via-gateway.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ async function agentViaGatewayCommand(
704704
timeout: timeoutSeconds,
705705
lane: opts.lane,
706706
extraSystemPrompt: opts.extraSystemPrompt,
707+
cleanupBundleMcpOnRunEnd: true,
707708
idempotencyKey,
708709
},
709710
expectFinal: true,

0 commit comments

Comments
 (0)