Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 5957a395da refactor(desktop): select native field setters with explicit branches 2026-11-07 15:55:12 +00:00
paseo-bot[bot] 940a2cd1cd fix(desktop): fill controlled form fields before submission
Write text controls through native DOM setters so page-level value tracking sees the input event as a change. Cover state updates, submission, replacement and clearing through the real browser tool journey.
2026-11-07 15:48:13 +00:00
paseo-bot[bot] ef7de3d066 test(server): control recursive watcher reconciliation timing (#9010)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 15:01:32 +00:00
Mohamed Boudra b7b38412a0 Keep turn footer timing within the available chat width (#9007)
* fix(app): keep turn footer timing within the chat width

Let visible timing text determine its height within the available chat width instead of sizing an absolute overlay by character count. Keep actions anchored when duration and timestamp labels swap.

* fix(app): keep footer timestamp hover target stable

* fix(app): reserve both wrapped timing label heights
2026-11-07 21:32:14 +07:00
a48bc3aa6f Keep concurrent Codex discovery from failing during startup (#9008)
* Serialize Codex app-server startup by state directory

* Keep Codex session constructor unchanged

* Type Codex startup failures and inject process launch port

* fix(codex): retain startup diagnostics emitted after exit

Classify failed initialization after stdio closes so the bounded SQLite retry sees the complete stderr. Keep running-session exit handling immediate. The regression delivers the SQLite diagnostic between exit and close.

* test(codex): inject history test process through the public dependency

The history fixture still replaced the old private spawn method, so provider launch preparation tried to find a real CLI in CI. Inject the same process interface as other provider tests and use an invalid command as a guard against accidental real launches.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com>
2026-11-07 14:03:47 +00:00
18 changed files with 1258 additions and 230 deletions
@@ -198,3 +198,81 @@ test.describe("Turn end", () => {
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
async function expectTimingInsideFooter(page: Page): Promise<void> {
const label = page.getByRole("button", { name: /^Worked for/ }).last();
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
const labelBounds = element.parentElement!.getBoundingClientRect();
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const lines: boolean[] = [];
while (walker.nextNode()) {
const range = document.createRange();
range.selectNodeContents(walker.currentNode);
for (const rect of range.getClientRects()) {
lines.push(
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
);
}
}
return lines;
});
expect(paintedLines.length).toBeGreaterThan(0);
expect(paintedLines.every(Boolean)).toBe(true);
}
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
const footer = page.getByTestId("turn-footer").last();
await footer.evaluate((element, availableWidth) => {
element.style.width = `${availableWidth}px`;
}, width);
const label = page.getByRole("button", { name: /^Worked for/ }).last();
await expect(label).toBeVisible();
await expectTimingInsideFooter(page);
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
const before = await controls.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
}),
);
const footerBefore = await footer.boundingBox();
const labelBefore = await label.boundingBox();
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
expect(await label.boundingBox()).toMatchObject({
x: labelBefore!.x,
width: labelBefore!.width,
height: labelBefore!.height,
});
expect(
await controls.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
}),
),
).toEqual(before);
expect(await footer.boundingBox()).toMatchObject({
x: footerBefore!.x,
y: footerBefore!.y,
width: footerBefore!.width,
});
await expectTimingInsideFooter(page);
await page.mouse.move(0, 0);
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
}
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
page,
agent,
}) => {
await openAgentRoute(page, agent);
await expectFooterWithinWidth(page, 140);
await expectFooterWithinWidth(page, 150);
await expectFooterWithinWidth(page, 300);
await expectFooterWithinWidth(page, 880);
});
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
alignSelf: "stretch",
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
+58 -25
View File
@@ -557,6 +557,15 @@ interface AssistantTurnFooterProps {
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
minWidth: 0,
minHeight: 24,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
actions: {
height: 24,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
marginTop: 0,
marginLeft: -theme.spacing[1],
},
labelWrapper: {
position: "relative",
labelButton: {
flex: 1,
minHeight: 24,
flexDirection: "row",
alignItems: "center",
minWidth: 0,
},
labelSizer: {
label: {
width: "100%",
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
overlappingLabel: {
marginLeft: "-100%",
},
hiddenLabel: {
opacity: 0,
},
labelOverlay: {
position: "absolute",
top: 0,
left: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
}));
const TIMESTAMP_REVEAL_MS = 3000;
@@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{getContent || canFork ? (
<View style={assistantTurnFooterStylesheet.actions}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
</View>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
style={assistantTurnFooterStylesheet.labelButton}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
{/* Both labels participate in layout at the available width. Overlapping their
columns reserves the taller wrapped height without measuring text or characters. */}
<Text
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{primaryLabel}
</Text>
{canSwap ? (
<Text
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={!showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
assistantTurnFooterStylesheet.overlappingLabel,
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
) : null}
</Pressable>
) : null}
</View>
@@ -0,0 +1,108 @@
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { expect } from "@playwright/test";
export async function buildFillFixture() {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement as e, useState } from 'react';
import { createRoot } from 'react-dom/client';
function Form() {
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [submitted, setSubmitted] = useState(null);
return e('form', { onSubmit: ev => {
ev.preventDefault(); setSubmitted({ title, notes });
} },
e('input', { 'aria-label': 'React title', value: title,
onChange: ev => setTitle(ev.target.value) }),
e('textarea', { 'aria-label': 'React notes', value: notes,
onChange: ev => setNotes(ev.target.value) }),
e('p', null, 'React state: ' + JSON.stringify({ title, notes })),
e('button', { type: 'submit' }, 'Submit'),
e('p', null, 'Submitted: ' + JSON.stringify(submitted)));
}
createRoot(document.getElementById('root')).render(e(Form));
document.getElementById('plain').addEventListener('input', ev => {
document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value;
});
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
define: { "process.env.NODE_ENV": '"production"' },
});
return `<!doctype html><title>React fill fixture</title><div id="root"></div>
<input aria-label="Plain input" id="plain"><p id="plain-state"></p>
<div role="textbox" aria-label="Editable text" contenteditable="true"></div>
<script>${outputFiles[0].text}</script>`;
}
async function findRef(call, browserId, role, name) {
const { snapshot } = await call("browser_snapshot", { browserId });
const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`));
const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1];
expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy();
return ref;
}
async function fillField(call, browserId, name, value) {
const ref = await findRef(call, browserId, "textbox", name);
await call("browser_fill", { browserId, ref, value });
}
async function submitForm(call, browserId) {
const ref = await findRef(call, browserId, "button", "Submit");
await call("browser_click", { browserId, ref });
}
async function expectPageText(call, browserId, text) {
await expect
.poll(async () => {
const result = await call("browser_evaluate", {
browserId,
function: "() => document.body.innerText",
});
return JSON.parse(result.resultJson);
})
.toContain(text);
}
export async function verifyReactFormFill(call, targetUrl) {
const { browserId } = await call("browser_new_tab", {
url: new URL("/react-fill", targetUrl).href,
});
try {
await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 });
await fillField(call, browserId, "Plain input", "plain value");
await expectPageText(call, browserId, "Plain state: plain value");
await fillField(call, browserId, "Editable text", "editable value");
await expectPageText(call, browserId, "editable value");
await fillField(call, browserId, "React title", "Hello from browser_fill");
await fillField(call, browserId, "React notes", "First line\nSecond line");
await expectPageText(
call,
browserId,
'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
);
await submitForm(call, browserId);
await expectPageText(
call,
browserId,
'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
);
await fillField(call, browserId, "React title", "replacement");
await fillField(call, browserId, "React notes", "");
await submitForm(call, browserId);
await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}');
await fillField(call, browserId, "React title", "");
await submitForm(call, browserId);
await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}');
} finally {
await call("browser_close_tab", { browserId });
}
}
@@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
@@ -216,7 +217,13 @@ async function settleDesktopStartup(page) {
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const fillFixture = await buildFillFixture();
const server = createServer((request, response) => {
if (request.url === "/react-fill") {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(fillFixture);
return;
}
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
@@ -782,6 +789,7 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl);
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
@@ -316,7 +316,16 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
element.focus();
const nextValue = ${JSON.stringify(value)};
if ('value' in element) {
element.value = nextValue;
const view = element.ownerDocument.defaultView;
let prototype = null;
if (element.tagName === 'INPUT') {
prototype = view.HTMLInputElement.prototype;
} else if (element.tagName === 'TEXTAREA') {
prototype = view.HTMLTextAreaElement.prototype;
}
// Bypass instance setters so framework value trackers observe the input event as a change.
if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue);
else element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
@@ -217,6 +217,33 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
}
describe("Codex app-server provider (local e2e)", () => {
test.runIf(isCodexInstalled())(
"concurrent import scans initialize successfully in a fresh shared Codex home",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-"));
try {
for (let round = 0; round < 4; round++) {
const codexHome = path.join(root, String(round));
mkdirSync(codexHome);
const provider = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: codexHome },
});
const results = await Promise.allSettled(
Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })),
);
expect(results).toEqual(
Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })),
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
test.runIf(isCodexInstalled())(
"reloads a persisted idle thread repeatedly without overlapping writers",
async () => {
@@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
createFakeCodexAppServer(),
];
let spawnCount = 0;
let markReconnectStarted!: () => void;
const reconnectStarted = new Promise<void>((resolve) => {
markReconnectStarted = resolve;
});
let releaseReconnect: (() => void) | undefined;
const reconnectGate = new Promise<void>((resolve) => {
releaseReconnect = resolve;
@@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
}
spawnCount += 1;
if (spawnCount > 1) {
markReconnectStarted();
await reconnectGate;
}
return appServer.child;
@@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
const runtimeInfo = session.getRuntimeInfo();
const turnStart = session.startTurn("continue after reconnect");
await reconnectStarted;
const reconnectSpawnCount = spawnCount - 1;
releaseReconnect?.();
await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" });
await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) });
expect(reconnectSpawnCount).toBe(1);
expect(spawnCount).toBe(2);
} finally {
releaseReconnect?.();
await session.close();
@@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
});
test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
},
});
const child = createCodexAppServerChildProcess();
const kill = vi.spyOn(child, "kill");
Object.assign(client, { spawnAppServer: async () => child });
const client = new CodexAppServerAgentClient(logger, undefined, {
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => child,
},
});
await expect(
runProviderRefreshWithDeadline({
@@ -87,6 +87,7 @@ describe("Codex executable discovery", () => {
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
@@ -185,18 +186,24 @@ function createProviderWithFakeAppServer(
): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
customProvider: options?.customProvider,
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
return provider;
}
function createFakeCodexAppServerProcess(spawn: () => ChildProcessWithoutNullStreams) {
return {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => spawn(),
};
}
async function startPublicSteeringSession(
appServer: FakeCodexAppServer,
resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{
@@ -626,24 +633,25 @@ function codexAgentItem(text: string) {
// app-server serving `threads` from thread/read.
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
const appServers: FakeCodexAppServer[] = [];
const provider = new CodexAppServerAgentClient(createTestLogger());
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
},
});
appServers.push(appServer);
return appServer.child;
}),
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
},
});
appServers.push(appServer);
return appServer.child;
};
// The fake app-server stands in for the Codex CLI, which test machines may not have.
const client: AgentClient = {
provider: "codex",
@@ -2373,10 +2381,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2398,10 +2405,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.archiveNativeSession({
provider: "codex",
@@ -2423,10 +2429,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "persisted-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2455,10 +2460,9 @@ describe("Codex app-server provider", () => {
return { thread: { id: "active-thread-id", turns: [] } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2488,10 +2492,9 @@ describe("Codex app-server provider", () => {
return Promise.reject(new Error("thread not found"));
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
await expect(
provider.unarchiveNativeSession({
@@ -7530,6 +7533,52 @@ describe("Codex app-server provider", () => {
});
describe("Codex importable sessions", () => {
test("serializes concurrent app-server startups while allowing all scans to complete", async () => {
const firstEntered = deferred<void>();
const releaseFirst = deferred<void>();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
spawned++;
return createCodexAppServerChildProcess();
}),
_createCodexClient: () => ({
request: async (method) => {
if (method === "initialize") {
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
}
if (method === "thread/list") return { data: [] };
throw new Error(`Unexpected Codex method: ${method}`);
},
notify: () => {},
dispose: async () => {
disposed++;
},
}),
});
const scans = Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 }));
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const results = await Promise.all(scans);
expect(results).toEqual(Array.from({ length: 24 }, () => []));
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(24);
});
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
@@ -7792,19 +7841,8 @@ describe("Codex importable sessions", () => {
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
_createCodexClient: () => fakeClient,
appServerProcess: createFakeCodexAppServerProcess(createCodexAppServerChildProcess),
});
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.exitCode = 0;
child.signalCode = null;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
};
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
@@ -75,7 +75,7 @@ import {
probeExecutable,
} from "../../../executable-resolution/executable-resolution.js";
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexFeatures,
@@ -94,6 +94,7 @@ import {
type CodexThreadRollbackResponse,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { codexAppServerStartup } from "./codex/app-server-startup.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
type CodexHistoryRow,
@@ -291,14 +292,31 @@ const CODEX_MODES: AgentMode[] = [
const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown): Promise<unknown>;
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface CodexAppServerLaunchPrefix {
command: string;
args: string[];
}
interface CodexAppServerProcessSpawnInput extends CodexAppServerLaunchPrefix {
options: SpawnProcessOptions;
}
interface CodexAppServerProcessPort {
resolvePrefix(options: {
runtimeSettings?: ProviderRuntimeSettings;
}): Promise<CodexAppServerLaunchPrefix>;
spawn(input: CodexAppServerProcessSpawnInput): ChildProcess;
}
interface CodexAppServerAgentDeps {
appServerProcess?: CodexAppServerProcessPort;
environment?: Record<string, string>;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
@@ -610,10 +628,9 @@ export async function findDefaultCodexBinary(): Promise<string | null> {
return await findCodexMicrosoftStoreBinary();
}
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string;
args: string[];
}> {
async function resolveCodexLaunchPrefix(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<CodexAppServerLaunchPrefix> {
const launch = await resolveCodexLaunch(runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
if (!availability.available) {
@@ -628,6 +645,11 @@ async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSetting
};
}
const defaultCodexAppServerProcess: CodexAppServerProcessPort = {
resolvePrefix: ({ runtimeSettings }) => resolveCodexLaunchPrefix(runtimeSettings),
spawn: ({ command, args, options }) => spawnProcess(command, args, options),
};
async function resolveCodexLaunch(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<ResolvedProviderLaunch> {
@@ -3369,6 +3391,14 @@ export function buildCodexAppServerEnv(
});
}
function codexStartupStateDirKey(environment: NodeJS.ProcessEnv): string {
const home =
process.platform === "win32"
? environment.USERPROFILE || os.homedir()
: environment.HOME || os.homedir();
return path.resolve(environment.CODEX_HOME || path.join(home, ".codex"));
}
function buildCodexAppServerInitializeParams(): {
clientInfo: { name: string; title: string; version: string };
capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true };
@@ -3566,6 +3596,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private unpairedCompactionItemCompletions = 0;
private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected";
private connectionPromise: Promise<void> | null = null;
private readonly startupAbort = new AbortController();
private closed = false;
private collaborationModes: Array<{
name: string;
@@ -3683,8 +3714,15 @@ export class CodexAppServerAgentSession implements AgentSession {
this.connectionState = "history-ready";
return;
}
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
const { client, initialized } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(this.harnessEnvironment),
spawn: this.spawnAppServer,
createClient: (child) =>
new CodexAppServerClient(child, this.logger, () => this.traceContext()),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
signal: this.startupAbort.signal,
});
if (this.closed) {
await client.dispose();
throw this.createClosedError();
@@ -3697,11 +3735,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerRequestHandlers();
try {
const initialized = toObjectRecord(
await client.request("initialize", buildCodexAppServerInitializeParams()),
this.threadRollbackAvailable = codexServerHasThreadRollback(
toObjectRecord(initialized)?.userAgent,
);
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
client.notify("initialized", {});
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
@@ -4029,11 +4065,16 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private async readArchivedHistory(): Promise<CodexThreadHistory> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
const { client } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(this.harnessEnvironment),
spawn: this.spawnAppServer,
createClient: (child) =>
new CodexAppServerClient(child, this.logger, () => this.traceContext()),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
signal: this.startupAbort.signal,
});
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
return await this.readThreadHistory(client);
} finally {
await client.dispose();
@@ -5296,6 +5337,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async close(): Promise<void> {
this.closed = true;
this.startupAbort.abort(this.createClosedError());
this.background.stop();
this.clearPendingPermissions();
this.pendingToolCallDetails.clear();
@@ -7534,31 +7576,55 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, string> },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
private async prepareAppServerSpawn(options: {
environment: Record<string, string>;
goalsEnabled?: boolean;
agentId?: string;
}): Promise<() => ChildProcessWithoutNullStreams> {
const processPort = this.deps.appServerProcess ?? defaultCodexAppServerProcess;
const launchPrefix = await processPort.resolvePrefix({ runtimeSettings: this.runtimeSettings });
const args = [...launchPrefix.args, "app-server"];
if (options?.goalsEnabled) {
if (options.goalsEnabled) {
args.push("--enable", "goals");
}
this.logger.trace(
{
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options?.goalsEnabled === true,
},
"provider.codex.spawn",
);
const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
return () => {
this.logger.trace(
{
agentId: options.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options.goalsEnabled === true,
},
"provider.codex.spawn",
);
const child = processPort.spawn({
command: launchPrefix.command,
args,
options: {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
baseEnv: options.environment,
},
});
assertChildWithPipes(child);
return child;
};
}
private async startAppServer(
options: { signal?: AbortSignal } = {},
): Promise<CodexAppServerClient> {
const environment = buildCodexAppServerEnv(this.runtimeSettings);
const spawn = await this.prepareAppServerSpawn({ environment });
const { client } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(environment),
spawn,
createClient: (child) => new CodexAppServerClient(child, this.logger),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
signal: options.signal,
});
assertChildWithPipes(child);
return child;
return client;
}
async createSession(
@@ -7577,16 +7643,16 @@ export class CodexAppServerAgentClient implements AgentClient {
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
const spawn = await this.prepareAppServerSpawn({
environment,
goalsEnabled,
agentId: launchContext?.agentId,
});
const session = new CodexAppServerAgentSession(
sessionConfig,
null,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
async () => spawn(),
{ ...this.sessionDeps(launchContext?.env), environment },
options?.persistSession === false,
goalsEnabled,
@@ -7614,16 +7680,16 @@ export class CodexAppServerAgentClient implements AgentClient {
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
const spawn = await this.prepareAppServerSpawn({
environment,
goalsEnabled,
agentId: launchContext?.agentId,
});
const session = new CodexAppServerAgentSession(
merged,
handle,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
async () => spawn(),
{ ...this.sessionDeps(launchContext?.env), environment },
false,
goalsEnabled,
@@ -7638,15 +7704,19 @@ export class CodexAppServerAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger);
const environment = buildCodexAppServerEnv(this.runtimeSettings);
const spawn = await this.prepareAppServerSpawn({ environment });
const { client } = await codexAppServerStartup.start({
stateDirKey: codexStartupStateDirKey(environment),
spawn,
createClient: (child) =>
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger),
initializeParams: buildCodexAppServerInitializeParams(),
logger: this.logger,
});
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
const scanLimit = Math.min(options?.scanLimit ?? limit, 500);
// thread/list returns the cheap `cwd` field. Fetch a wider window when
@@ -7749,15 +7819,10 @@ export class CodexAppServerAgentClient implements AgentClient {
try {
await runProviderRefreshActivity(context, "app-server.start", async () => {
const child = await this.spawnAppServer();
client = new CodexAppServerClient(child, this.logger);
client = await this.startAppServer({ signal: context?.signal });
if (context?.signal.aborted) await dispose();
});
if (!client) throw new Error("Codex app-server did not start");
await runProviderRefreshActivity(context, "initialize", () =>
client!.request("initialize", buildCodexAppServerInitializeParams()),
);
client.notify("initialized", {});
const rawResponse = await runProviderRefreshActivity(context, "model/list", () =>
client!.request("model/list", {}),
@@ -7801,12 +7866,9 @@ export class CodexAppServerAgentClient implements AgentClient {
const threadId = handle.nativeHandle ?? handle.sessionId;
if (!threadId) return;
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
const client = await this.startAppServer();
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
if (state === "archive") {
await client.request("thread/archive", { threadId });
return;
@@ -0,0 +1,347 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerStartupCoordinator } from "./app-server-startup.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
function sqliteStartupError(): CodexAppServerExitError {
return new CodexAppServerExitError({
exitCode: 1,
exitSignal: null,
stderr: "failed to initialize sqlite state runtime",
});
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Codex app-server startup coordinator", () => {
test("returns initialize metadata so sessions retain server capabilities", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const client = {
request: async () => ({ userAgent: "codex/0.164.0" }),
notify: () => {},
dispose: async () => {},
};
const started = await coordinator.start({
stateDirKey: "/codex/metadata",
spawn: createCodexAppServerChildProcess,
createClient: () => client,
initializeParams: {},
logger: createTestLogger(),
});
expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } });
});
test("serializes a burst through initialize but retains all initialized clients", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const starts = Array.from({ length: 24 }, () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async (method: string) => {
expect(method).toBe("initialize");
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
);
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const clients = await Promise.all(starts);
expect(clients).toHaveLength(24);
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(0);
});
test("allows different Codex homes to initialize concurrently", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const bothEntered = deferred();
const release = deferred();
let entered = 0;
const start = (stateDirKey: string) =>
coordinator.start({
stateDirKey,
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: async () => {
entered++;
if (entered === 2) bothEntered.resolve();
await release.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const first = start("/codex/one");
const second = start("/codex/two");
await bothEntered.promise;
expect(entered).toBe(2);
release.resolve();
await Promise.all([first, second]);
});
test("retries only SQLite startup failures after disposing the failed child", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const events: string[] = [];
let attempt = 0;
const client = await coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
attempt++;
events.push(`spawn ${attempt}`);
return createCodexAppServerChildProcess();
},
createClient: () => {
const number = attempt;
return {
request: async () => {
events.push(`initialize ${number}`);
if (number === 1) throw sqliteStartupError();
return {};
},
notify: () => events.push(`initialized ${number}`),
dispose: async () => {
events.push(`dispose ${number}`);
},
};
},
initializeParams: {},
logger: createTestLogger(),
});
expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) }));
expect(events).toEqual([
"spawn 1",
"initialize 1",
"dispose 1",
"spawn 2",
"initialize 2",
"initialized 2",
]);
});
test("does not retry unrelated startup failures", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
await expect(
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
throw new Error("database is locked while reading unrelated configuration");
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
).rejects.toThrow("database is locked while reading unrelated configuration");
expect(spawned).toBe(1);
expect(disposed).toBe(1);
});
test("bounds persistent SQLite failures and leaves the lane usable", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
let locked = true;
const start = () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
if (locked) throw sqliteStartupError();
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime");
expect(spawned).toBe(3);
expect(disposed).toBe(3);
locked = false;
await expect(start()).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(spawned).toBe(4);
expect(disposed).toBe(3);
});
test("a cancelled waiter never spawns or overtakes the active startup", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn first");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
firstEntered.resolve();
await releaseFirst.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const controller = new AbortController();
const queued = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn cancelled");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
signal: controller.signal,
});
controller.abort(new Error("cancelled"));
await expect(queued).rejects.toThrow("cancelled");
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
expect(events).toEqual(["spawn first"]);
releaseFirst.resolve();
await Promise.all([first, next]);
expect(events).toEqual(["spawn first", "spawn next"]);
});
test("a timed-out initialize disposes its child and releases the next waiter", async () => {
vi.useFakeTimers();
try {
const coordinator = new CodexAppServerStartupCoordinator({
attemptTimeoutMs: 10,
queueTimeoutMs: 100,
});
const firstEntered = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: () => {
firstEntered.resolve();
return new Promise<never>(() => {});
},
notify: vi.fn(),
dispose: async () => {
events.push("dispose first");
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server");
await vi.advanceTimersByTimeAsync(10);
await firstResult;
await expect(next).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(events).toEqual(["dispose first", "spawn next"]);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,189 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 3;
interface InitializableAppServerClient {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface StartupOptions<TClient extends InitializableAppServerClient> {
stateDirKey: string;
spawn: () => ChildProcessWithoutNullStreams | Promise<ChildProcessWithoutNullStreams>;
createClient: (child: ChildProcessWithoutNullStreams) => TClient;
initializeParams: unknown;
logger: Pick<Logger, "warn" | "debug">;
signal?: AbortSignal;
}
interface StartedAppServer<TClient> {
client: TClient;
initialized: unknown;
}
interface CoordinatorOptions {
queueTimeoutMs?: number;
attemptTimeoutMs?: number;
maxAttempts?: number;
}
async function waitForSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise<void> {
const result = await terminateWithTreeKill(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 1_000,
});
if (result === "kill-timeout") {
throw new Error("Codex app-server did not report exit after SIGKILL");
}
}
export class CodexAppServerStartupCoordinator {
private readonly tails = new Map<string, Promise<void>>();
private readonly queueTimeoutMs: number;
private readonly attemptTimeoutMs: number;
private readonly maxAttempts: number;
constructor(options: CoordinatorOptions = {}) {
this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
async start<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
options.signal?.throwIfAborted();
const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
// An aborted waiter must not let later waiters pass the current owner.
const tail = previous.then(() => gate);
this.tails.set(options.stateDirKey, tail);
void tail.then(
() => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey),
);
const queueAbort = new AbortController();
const onAbort = () => queueAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const queueTimer = setTimeout(
() => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")),
this.queueTimeoutMs,
);
try {
await waitForSignal(previous, queueAbort.signal);
clearTimeout(queueTimer);
options.signal?.throwIfAborted();
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
return await this.startAttempt(options);
} catch (error) {
const isRetryableStartupFailure =
error instanceof CodexAppServerExitError &&
error.reason === "sqlite_state_initialization";
if (
options.signal?.aborted ||
attempt === this.maxAttempts ||
!isRetryableStartupFailure
) {
throw error;
}
options.logger.warn(
{ err: error, attempt, maxAttempts: this.maxAttempts },
"Codex app-server SQLite startup failed; retrying with a fresh process",
);
}
}
throw new Error("Codex app-server startup attempts exhausted");
} finally {
clearTimeout(queueTimer);
options.signal?.removeEventListener("abort", onAbort);
release();
}
}
private async startAttempt<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
const attemptAbort = new AbortController();
const onAbort = () => attemptAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const attemptTimer = setTimeout(
() => attemptAbort.abort(new Error("Timed out initializing Codex app-server")),
this.attemptTimeoutMs,
);
let child: ChildProcessWithoutNullStreams | undefined;
let client: TClient | undefined;
try {
attemptAbort.signal.throwIfAborted();
// Production hands in a synchronous spawn closure. Resolve the binary
// before entering the lane, then create the child while holding it.
const spawnPromise = Promise.resolve(options.spawn());
try {
child = await waitForSignal(spawnPromise, attemptAbort.signal);
} catch (error) {
if (attemptAbort.signal.aborted) {
// A future asynchronous spawner may finish after cancellation.
void spawnPromise
.then((lateChild) => disposeUnownedChild(lateChild))
.catch((cleanupError) =>
options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"),
);
}
throw error;
}
attemptAbort.signal.throwIfAborted();
client = options.createClient(child);
const initialized = await waitForSignal(
client.request("initialize", options.initializeParams, this.attemptTimeoutMs),
attemptAbort.signal,
);
attemptAbort.signal.throwIfAborted();
client.notify("initialized", {});
return { client, initialized };
} catch (error) {
try {
if (client) {
await client.dispose();
} else if (child) {
await disposeUnownedChild(child);
}
} catch (cleanupError) {
options.logger.warn(
{ err: cleanupError, startupError: error },
"Failed to clean up Codex app-server after startup failure",
);
throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError });
}
throw error;
} finally {
clearTimeout(attemptTimer);
options.signal?.removeEventListener("abort", onAbort);
}
}
}
export const codexAppServerStartup = new CodexAppServerStartupCoordinator();
@@ -5,9 +5,57 @@ import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
} from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js";
describe("Codex app-server transport", () => {
test("classifies startup diagnostics delivered after the child exits", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
const result = expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: "sqlite_state_initialization",
stderr: "failed to initialize sqlite state runtime",
});
child.exitCode = 1;
child.emit("exit", 1, null);
child.stderr.write("failed to initialize sqlite state runtime");
child.emit("close", 1, null);
try {
await result;
} finally {
child.stdout.end();
child.stderr.end();
child.stdin.end();
}
});
test.each([
{
stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex",
reason: "sqlite_state_initialization",
},
{ stderr: "database is locked while loading configuration", reason: "other" },
] as const)("classifies a failed startup from child stderr as $reason", async (entry) => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
child.stderr.write(entry.stderr);
child.exitCode = 1;
child.emit("exit", 1, null);
await expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: entry.reason,
exitCode: 1,
exitSignal: null,
stderr: entry.stderr,
} satisfies Partial<CodexAppServerExitError>);
child.stdout.end();
child.stderr.end();
child.stdin.end();
});
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
@@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error {
}
}
interface CodexAppServerExitDetails {
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
stderr: string;
}
export class CodexAppServerExitError extends Error {
readonly reason: "sqlite_state_initialization" | "other";
readonly exitCode: number | null;
readonly exitSignal: NodeJS.Signals | null;
readonly stderr: string;
constructor(details: CodexAppServerExitDetails) {
const message =
details.exitCode === 0 && !details.exitSignal
? "Codex app-server exited"
: `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`;
super(`${message}\n${details.stderr}`.trim());
this.name = "CodexAppServerExitError";
this.exitCode = details.exitCode;
this.exitSignal = details.exitSignal;
this.stderr = details.stderr;
// Codex reports this pre-initialize failure only on stderr, not as a
// JSON-RPC error. Classify it here so callers branch on a typed reason.
this.reason =
details.exitCode === 1 &&
!details.exitSignal &&
details.stderr.includes("failed to initialize sqlite state runtime")
? "sqlite_state_initialization"
: "other";
}
}
type RequestHandler = (params: unknown, requestId: number) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
type UnexpectedTerminationHandler = (error: Error) => void;
@@ -234,14 +267,24 @@ export class CodexAppServerClient {
this.handleUnexpectedTermination(err);
});
const reportExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.handleUnexpectedTermination(
new CodexAppServerExitError({
exitCode: code,
exitSignal: signal,
stderr: this.stderrBuffer,
}),
);
};
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
this.handleUnexpectedTermination(error);
// Startup retries need complete diagnostics. Node drains the child's
// stdio before close, but exit can precede the final stderr data.
const initializing = Array.from(this.pending.values()).some(
(request) => request.method === "initialize",
);
if (!initializing) reportExit(code, signal);
});
child.on("close", reportExit);
}
setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void {
@@ -146,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
// A real ChildProcess closes its stdio after exit, then emits close.
child.once("exit", (code, signal) => {
queueMicrotask(() => child.emit("close", code, signal));
});
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
@@ -1,4 +1,3 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -27,15 +26,24 @@ async function openCodexThread(
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const provider = new CodexAppServerAgentClient(
createTestLogger(),
{
command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] },
},
{
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => appServer.child,
},
},
);
const internals = provider as unknown as {
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
};
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
const session = await provider.resumeSession({ sessionId: "saved-thread" });
return { appServer, session };
}
@@ -60,6 +60,7 @@ test.each(["existing", "new"])(
await Promise.all(removed.map((path) => rm(path)));
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
for (const path of removed) notifications.emit("change", "change", path);
// The file-only event uses the eight-second optional audit debounce.
await vi.advanceTimersByTimeAsync(8_000);
vi.useRealTimers();
await expect
@@ -309,79 +310,98 @@ test("a classification shed onto the scoped audit still recovers the file", asyn
// re-link `directory` itself back into its parent -- walkSubtree only
// descends through that link, so without the re-link a later ancestor
// removal can no longer reach anything below the reconciled directory.
test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => {
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
const paths = createObserverPaths(process.platform);
const deepDirectory = join(root, "a", "b", "c");
const deepFile = join(deepDirectory, "deep.txt");
await mkdir(deepDirectory, { recursive: true });
await writeFile(deepFile, "content");
const events: FileChange[] = [];
const notifications = new EventEmitter();
const observer = createFileObserver();
let active = true;
const backend = createNativeRecursiveBackend(
{
root,
metrics: observer.getDiagnostics(),
isActive: () => active,
isIgnored: () => false,
isPathInside: paths.isInside,
queueEvent: (type, path) => events.push({ type, path }),
fail: (error) => {
throw error;
},
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => notifications.removeAllListeners(),
on: (event, onError) => notifications.on(event, onError),
};
},
);
const settled = () =>
expect
.poll(
() => {
const diagnostics = backend.getDiagnostics();
return (
diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight
);
test.each([
{ notification: "ancestor", deletedPath: ["a"] },
{ notification: "file", deletedPath: ["a", "b", "c", "deep.txt"] },
])(
"a recursive-scope reconcile preserves nested deletions announced by $notification",
async ({ deletedPath }) => {
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
const paths = createObserverPaths(process.platform);
const deepDirectory = join(root, "a", "b", "c");
const deepFile = join(deepDirectory, "deep.txt");
await mkdir(deepDirectory, { recursive: true });
await writeFile(deepFile, "content");
const events: FileChange[] = [];
const notifications = new EventEmitter();
const observer = createFileObserver();
let active = true;
const backend = createNativeRecursiveBackend(
{
root,
metrics: observer.getDiagnostics(),
isActive: () => active,
isIgnored: () => false,
isPathInside: paths.isInside,
queueEvent: (type, path) => events.push({ type, path }),
fail: (error) => {
throw error;
},
{ timeout: 10_000 },
)
.toBe(true);
try {
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
// A rename event landing on the already-known directory a/b produces a
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
// still a directory, a recursive scope on a/b itself -- the same "known
// directory changes in place" shape the directory-churn benchmark
// exercises via atomic replace.
notifications.emit("change", "rename", join(root, "a", "b"));
await settled();
// Remove the ancestor while the native event names only a deleted file.
// The scoped scan must report its missing directory as well as that file.
await rm(join(root, "a"), { recursive: true, force: true });
notifications.emit("change", "rename", deepFile);
await settled();
expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain(
deepFile,
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => notifications.removeAllListeners(),
on: (event, onError) => notifications.on(event, onError),
};
},
);
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
} finally {
active = false;
await backend.close();
await observer.close();
await rm(root, { recursive: true, force: true });
}
}, 15_000);
const settled = async () => {
// Classification uses real filesystem I/O and can request another audit.
// Drain it before advancing the debounce clock so no later classification
// leaves a fresh timer behind.
await expect
.poll(() => backend.getDiagnostics().pendingClassificationCount, { timeout: 10_000 })
.toBe(0);
// The file-only event uses the eight-second optional audit debounce.
await vi.advanceTimersByTimeAsync(8_000);
await expect
.poll(
() => {
const diagnostics = backend.getDiagnostics();
return (
diagnostics.pendingReconciliationWorkCount === 0 &&
!diagnostics.reconciliationInFlight
);
},
{ timeout: 10_000 },
)
.toBe(true);
};
try {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
// A rename event landing on the already-known directory a/b produces a
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
// still a directory, a recursive scope on a/b itself -- the same "known
// directory changes in place" shape the directory-churn benchmark
// exercises via atomic replace.
notifications.emit("change", "rename", join(root, "a", "b"));
await settled();
// An ancestor notification must walk the re-linked subtree. A file-only
// notification must also recover the missing directory from its scoped scan.
await rm(join(root, "a"), { recursive: true, force: true });
notifications.emit("change", "rename", join(root, ...deletedPath));
await settled();
expect(
events.filter((event) => event.type === "delete").map((event) => event.path),
).toContain(deepFile);
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
} finally {
vi.useRealTimers();
active = false;
await backend.close();
await observer.close();
await rm(root, { recursive: true, force: true });
}
},
15_000,
);
test("a full native inventory audit reports a removed directory", async () => {
const root = await mkdtemp(join(tmpdir(), "native-full-audit-"));