test(codex-local): regression for CodexRpcClient spawn ENOENT

Add a Vitest case that mocks `node:child_process.spawn` so the child
emits `error` (ENOENT) after the constructor attaches listeners.
`getQuotaWindows()` must resolve with `ok: false` instead of leaving an
unhandled `error` event on the process.

Register `packages/adapters/codex-local` in the root Vitest workspace.

Document in DEVELOPING.md that a missing `codex` binary should not take
down the API server during quota polling.
This commit is contained in:
Mikhail Batukhtin 2026-03-29 14:41:25 +03:00
parent 01fb97e8da
commit c98af52590
4 changed files with 74 additions and 1 deletions

View file

@ -134,6 +134,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins
- `~/.paperclip/instances/default/companies/<company-id>/codex-home` - `~/.paperclip/instances/default/companies/<company-id>/codex-home`
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
## Worktree-local Instances ## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.

View file

@ -0,0 +1,57 @@
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import { describe, expect, it, vi, beforeEach } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const cp = await importOriginal<typeof import("node:child_process")>();
return {
...cp,
spawn: (...args: Parameters<typeof cp.spawn>) => mockSpawn(...args) as ReturnType<typeof cp.spawn>,
};
});
import { getQuotaWindows } from "./quota.js";
function createChildThatErrorsOnMicrotask(err: Error): ChildProcess {
const child = new EventEmitter() as ChildProcess;
const stream = Object.assign(new EventEmitter(), {
setEncoding: () => {},
});
Object.assign(child, {
stdout: stream,
stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }),
stdin: { write: vi.fn(), end: vi.fn() },
kill: vi.fn(),
});
queueMicrotask(() => {
child.emit("error", err);
});
return child;
}
describe("CodexRpcClient spawn failures", () => {
beforeEach(() => {
mockSpawn.mockReset();
});
it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => {
const enoent = Object.assign(new Error("spawn codex ENOENT"), {
code: "ENOENT",
errno: -2,
syscall: "spawn codex",
path: "codex",
});
mockSpawn.mockImplementation(() => createChildThatErrorsOnMicrotask(enoent));
const result = await getQuotaWindows();
expect(result.ok).toBe(false);
expect(result.windows).toEqual([]);
expect(result.error).toContain("Codex app-server");
expect(result.error).toContain("spawn codex ENOENT");
});
});

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View file

@ -2,6 +2,13 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"], projects: [
"packages/db",
"packages/adapters/codex-local",
"packages/adapters/opencode-local",
"server",
"ui",
"cli",
],
}, },
}); });