diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7864b90e..639c967e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -134,6 +134,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins - `~/.paperclip/instances/default/companies//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 When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts new file mode 100644 index 00000000..a2349d84 --- /dev/null +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -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(); + return { + ...cp, + spawn: (...args: Parameters) => mockSpawn(...args) as ReturnType, + }; +}); + +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"); + }); +}); diff --git a/packages/adapters/codex-local/vitest.config.ts b/packages/adapters/codex-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/codex-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9bf83928..3ec05081 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ 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", + ], }, });