Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/refresh-codex-dev-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Refresh a running Codex app-server's MCP and hook components before `agent-bundle dev --install-host codex` reports the epoch attached, with failed refreshes reported as `AB7202`. (#722)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1699,7 +1699,7 @@ host-facing build together with the failed checks.
| --- | --- | --- | --- |
| `AB7200` | error | A development rebuild could not be admitted: the coordinator is closed, closing, or not yet started. | Restart `agent-bundle dev`; no epoch changed. |
| `AB7201` | error | The prepare, lint, or artifact phase of a development rebuild threw instead of reporting diagnostics. The message names the phase and the underlying error. | Fix the named failure and save again; the last-good epoch stays active. |
| `AB7202` | error | Publishing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed. Pointers were rolled back to the previous generation and the failure was published on `dev.host.sync`. | Repair the host cache path or permissions named in the message; the next successful epoch re-syncs. |
| `AB7202` | error | Synchronizing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed during stable-source staging, host installation, an existing Codex app-server refresh, or direct publication. A failed direct publication rolls pointers back when a prior generation remains; every failure is published on `dev.host.sync`. | Repair the host cache path, permissions, or Codex app-server control connection named in the message; the next successful epoch re-syncs. |
| `AB7210` | error | `dev.contracts` is malformed, its `fixtures` module escapes the project root, cannot be loaded, or default-exports something other than route-id keyed `ContractRouteFixture` objects. Reported on `dev.contract.status` for the affected epoch; compilation is unaffected. | Correct `dev.contracts` or the fixture module and rebuild; host surfaces keep the last passing epoch meanwhile. |
| `AB7211` | error | The development contract matrix failed or could not complete for a published epoch. The message carries the aggregated `contract-violation` detail; `dev.contract.status` lists the failed check names grouped by route. That epoch is never adopted by live host connections or development installs. | Fix the failing route or fixture and rebuild; a passing epoch is adopted normally. |
| `AB8024` | error (MCP) | The epoch a live host connection was serving vanished from the epoch store mid-session. The connection is invalidated and the typed MCP error carries `{ code, epochId }`. | Reconnect from the host; the proxy binds to the currently adopted epoch. |
Expand Down
123 changes: 123 additions & 0 deletions packages/agent-bundle/src/dev/codex-app-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { createConnection } from 'node:net';
import { join } from 'node:path';

import WebSocket from 'ws';

import { isErrno } from '../core/errors.ts';
import { exists } from '../core/paths.ts';
import { isRecord } from '../core/strict-json.ts';

const requestTimeoutMs = 30_000;

type CodexAppServerRequest = <Result>(
method: string,
params: Readonly<Record<string, unknown>>,
) => Promise<Result>;

/** Runs one initialized client exchange over Codex's documented local control socket, when present. */
export const withCodexAppServer = async <Result>(
codexRoot: string,
action: (request: CodexAppServerRequest) => Promise<Result>,
): Promise<Result | undefined> => {
const socketPath = join(codexRoot, 'app-server-control', 'app-server-control.sock');
if (!await exists(socketPath)) return undefined;

const socket = new WebSocket('ws://localhost/', {
createConnection: () => createConnection(socketPath),
handshakeTimeout: 5_000,
perMessageDeflate: false,
});
try {
await new Promise<void>((resolvePromise, rejectPromise) => {
const onError = (error: Error): void => rejectPromise(error);
socket.once('error', onError);
socket.once('open', () => {
socket.off('error', onError);
resolvePromise();
});
});
} catch (error) {
socket.terminate();
if (isErrno(error, 'ENOENT') || isErrno(error, 'ECONNREFUSED')) return undefined;
throw error;
}

let nextId = 0;
const pending = new Map<number, {
readonly reject: (error: Error) => void;
readonly resolve: (result: unknown) => void;
}>();
const rejectPending = (error: Error): void => {
for (const request of pending.values()) request.reject(error);
pending.clear();
};
socket.on('error', rejectPending);
socket.on('close', () => rejectPending(new Error('Codex app-server connection closed before responding.')));
socket.on('message', (data) => {
let message: Readonly<Record<string, unknown>> | undefined;
try {
const parsed = JSON.parse(data.toString()) as unknown;
message = isRecord(parsed) ? parsed : undefined;
} catch {
return;
}
if (message === undefined) return;
if (typeof message.method === 'string') return;
const id = message.id;
if (typeof id !== 'number') return;
const request = pending.get(id);
if (request === undefined) return;
pending.delete(id);
if (message.error !== undefined) {
request.reject(new Error(`Codex app-server request failed: ${JSON.stringify(message.error)}`));
} else {
request.resolve(message.result);
}
});
const request: CodexAppServerRequest = <Response>(
method: string,
params: Readonly<Record<string, unknown>>,
): Promise<Response> => new Promise((resolvePromise, rejectPromise) => {
const id = nextId++;
const timeout = setTimeout(() => {
pending.delete(id);
rejectPromise(new Error(`Codex app-server ${method} timed out.`));
}, requestTimeoutMs);
pending.set(id, {
reject: (error) => {
clearTimeout(timeout);
rejectPromise(error);
},
resolve: (result) => {
clearTimeout(timeout);
resolvePromise(result as Response);
},
});
socket.send(JSON.stringify({ id, method, params }));
});

try {
await request('initialize', {
capabilities: {},
clientInfo: {
name: 'agent_bundle',
title: 'Agent Bundle',
version: '0.1.0',
},
});
socket.send('{"method":"initialized"}');
return await action(request);
} finally {
await new Promise<void>((resolvePromise) => {
const timeout = setTimeout(() => {
socket.terminate();
resolvePromise();
}, 1_000);
socket.once('close', () => {
clearTimeout(timeout);
resolvePromise();
});
socket.close();
});
}
};
56 changes: 47 additions & 9 deletions packages/agent-bundle/src/dev/host-install-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { Diagnostic } from '../core/diagnostics.ts';
import {
defaultCommandRunner,
installBundle as defaultInstallBundle,
publicHostRoot,
type InstallBundleOptions,
type InstallCommandRunner,
type InstallHost,
Expand All @@ -32,6 +33,7 @@ import {
uninstallBundle as defaultUninstallBundle,
type UninstallBundleOptions,
} from '../install/uninstall.ts';
import { withCodexAppServer } from './codex-app-server.ts';
import { devProxyServerCommand } from './dev-proxy-command.ts';
import {
subscribeToEpochAdoption,
Expand Down Expand Up @@ -63,6 +65,7 @@ export interface DevHostInstallManagerOptions {
interface InstalledDevHost {
readonly destination: string;
readonly host: InstallHost;
readonly plugin?: string;
epochId: string;
}

Expand Down Expand Up @@ -130,7 +133,11 @@ const prepareDevBundle = async (
epochId: string,
projectRoot: string,
run: PlatformRun,
): Promise<Readonly<{ readonly cleanup: () => Promise<void>; readonly root: string }>> => {
): Promise<Readonly<{
readonly cleanup: () => Promise<void>;
readonly marketplaceDocument?: string;
readonly root: string;
}>> => {
const parent = await mkdtemp(join(tmpdir(), `agent-bundle-dev-${host}-`));
const root = join(parent, 'bundle');
try {
Expand All @@ -139,9 +146,10 @@ const prepareDevBundle = async (
if (manifestRead.status !== 'ok') {
throw new Error(`Development install requires a valid artifact manifest at ${manifestRead.path}.`);
}
const mcpDocument = manifestRead.manifest.projections.find(
const projection = manifestRead.manifest.projections.find(
(projection) => projection.builtInHost === host,
)?.documents.mcp;
);
const mcpDocument = projection?.documents.mcp;
if (mcpDocument !== undefined) {
await rewriteMcpDocument(root, mcpDocument, host, projectRoot, run);
}
Expand All @@ -155,6 +163,9 @@ const prepareDevBundle = async (
});
return Object.freeze({
cleanup: () => rm(parent, { force: true, recursive: true }),
...(projection?.documents.marketplace === undefined
? {}
: { marketplaceDocument: projection.documents.marketplace }),
root,
});
} catch (error) {
Expand Down Expand Up @@ -474,8 +485,10 @@ export class DevHostInstallManager {
try {
const source = host === 'cursor' ? prepared.root : stableDevBundle(this.#projectRoot, host);
let installed = this.#installed.get(host);
if (host !== 'cursor' && (installed === undefined || host === 'codex')) {
await ensureStableDevBundle(prepared.root, source);
}
if (installed === undefined) {
if (host !== 'cursor') await ensureStableDevBundle(prepared.root, source);
const result = await this.#installBundle({
commandRunner: this.#commandRunner,
environment: this.#environment,
Expand All @@ -489,12 +502,35 @@ export class DevHostInstallManager {
destination: installedDestination(result, this.#home, this.#environment),
epochId: '',
host,
...(host === 'codex' ? { plugin: result.plugin } : {}),
};
this.#installed.set(host, installed);
}
const previousEpochId = installed.epochId;
let generationPublished = false;
try {
await publishDevGeneration(installed.destination, prepared.root, epochId);
let refreshedByAppServer = false;
if (host === 'codex') {
refreshedByAppServer = await withCodexAppServer(
publicHostRoot('codex', this.#environment, this.#home ?? homedir()),
async (request) => {
const plugin = installed.plugin;
const marketplaceDocument = prepared.marketplaceDocument;
if (plugin === undefined || marketplaceDocument === undefined) {
throw new TypeError('Cannot refresh a Codex development install with no plugin marketplace identity.');
}
await request('plugin/install', {
marketplacePath: join(source, marketplaceDocument),
pluginName: plugin,
});
return true;
},
) === true;
}
if (!refreshedByAppServer) {
await publishDevGeneration(installed.destination, prepared.root, epochId);
generationPublished = true;
}
} catch (error) {
if (previousEpochId.length > 0 && await pathExists(generationRoot(installed.destination, previousEpochId))) {
await publishInstalledGeneration(installed.destination, previousEpochId);
Expand All @@ -503,10 +539,12 @@ export class DevHostInstallManager {
throw error;
}
installed.epochId = epochId;
await pruneGenerations(
installed.destination,
previousEpochId.length === 0 ? [epochId] : [previousEpochId, epochId],
);
if (generationPublished) {
await pruneGenerations(
installed.destination,
previousEpochId.length === 0 ? [epochId] : [previousEpochId, epochId],
);
}
} finally {
await prepared.cleanup();
}
Expand Down
Loading
Loading