Skip to content

Commit a07cbdd

Browse files
committed
stop codex falling back to opus on mcp failure
1 parent 42660b2 commit a07cbdd

4 files changed

Lines changed: 119 additions & 3 deletions

File tree

packages/agent/src/adapters/acp-connection.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection {
206206
processCallbacks: config.processCallbacks,
207207
posthogApiConfig: resolveEnricherApiConfig(config),
208208
onStructuredOutput: config.onStructuredOutput,
209+
logger: config.logger?.child("CodexAcpAgent"),
209210
});
210211
return agent;
211212
}, agentStream);

packages/agent/src/adapters/codex/codex-agent.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ export interface CodexAcpAgentOptions {
128128
processCallbacks?: ProcessSpawnedCallback;
129129
posthogApiConfig?: PostHogAPIConfig;
130130
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
131+
/**
132+
* Logger wired to the host log sink. Without it the codex-acp subprocess
133+
* stderr, spawn failures and exit codes are written to a throwaway logger and
134+
* never reach the exported logs, so a crash surfaces only as a generic
135+
* "ACP connection closed" with no cause.
136+
*/
137+
logger?: Logger;
131138
}
132139

133140
type CodexSession = BaseSession & {
@@ -320,7 +327,8 @@ export class CodexAcpAgent extends BaseAcpAgent {
320327

321328
constructor(client: AgentSideConnection, options: CodexAcpAgentOptions) {
322329
super(client);
323-
this.logger = new Logger({ debug: true, prefix: "[CodexAcpAgent]" });
330+
this.logger =
331+
options.logger ?? new Logger({ debug: true, prefix: "[CodexAcpAgent]" });
324332

325333
// Load user codex settings before spawning so spawnCodexProcess can
326334
// filter out any [mcp_servers.*] entries from ~/.codex/config.toml.

packages/workspace-server/src/services/agent/agent.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ describe("AgentService", () => {
199199
beforeEach(() => {
200200
vi.clearAllMocks();
201201

202+
// The Codex MCP reachability probe hits the network; default it to "reachable"
203+
// so unrelated session tests stay deterministic and offline-safe.
204+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ body: null }));
205+
202206
deps = createMockDependencies();
203207
service = new AgentService(
204208
deps.processTracking as never,
@@ -219,6 +223,7 @@ describe("AgentService", () => {
219223

220224
afterEach(() => {
221225
vi.restoreAllMocks();
226+
vi.unstubAllGlobals();
222227
});
223228

224229
describe("MCP servers", () => {
@@ -291,6 +296,29 @@ describe("AgentService", () => {
291296
expect(codexMcp).toEqual(claudeMcp);
292297
});
293298

299+
it("drops unreachable MCP servers for codex but keeps them for claude", async () => {
300+
vi.stubGlobal(
301+
"fetch",
302+
vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
303+
);
304+
305+
await service.startSession({
306+
...baseSessionParams,
307+
taskRunId: "run-claude",
308+
adapter: "claude",
309+
});
310+
await service.startSession({
311+
...baseSessionParams,
312+
taskRunId: "run-codex",
313+
adapter: "codex",
314+
});
315+
316+
// Claude connects to MCP lazily, so an unreachable server is harmless.
317+
expect(mockNewSession.mock.calls[0][0].mcpServers).toHaveLength(1);
318+
// codex-acp dies on an unreachable server, so it must be pruned.
319+
expect(mockNewSession.mock.calls[1][0].mcpServers).toHaveLength(0);
320+
});
321+
294322
it("passes reasoning effort to local Codex startup options", async () => {
295323
await service.startSession({
296324
...baseSessionParams,

packages/workspace-server/src/services/agent/agent.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,16 @@ When creating pull requests, add the following footer at the end of the PR descr
762762
})),
763763
);
764764

765+
// codex-acp connects to every MCP server eagerly during session creation
766+
// and treats an unreachable one as fatal, which kills the session
767+
// ("ACP connection closed") and makes the host silently fall back to a
768+
// Claude/Opus session. Claude connects lazily and is unaffected, so only
769+
// the Codex server list is pruned to the reachable ones.
770+
const sessionMcpServers =
771+
adapter === "codex"
772+
? await this.filterReachableMcpServers(mcpServers, taskRunId)
773+
: mcpServers;
774+
765775
let externalPlugins: Awaited<ReturnType<typeof discoverExternalPlugins>> =
766776
[];
767777
try {
@@ -833,7 +843,7 @@ When creating pull requests, add the following footer at the end of the PR descr
833843
const resumeResponse = await connection.resumeSession({
834844
sessionId: existingSessionId,
835845
cwd: repoPath,
836-
mcpServers,
846+
mcpServers: sessionMcpServers,
837847
_meta: {
838848
...(logUrl && {
839849
persistence: { taskId, runId: taskRunId, logUrl },
@@ -862,7 +872,7 @@ When creating pull requests, add the following footer at the end of the PR descr
862872
}
863873
const newSessionResponse = await connection.newSession({
864874
cwd: repoPath,
865-
mcpServers,
875+
mcpServers: sessionMcpServers,
866876
_meta: {
867877
taskRunId,
868878
environment: "local",
@@ -952,6 +962,75 @@ When creating pull requests, add the following footer at the end of the PR descr
952962
}
953963
}
954964

965+
private async filterReachableMcpServers<
966+
T extends {
967+
name: string;
968+
url: string;
969+
headers: Array<{ name: string; value: string }>;
970+
},
971+
>(servers: T[], taskRunId: string): Promise<T[]> {
972+
const probed = await Promise.all(
973+
servers.map(async (server) => ({
974+
server,
975+
reachable: await this.isMcpServerReachable(server),
976+
})),
977+
);
978+
const reachable: T[] = [];
979+
for (const { server, reachable: ok } of probed) {
980+
if (ok) {
981+
reachable.push(server);
982+
} else {
983+
this.log.warn(
984+
"Dropping unreachable MCP server from Codex session; codex-acp treats an unreachable server as a fatal startup error",
985+
{ taskRunId, server: server.name, url: server.url },
986+
);
987+
}
988+
}
989+
return reachable;
990+
}
991+
992+
private async isMcpServerReachable(server: {
993+
url: string;
994+
headers: Array<{ name: string; value: string }>;
995+
}): Promise<boolean> {
996+
const PROBE_TIMEOUT_MS = 4_000;
997+
try {
998+
const headers: Record<string, string> = {
999+
"content-type": "application/json",
1000+
accept: "application/json, text/event-stream",
1001+
};
1002+
for (const header of server.headers) {
1003+
headers[header.name] = header.value;
1004+
}
1005+
const response = await fetch(server.url, {
1006+
method: "POST",
1007+
headers,
1008+
body: JSON.stringify({
1009+
jsonrpc: "2.0",
1010+
id: 0,
1011+
method: "initialize",
1012+
params: {
1013+
protocolVersion: "2025-06-18",
1014+
capabilities: {},
1015+
clientInfo: { name: "posthog-code", version: "1.0.0" },
1016+
},
1017+
}),
1018+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
1019+
});
1020+
await response.body?.cancel();
1021+
// Any HTTP response means the endpoint is reachable. codex-acp only treats
1022+
// transport failures (connection refused, DNS, timeout) as fatal; HTTP or
1023+
// JSON-RPC error responses are handled gracefully.
1024+
return true;
1025+
} catch (err) {
1026+
this.log.debug("MCP server reachability probe failed", {
1027+
url: server.url,
1028+
error: err instanceof Error ? err.message : String(err),
1029+
});
1030+
return false;
1031+
}
1032+
}
1033+
9551034
async prompt(
9561035
sessionId: string,
9571036
prompt: ContentBlock[],

0 commit comments

Comments
 (0)