From 1a81c206eb249f7c849f8718ba7b7b700e795233 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 18:16:12 -0700 Subject: [PATCH 01/39] Check the planning agent at startup and before planning requests (#164) PlanningAgent.probe runs one planning invocation in planning's worker and profile, asking for the schema's own answer. PlanningCheck starts it when the server starts and again when the vendor in Settings changes; productionPlanning waits for it in `describe`, before the issue is read or anything is recorded. A failed check refuses the request with "Planning is unavailable: " for a minute, after which the next request checks again. Shutdown cancels a running check before it closes the worker. Closes #164 Co-Authored-By: Claude Opus 5.5 --- docs/implementation/plan-library-alignment.md | 2 +- docs/implementation/planning-acceptance.md | 18 +++ runner/planning.ts | 120 +++++++++++++++- test/fixtures/question-worker-stub.ts | 5 + test/planning-production.test.ts | 131 +++++++++++++++++- test/planning-worker.test.ts | 24 +++- web/planning.ts | 54 +++++--- 7 files changed, 327 insertions(+), 27 deletions(-) diff --git a/docs/implementation/plan-library-alignment.md b/docs/implementation/plan-library-alignment.md index 7e76cac8..336821ae 100644 --- a/docs/implementation/plan-library-alignment.md +++ b/docs/implementation/plan-library-alignment.md @@ -24,7 +24,7 @@ Issue #6 tracked the v1 plan library contract and the runner obligations that ca | Output limits at the exact byte | **This change** (tests; the supervisor already enforced the limits) | Supervisor enforcement: `test/agent-supervisor.test.ts` (real Docker). Decoder behaviour only, not a limit: the Claude decoder handles an envelope of exactly the stdout ceiling, and the 1 MiB parser limit applies to the decoded answer at the exact byte: `test/agent-adapter.test.ts`. At #6 the decoder re-serialized the answer, so the limit applied to the re-encoded text. *Later:* #166 returns the exact text of `structured_output` in the CLI's envelope, so the limit applies to those bytes | | Prompt byte budget before spawn | Done before this change | `MAX_PROMPT_BYTES` in `core/planning-author.ts`; `test/planning-author.test.ts` ("accepts exactly the prompt byte limit and rejects the next byte without truncation") | | Model budget before spawn | Moved to #163 | — | -| Planning profile probed at startup | Moved to #164 | — | +| Planning profile probed at startup | Moved to #164. *Later:* fixed in #164, checked at startup and before the first request of each vendor | `runner/planning.ts` (`PlanningAgent.probe`, `PlanningCheck`), `web/planning.ts`; `test/planning-production.test.ts` ("the planning check (#164)"), `test/planning-worker.test.ts`; `docs/implementation/planning-acceptance.md` | | Docker daemon log limits | Moved to #165 | — | | Claude structured output under the v1 parsing contract | Moved to #166 (found during this change). *Later:* fixed in #166 | `agents/adapters/claude.ts`; `test/agent-adapter.test.ts` ("lets draft validation refuse %s in a structured answer") | diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 37eabaa6..98b87877 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -82,6 +82,24 @@ suggestion timer and the request share one ten-minute budget. The recording script still runs the provider in its own process and prints its runner label if it leaves anything. +The planning check (#164) proves that the vendor chosen in Settings can run a +planning invocation before any request does. It is one planning job in the same +worker and profile as a real request (read, list and search only, vendor-only +egress, a mounted schema), on the review's code at the snapshot's head. It asks +for the schema's own answer, `{"ok": true}`, within a two-minute budget, so it +covers sign-in, the schema-constrained answer and the read-only profile +(`PlanningAgent.probe`). `PlanningCheck` starts it when the server starts, and +again before the first request after the vendor changes. `productionPlanning`'s +`describe` waits for it before it reads the issue or records anything. A failed +check refuses the request with a `GuardRefusal` that starts "Planning is +unavailable:" and names the reason; the server records that refusal and starts no +agent. Requests within a minute of the failure are refused with it, and the next +request after that checks again. With no vendor chosen nothing is checked, and +the request is refused later, as before. Shutdown cancels a running check and waits +up to 20 s for it before closing the worker. Tests: `test/planning-production.test.ts` +("the planning check (#164)", with a check that passes and one that fails) and +`test/planning-worker.test.ts` (the check in planning's worker). + The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now composes the browser with production planning setup, lane D's provider boundary, diff --git a/runner/planning.ts b/runner/planning.ts index d6fe00af..e83b08ec 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -1,5 +1,7 @@ +import { randomUUID } from 'node:crypto'; import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts'; -import { AgentWorker } from './question-agent.ts'; +import { GuardRefusal } from './lifecycle.ts'; +import { AgentWorker, type Provider } from './question-agent.ts'; import { LeftoverLedger, PLANNING_NAMING } from './question-leftovers.ts'; import { PLANNING_BUDGET_MS, planningRun } from './planning-provider.ts'; import type { ReviewService } from './review.ts'; @@ -8,6 +10,25 @@ import type { ReviewService } from './review.ts'; const SETTLE_MARGIN_MS = 5_000; export const NO_PLANNING_AGENT = 'Choose an agent in Settings, then retry.'; +/** + * The planning check (#164): one planning invocation in the profile a real request gets (lane D's "planning" phase: + * read, list and search only, vendor-only egress, the answer constrained by a mounted schema), on the review's code at + * the current snapshot's head. It passes when the agent signs in and returns exactly this schema's answer. + */ +export const PLANNING_PROBE_PROMPT = 'This is codeboost\'s check that planning works. Read no files. ' + + 'Answer with the JSON object {"ok": true} and nothing else.'; +export const PLANNING_PROBE_SCHEMA = JSON.stringify({ type: 'object', properties: { ok: { const: true } }, + required: ['ok'], additionalProperties: false }); +/** Whether a planning answer is exactly the check's: the object `{"ok": true}`, as JSON. */ +export function isPlanningProbeAnswer(text: string): boolean { + let answer: unknown; + try { answer = JSON.parse(text); } catch { return false; } + return typeof answer === 'object' && answer !== null && !Array.isArray(answer) && Object.keys(answer).length === 1 + && (answer as { ok?: unknown }).ok === true; +} +/** The whole budget of one check, setup included. Lane D caps the invocation inside it. */ +export const PLANNING_PROBE_BUDGET_MS = 2 * 60_000; + /** * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous * setup never blocks the server. The worker's TMPDIR is a planning root recorded in a ledger beside the review @@ -39,6 +60,103 @@ export class PlanningAgent implements AuthorProvider { snapshotId: snapshot.id }, Date.now() + PLANNING_BUDGET_MS - SETTLE_MARGIN_MS); return this.#worker.plan(run, signal); } + /** + * Run the planning check (#164) with `vendor`, in this provider's worker. Rejects with the reason when the agent + * cannot run a planning invocation (no sign-in, Codex, Docker, a refused or wrong answer); resolves when it can. + */ + async probe(vendor: Provider, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const store = this.#service.store, identity = this.#service.config.identity; + const plan = store.getPlan(identity), snapshot = store.getSnapshot(identity), requestId = randomUUID(); + const run = planningRun({ mode: 'suggest', phase: 'planning', access: 'read-only', identity, requestId, issue: plan.issue, + revision: plan.revision, prompt: PLANNING_PROBE_PROMPT, schemaText: PLANNING_PROBE_SCHEMA }, + { vendor, repository: this.#service.reviewRepository().path, head: snapshot.head, snapshotId: snapshot.id }, + Date.now() + PLANNING_PROBE_BUDGET_MS - SETTLE_MARGIN_MS); + const text = await this.#worker.plan({ ...run, taskId: `planning-probe-${requestId}` }, signal); + if (!isPlanningProbeAnswer(text)) throw new Error('The planning agent did not give the check\'s answer.'); + } /** Call only after every request has settled (E3's close). Releases what the worker still owns, then stops it. */ close(): Promise { return this.#worker.close(); } } + +/** Starts with this text when planning is refused because its check failed. */ +export const PLANNING_UNAVAILABLE = 'Planning is unavailable:'; +/** How long a failed check refuses requests before the next request checks again. */ +export const PLANNING_RECHECK_MS = 60_000; +/** How long close waits for a check that is still running after it is cancelled. */ +export const PLANNING_CHECK_CLOSE_MS = 20_000; + +/** One run of the planning check, for one vendor. `failure` is set once it failed. */ +type Check = { vendor: Provider; done: Promise; failure?: { error: unknown; at: number } }; +/** + * Admission for planning requests (#164). The planning check runs once per process for each vendor chosen in Settings: + * `start` begins it at startup, and `admit`, called before anything about a request is recorded, waits for it. A + * request is refused while the check that covers it has failed: for `recheckMs` after the failure, after which the next + * request checks again. A changed vendor is checked before its first request. With no vendor chosen nothing is + * checked, and the request is refused later, as before, with `NO_PLANNING_AGENT`. + */ +export class PlanningCheck { + readonly #probe: (vendor: Provider, signal: AbortSignal) => Promise; + readonly #vendor: () => Provider | null; + readonly #recheckMs: number; + readonly #abort = new AbortController(); + readonly #running = new Set>(); + #current?: Check; + constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, + options: { recheckMs?: number } = {}) { + this.#probe = probe; this.#vendor = vendor; this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; + } + /** Begin the check for the chosen vendor, if any, without waiting for it. */ + start(): void { + const vendor = this.#vendor(); + if (vendor) this.#check(vendor); + } + // The check that covers `vendor` now: the current one, unless it was for another vendor, or failed `recheckMs` ago. + #check(vendor: Provider) { + const current = this.#current; + if (current && current.vendor === vendor + && !(current.failure && performance.now() - current.failure.at >= this.#recheckMs)) return current; + if (this.#abort.signal.aborted) throw new GuardRefusal('Planning is stopping.'); + const check: Check = { vendor, done: Promise.resolve() }; + // Never rejects: a failure is kept on the check, so every waiting request is refused with it. + const done = this.#probe(vendor, this.#abort.signal).then(() => undefined, (error: unknown) => { + check.failure = { error, at: performance.now() }; + }); + check.done = done; + this.#running.add(done); + void done.finally(() => this.#running.delete(done)); + this.#current = check; + return check; + } + /** + * Resolve once the check for the vendor chosen now has passed; reject with a `GuardRefusal` naming why when it + * failed. `signal` stops only this wait, never the check, which other requests may share. + */ + async admit(signal: AbortSignal): Promise { + for (;;) { + const vendor = this.#vendor(); + if (!vendor) return; + const check = this.#check(vendor); + let release!: () => void; + const aborted = new Promise((_, reject) => { + release = () => reject(signal.reason); signal.addEventListener('abort', release, { once: true }); + }); + if (signal.aborted) release(); + try { await Promise.race([check.done, aborted]); } + finally { signal.removeEventListener('abort', release); } + // Settings changed while this request waited: check the vendor it will run with. + if (this.#vendor() !== vendor) continue; + if (!check.failure) return; + const reason = check.failure.error instanceof Error ? check.failure.error.message : String(check.failure.error); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ${reason} ` + + 'Planning stays off until a check passes; retry in a minute.'); + } + } + /** Cancel any running check and wait up to `waitMs` for it to stop. No check starts afterwards. */ + async close(waitMs = PLANNING_CHECK_CLOSE_MS): Promise { + this.#abort.abort(new Error('Planning stopped.')); + let timer: ReturnType | undefined; + await Promise.race([Promise.all(this.#running), new Promise(done => { timer = setTimeout(done, waitMs); })]); + clearTimeout(timer); + } +} diff --git a/test/fixtures/question-worker-stub.ts b/test/fixtures/question-worker-stub.ts index 386f27d6..e0600345 100644 --- a/test/fixtures/question-worker-stub.ts +++ b/test/fixtures/question-worker-stub.ts @@ -41,6 +41,11 @@ parentPort!.on('message', (message: WorkerRequest) => { // A planning job carries a ReadOnlyRun; the stub reads the same fields from either kind (#117). const job = message.type === 'ask' ? message.question : { ...message.run, noteId: message.run.taskId }; const { prompt, provider, noteId, attemptId } = job; + // The planning check (#164) passes with the schema's own answer. + if (message.type === 'plan' && message.run.taskId.startsWith('planning-probe-')) { + parentPort!.postMessage({ id: message.id, attemptId, ok: true, text: '{"ok":true}' }); + return; + } if (prompt === 'crash') throw new Error('stub crashed'); if (prompt === 'leak') { remaining++; diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index eec89019..90eb9b40 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts'; import { ISSUE_READ_ACTIVE_MS, type IssueAccess, type IssueText } from '../github/issues.ts'; import { createDemo } from '../scripts/demo.ts'; @@ -13,6 +13,7 @@ import { DatabaseSync } from 'node:sqlite'; import { PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; +import { PLANNING_UNAVAILABLE } from '../runner/planning.ts'; // Production planning wiring (#117): when it is on, what each request is told, and how the server awaits the issue. vi.setConfig({ testTimeout: 20_000 }); @@ -34,7 +35,9 @@ const text = (number: number): IssueText => ({ number, title: 'Retries ignore th const issues = (issueText: (number: number, options?: { trustedAuthor?: string | null; expectedAccess?: IssueAccess }) => Promise, collaborator = true) => ({ issueAccess: async (number: number) => ({ number, authorLogin: 'outside', collaborator }), issueText, }); -const closable = (close = vi.fn(async () => undefined)) => ({ close, invoke: vi.fn() }) as unknown as PlanningAgent; +const closable = (close = vi.fn(async () => undefined), + probe: (vendor: string, signal: AbortSignal) => Promise = vi.fn(async () => undefined)) => + ({ close, invoke: vi.fn(), probe }) as unknown as PlanningAgent; const verified = { verifyLock: () => undefined }; it('is off in a demo and without a github block, so neither plans', () => { @@ -310,3 +313,127 @@ it('ends a planning request that does not settle after shutdown aborts it by clo expect(order).toEqual(['planning closed', 'server closed']); } finally { vi.useRealTimers(); } }); + +// #164: the planning check, through productionPlanning's setup with a stand-in agent whose `probe` is the check. +describe('the planning check (#164)', () => { + /** The production setup on a review whose Settings chose `vendor`, with a stand-in agent and issue reads. */ + function checked(probe: (vendor: string, signal: AbortSignal) => Promise, vendor: 'claude' | 'codex' | null = 'claude', + recheckMs?: number) { + const config = production(), service = new ReviewService(config); closers.push(() => service.close()); + if (vendor) service.store.setQuestionProvider(vendor); + const issueAccess = vi.fn(async (number: number) => ({ number, authorLogin: 'outside', collaborator: true })); + const issueText = vi.fn(async (number: number) => text(number)); + const close = vi.fn(async () => undefined), agent = closable(close, vi.fn(probe)); + const deps = productionPlanning(config, { ...verified, issues: { issueAccess, issueText }, agent: () => agent, recheckMs })!(service); + return { deps, service, agent, issueAccess, close, probe: vi.mocked(agent.probe) }; + } + const signal = () => new AbortController().signal; + + it('starts at setup with the vendor chosen in Settings, and runs once for every request after', async () => { + const { deps, probe, issueAccess } = checked(async () => undefined); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0]![0]).toBe('claude'); + await deps.describe(signal()); + await deps.describe(signal()); + expect(probe).toHaveBeenCalledTimes(1); + expect(issueAccess).toHaveBeenCalledTimes(2); + }); + + it('refuses a request with the check\'s reason, before it reads the issue, and checks again only after the wait', async () => { + const { deps, probe, issueAccess } = checked(async () => { throw new Error('Claude could not sign in.'); }); + const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(GuardRefusal); + expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*Claude could not sign in\\. `)); + // Inside the wait, the same failure refuses at once. + await expect(deps.describe(signal())).rejects.toThrow('Claude could not sign in.'); + expect(probe).toHaveBeenCalledTimes(1); + expect(issueAccess).not.toHaveBeenCalled(); + }); + + it('checks again once the wait after a failure is over, and admits once a check passes', async () => { + let fail = true; + const { deps, probe, issueAccess } = checked(async () => { if (fail) throw new Error('Docker is not running.'); }, 'claude', 0); + await expect(deps.describe(signal())).rejects.toThrow('Docker is not running.'); + fail = false; + await expect(deps.describe(signal())).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); + expect(probe).toHaveBeenCalledTimes(2); + // A passed check is kept: the wait applies only to a failure. + fail = true; + await deps.describe(signal()); + expect(probe).toHaveBeenCalledTimes(2); + expect(issueAccess).toHaveBeenCalledTimes(2); + }); + + it('makes every request wait for the one running check, and a request that stops waiting leaves it running', async () => { + let pass!: () => void; + const { deps, probe } = checked((_vendor, probeSignal) => new Promise((resolve, reject) => { + pass = resolve; probeSignal.addEventListener('abort', () => reject(probeSignal.reason), { once: true }); + })); + const leaving = new AbortController(), left = deps.describe(leaving.signal), waiting = deps.describe(signal()); + leaving.abort(new Error('The browser went away.')); + await expect(left).rejects.toThrow('The browser went away.'); + pass(); + await expect(waiting).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0]![1].aborted).toBe(false); + }); + + it('checks nothing while no agent is chosen, then checks the vendor chosen before its first request', async () => { + const { deps, service, probe, issueAccess } = checked(async () => undefined, null); + expect(probe).not.toHaveBeenCalled(); + // No vendor: the request goes on, and the agent refuses it later with NO_PLANNING_AGENT, as before. + await deps.describe(signal()); + expect(probe).not.toHaveBeenCalled(); + service.store.setQuestionProvider('claude'); + await deps.describe(signal()); + // Settings refuse Codex now, but a database from before #75 may still hold it. + vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); + await deps.describe(signal()); + expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); + expect(issueAccess).toHaveBeenCalledTimes(3); + }); + + it('checks the vendor chosen while a request waited, before it admits the request', async () => { + let pass!: () => void; + const { deps, service, probe } = checked(vendor => vendor === 'claude' ? new Promise(resolve => { pass = resolve; }) + : Promise.reject(new Error('Codex cannot write plans yet.'))); + const waiting = Promise.resolve(deps.describe(signal())); + vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); + pass(); + await expect(waiting).rejects.toThrow('Codex cannot write plans yet.'); + expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); + }); + + it('cancels a running check on close, before it closes the agent', async () => { + const order: string[] = []; + const { deps, probe, close } = checked((_vendor, probeSignal) => new Promise((_, reject) => + probeSignal.addEventListener('abort', () => { order.push('check stopped'); reject(probeSignal.reason); }, { once: true }))); + close.mockImplementation(async () => { order.push('agent closed'); }); + await deps.close!(); + expect(probe.mock.calls[0]![1].aborted).toBe(true); + expect(order).toEqual(['check stopped', 'agent closed']); + }); + + it('refuses a start through the server with 409, recording no request and starting no agent', async () => { + const config = production(), probe = vi.fn(async () => { throw new Error('Claude could not sign in.'); }); + const service = new ReviewService(config); service.store.setQuestionProvider('claude'); service.close(); + const agent = closable(undefined, probe), issueAccess = vi.fn(); + const setup = productionPlanning(config, { ...verified, issues: { issueAccess, issueText: vi.fn() }, agent: () => agent })!; + const app = await startServer(config, 0, async () => 'answer', undefined, 2_000, undefined, undefined, setup); + closers.push(() => app.close()); + const api = async (method: string, path: string, body?: unknown) => { + const response = await fetch(`${new URL(app.url).origin}${path}`, { method, headers: { 'x-codeboost-token': app.token, + ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }); + return { status: response.status, body: await response.json() as Record }; + }; + const view = (await api('GET', '/api/review')).body; + const started = await api('POST', '/api/plan/suggestions', { expectedRevision: view.plan.revision, + snapshotId: view.snapshot.id, feedback: '', actionId: randomUUID() }); + expect(started.status).toBe(409); + expect(started.body.error).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*Claude could not sign in\\.`)); + const db = new DatabaseSync(config.database); + try { expect((db.prepare('SELECT COUNT(*) AS n FROM requests').get() as { n: number }).n).toBe(0); } finally { db.close(); } + expect(agent.invoke).not.toHaveBeenCalled(); + expect(issueAccess).not.toHaveBeenCalled(); + }); +}); diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index 1cb6b9e0..f52572fb 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -9,7 +9,7 @@ import { createDemo } from '../scripts/demo.ts'; import { AgentWorker } from '../runner/question-agent.ts'; import { ASK_NAMING, createWorkerRoot, LeftoverLedger, PLANNING_NAMING } from '../runner/question-leftovers.ts'; import { CODEX_PLANNING_REFUSED, PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; -import { NO_PLANNING_AGENT, PlanningAgent } from '../runner/planning.ts'; +import { isPlanningProbeAnswer, NO_PLANNING_AGENT, PlanningAgent } from '../runner/planning.ts'; import { ReviewService } from '../runner/review.ts'; // Planning's worker, ledger and owner (#117), driven through the stub worker, which reports what reached it. @@ -155,3 +155,25 @@ it.each([ await expect(agent(service).invoke(request(service), new AbortController().signal)).rejects.toThrow(message); expect(acquire).not.toHaveBeenCalled(); }); + +// #164: the planning check runs as a planning job in planning's own worker, and passes only with the schema's answer. +it('runs the planning check as a planning job in planning\'s worker, and passes with the schema\'s answer', async () => { + const service = review(); + await expect(agent(service).probe('claude', new AbortController().signal)).resolves.toBeUndefined(); +}); + +it('fails the planning check without a sign-in or with Codex, before any lock, worker or Docker work', async () => { + const service = review(), acquire = vi.spyOn(LeftoverLedger.prototype, 'acquire'); + const unsigned = new PlanningAgent(service, { url: STUB, env: {} }); + closers.push(() => unsigned.close()); + await expect(unsigned.probe('claude', new AbortController().signal)).rejects.toThrow('CLAUDE_CODE_OAUTH_TOKEN'); + await expect(agent(service).probe('codex', new AbortController().signal)).rejects.toThrow(CODEX_PLANNING_REFUSED); + expect(acquire).not.toHaveBeenCalled(); +}); + +it('passes the planning check only with exactly the schema\'s answer', () => { + expect(isPlanningProbeAnswer('{"ok":true}')).toBe(true); + expect(isPlanningProbeAnswer(' { "ok": true }\n')).toBe(true); + for (const text of ['', 'ok', 'true', '{"ok":false}', '{"ok":"true"}', '{"ok":true,"extra":1}', '[{"ok":true}]', 'null', '{}']) + expect(isPlanningProbeAnswer(text)).toBe(false); +}); diff --git a/web/planning.ts b/web/planning.ts index 3385eb3f..0e45412c 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -1,6 +1,6 @@ import { GhIssueGateway, ISSUE_READ_TIMEOUT_MS, withIssueReadDeadline, type IssueTrustGateway } from '../github/issues.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; -import { PlanningAgent } from '../runner/planning.ts'; +import { PlanningAgent, PlanningCheck } from '../runner/planning.ts'; import type { ReviewConfig, ReviewService } from '../runner/review.ts'; import type { PlanningSetup } from './server.ts'; @@ -10,13 +10,16 @@ export { ISSUE_READ_TIMEOUT_MS } from '../github/issues.ts'; * Production planning (#117): on for a review with a github block, never in a demo. It needs no runner block, since * planning only reads code, as Ask does. The issue text is read from GitHub for each request (current collaborators' * comments, or every comment under explicit author-bound trust), bounded like an execute prompt; approved lessons stay - * empty until lessons exist (L1 to L4). + * empty until lessons exist (L1 to L4). The planning check (#164) starts with the server and must pass before a request + * reads the issue or records anything; a failed check refuses the request with its reason. */ export function productionPlanning(config: ReviewConfig, options: { /** The single-runner lock's check that the database path still names the locked file (runner/recovery.ts). */ verifyLock: () => void; issues?: Pick; agent?: (service: ReviewService) => PlanningAgent; + /** How long a failed planning check refuses requests; `PLANNING_RECHECK_MS` by default. */ + recheckMs?: number; }): PlanningSetup | undefined { const github = config.github; if (config.demo || !github) return undefined; @@ -28,28 +31,35 @@ export function productionPlanning(config: ReviewConfig, options: { options.verifyLock(); service.store.settleInterruptedRequests(); const provider = options.agent?.(service) ?? new PlanningAgent(service); + const check = new PlanningCheck((vendor, signal) => provider.probe(vendor, signal), () => service.store.questionProvider(), + { recheckMs: options.recheckMs }); + check.start(); return { provider, - describe(signal) { return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { - const access = await issues.issueAccess(github.issue, { signal: readSignal, timeoutMs }); - const trust = service.store.issueTrust(github.repository, github.issue); - const explicitlyTrusted = trust?.revokedAt === null && trust.authorLogin === access.authorLogin; - if (!access.collaborator && !explicitlyTrusted) throw new GuardRefusal(`Issue #${github.issue} is not trusted for its current author.`); - const validate = () => { - if (!explicitlyTrusted) return; - const current = service.store.issueTrust(github.repository, github.issue); - if (!current || current.revokedAt !== null || current.authorLogin !== access.authorLogin) - throw new GuardRefusal(`Issue #${github.issue} is not trusted for its current author.`); - }; - const text = await issues.issueText(github.issue, { signal: readSignal, timeoutMs, - trustedAuthor: explicitlyTrusted ? access.authorLogin : undefined, expectedAccess: access }); - validate(); - // The configured base branch (#103), or the base commit when none (or an empty one) is configured. - const baseRef = github.baseBranch || service.store.getSnapshot(config.identity).base; - return { issue: { number: text.number, title: text.title, body: text.body, comments: [...text.comments] }, - approvedLessons: [], repo: { name: github.repository, baseRef }, validate }; - }); }, - close: () => provider.close(), + async describe(signal) { + await check.admit(signal); + return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { + const access = await issues.issueAccess(github.issue, { signal: readSignal, timeoutMs }); + const trust = service.store.issueTrust(github.repository, github.issue); + const explicitlyTrusted = trust?.revokedAt === null && trust.authorLogin === access.authorLogin; + if (!access.collaborator && !explicitlyTrusted) throw new GuardRefusal(`Issue #${github.issue} is not trusted for its current author.`); + const validate = () => { + if (!explicitlyTrusted) return; + const current = service.store.issueTrust(github.repository, github.issue); + if (!current || current.revokedAt !== null || current.authorLogin !== access.authorLogin) + throw new GuardRefusal(`Issue #${github.issue} is not trusted for its current author.`); + }; + const text = await issues.issueText(github.issue, { signal: readSignal, timeoutMs, + trustedAuthor: explicitlyTrusted ? access.authorLogin : undefined, expectedAccess: access }); + validate(); + // The configured base branch (#103), or the base commit when none (or an empty one) is configured. + const baseRef = github.baseBranch || service.store.getSnapshot(config.identity).base; + return { issue: { number: text.number, title: text.title, body: text.body, comments: [...text.comments] }, + approvedLessons: [], repo: { name: github.repository, baseRef }, validate }; + }); + }, + // The check runs in the provider's worker, so it stops before the worker does. + close: async () => { await check.close(); await provider.close(); }, }; }; } From ce80552b2a06a509550a982ef251828540525584 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 18:16:53 -0700 Subject: [PATCH 02/39] Give the browser stand-in a passing check; test the check against the vendor (#164) Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 2 +- test/agent-planning.test.ts | 14 ++++++++++++++ test/browser/plans-production.spec.ts | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index e83b08ec..d1dcf048 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -119,7 +119,7 @@ export class PlanningCheck { if (this.#abort.signal.aborted) throw new GuardRefusal('Planning is stopping.'); const check: Check = { vendor, done: Promise.resolve() }; // Never rejects: a failure is kept on the check, so every waiting request is refused with it. - const done = this.#probe(vendor, this.#abort.signal).then(() => undefined, (error: unknown) => { + const done = Promise.resolve().then(() => this.#probe(vendor, this.#abort.signal)).then(() => undefined, (error: unknown) => { check.failure = { error, at: performance.now() }; }); check.done = done; diff --git a/test/agent-planning.test.ts b/test/agent-planning.test.ts index 6fb0fe1e..3c28d7a6 100644 --- a/test/agent-planning.test.ts +++ b/test/agent-planning.test.ts @@ -43,4 +43,18 @@ describe('planning in the agent container', () => { expect(docker(kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '--quiet', '--filter', `label=io.codeboost.runner=${owner}`).stdout.trim(), kind).toBe(''); expect(existsSync(`${realpathSync(service.config.database)}.planning-leftovers.json`)).toBe(false); }, 11 * 60_000); + + it('fails the planning check when the vendor refuses the sign-in, and leaves nothing (#164)', async () => { + const root = mkdtempSync(join(tmpdir(), 'agent-planning-')); roots.push(root); + const service = new ReviewService(createDemo(join(root, 'demo'))); services.push(service); + owners.push(service.store.planningOwnerToken(statSync(realpathSync(service.config.database), { bigint: true }))); + const agent = new PlanningAgent(service, { env: { CLAUDE_CODE_OAUTH_TOKEN: 'codeboost-invalid-test-token' } }); + try { + // The check is a real planning invocation: only one that reached Anthropic comes back with its 401. + await expect(agent.probe('claude', new AbortController().signal)).rejects.toThrow(/Claude could not write the plan.*(401|authenticate)/); + } finally { await agent.close(); } + const owner = owners.at(-1)!; + for (const kind of ['container', 'volume', 'network']) + expect(docker(kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '--quiet', '--filter', `label=io.codeboost.runner=${owner}`).stdout.trim(), kind).toBe(''); + }, 11 * 60_000); }); diff --git a/test/browser/plans-production.spec.ts b/test/browser/plans-production.spec.ts index 4d2cf4e0..193e586f 100644 --- a/test/browser/plans-production.spec.ts +++ b/test/browser/plans-production.spec.ts @@ -91,7 +91,7 @@ function dBackedAgent(service: ReviewService, observations: { const snapshot = service.store.getSnapshot(service.config.identity); const provider = createPlanningProvider({ vendor: 'claude', repository: service.reviewRepository().path, head: snapshot.head, snapshotId: snapshot.id, runnerOwner: '0123456789abcdef0123456789abcdef', deps }); - const agent: AuthorProvider & { close(): Promise } = { + const agent: AuthorProvider & Pick = { async invoke(request: AuthorRequest, signal: AbortSignal) { const current = service.store.getPlan(request.identity); const expected = request.mode === 'draft' ? current.revision + 1 : current.revision; @@ -100,6 +100,8 @@ function dBackedAgent(service: ReviewService, observations: { return provider.invoke(request, signal); }, async close() { /* createPlanningProvider has no process-level owner */ }, + // The planning check (#164) passes here; planning-production and agent-planning test it. + async probe() { /* passes */ }, }; return agent as PlanningAgent; } From 330a01b3d147d3bf178edd9abc64e8f45ba673b0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 18:34:42 -0700 Subject: [PATCH 03/39] Start the check at once, bound the wait, recheck before the start (#164) Review round 1: the probe starts synchronously again (a deferred start broke three tests and let close wait out its cap); the check gets a real request's budget, since the first job may build the image, while a request waits for it at most 30 s; the request's validate refuses a vendor chosen during the issue read until its check passed; the refusal no longer advises a retry that cannot help; tests cover close waiting, the close cap, the wait and a wrong answer. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 34 ++++++---- runner/planning.ts | 75 ++++++++++++++-------- test/fixtures/question-worker-stub.ts | 4 +- test/planning-production.test.ts | 50 +++++++++++++-- test/planning-worker.test.ts | 6 ++ web/planning.ts | 10 ++- 6 files changed, 131 insertions(+), 48 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 98b87877..b6c8348c 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -85,20 +85,26 @@ it leaves anything. The planning check (#164) proves that the vendor chosen in Settings can run a planning invocation before any request does. It is one planning job in the same worker and profile as a real request (read, list and search only, vendor-only -egress, a mounted schema), on the review's code at the snapshot's head. It asks -for the schema's own answer, `{"ok": true}`, within a two-minute budget, so it -covers sign-in, the schema-constrained answer and the read-only profile -(`PlanningAgent.probe`). `PlanningCheck` starts it when the server starts, and -again before the first request after the vendor changes. `productionPlanning`'s -`describe` waits for it before it reads the issue or records anything. A failed -check refuses the request with a `GuardRefusal` that starts "Planning is -unavailable:" and names the reason; the server records that refusal and starts no -agent. Requests within a minute of the failure are refused with it, and the next -request after that checks again. With no vendor chosen nothing is checked, and -the request is refused later, as before. Shutdown cancels a running check and waits -up to 20 s for it before closing the worker. Tests: `test/planning-production.test.ts` -("the planning check (#164)", with a check that passes and one that fails) and -`test/planning-worker.test.ts` (the check in planning's worker). +egress, a mounted schema), on the review's code at the snapshot's head, with a +real request's budget, since a process's first job may build the agent image. It +asks for the schema's own answer, `{"ok": true}`, so it covers sign-in, the +schema-constrained answer and the read-only profile (`PlanningAgent.probe`). +`PlanningCheck` starts it when the server starts, before it listens, and again +when the vendor changes. `productionPlanning`'s `describe` waits for it, at most +30 s, before it reads the issue or records anything, and the request checks again +just before it starts, so a vendor chosen during the issue read never runs +unchecked. A failed check refuses the request with a `GuardRefusal` that starts +"Planning is unavailable:" and names the reason, and so does a check still +running after the wait; the server records the refusal and starts no agent. +Requests within a minute of a failure are refused with it; the next request after +that checks again. With no vendor chosen nothing is checked, and the request is +refused later, as before. Shutdown cancels a running check and waits up to 20 s +for it before closing the worker. If startup fails after planning's setup, the +process exits, and the next start's planning recovery removes what the check left. +Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a +check that passes, one that fails and one that does not finish), +`test/planning-worker.test.ts` (the check in planning's worker, including a wrong +answer) and `test/agent-planning.test.ts` (against the vendor, real Docker). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/runner/planning.ts b/runner/planning.ts index d1dcf048..3170794a 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -26,8 +26,6 @@ export function isPlanningProbeAnswer(text: string): boolean { return typeof answer === 'object' && answer !== null && !Array.isArray(answer) && Object.keys(answer).length === 1 && (answer as { ok?: unknown }).ok === true; } -/** The whole budget of one check, setup included. Lane D caps the invocation inside it. */ -export const PLANNING_PROBE_BUDGET_MS = 2 * 60_000; /** * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous @@ -71,7 +69,8 @@ export class PlanningAgent implements AuthorProvider { const run = planningRun({ mode: 'suggest', phase: 'planning', access: 'read-only', identity, requestId, issue: plan.issue, revision: plan.revision, prompt: PLANNING_PROBE_PROMPT, schemaText: PLANNING_PROBE_SCHEMA }, { vendor, repository: this.#service.reviewRepository().path, head: snapshot.head, snapshotId: snapshot.id }, - Date.now() + PLANNING_PROBE_BUDGET_MS - SETTLE_MARGIN_MS); + // A real request's budget: a process's first job may build the agent image, which takes its time from this. + Date.now() + PLANNING_BUDGET_MS - SETTLE_MARGIN_MS); const text = await this.#worker.plan({ ...run, taskId: `planning-probe-${requestId}` }, signal); if (!isPlanningProbeAnswer(text)) throw new Error('The planning agent did not give the check\'s answer.'); } @@ -79,32 +78,38 @@ export class PlanningAgent implements AuthorProvider { close(): Promise { return this.#worker.close(); } } -/** Starts with this text when planning is refused because its check failed. */ +/** Starts with this text when planning is refused because its check failed or has not finished. */ export const PLANNING_UNAVAILABLE = 'Planning is unavailable:'; /** How long a failed check refuses requests before the next request checks again. */ export const PLANNING_RECHECK_MS = 60_000; +/** How long a request waits for a running check before it is refused, so the browser is never held for long. */ +export const PLANNING_ADMIT_WAIT_MS = 30_000; /** How long close waits for a check that is still running after it is cancelled. */ export const PLANNING_CHECK_CLOSE_MS = 20_000; -/** One run of the planning check, for one vendor. `failure` is set once it failed. */ -type Check = { vendor: Provider; done: Promise; failure?: { error: unknown; at: number } }; +/** One run of the planning check, for one vendor. `passed` or `failure` is set once it settles. */ +type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number } }; +const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); /** - * Admission for planning requests (#164). The planning check runs once per process for each vendor chosen in Settings: - * `start` begins it at startup, and `admit`, called before anything about a request is recorded, waits for it. A - * request is refused while the check that covers it has failed: for `recheckMs` after the failure, after which the next - * request checks again. A changed vendor is checked before its first request. With no vendor chosen nothing is - * checked, and the request is refused later, as before, with `NO_PLANNING_AGENT`. + * Admission for planning requests (#164). The check runs for the vendor chosen in Settings: `start` begins it at + * startup, and `admit`, called before anything about a request is recorded, waits for it. The last check is kept: a + * passed one admits every request for its vendor; a failed one refuses them for `recheckMs`, after which the next + * request checks again; a check for another vendor starts when the chosen vendor changes. A request waits for a + * running check for at most `waitMs`, then is refused as still starting. With no vendor chosen nothing is checked, + * and the request is refused later, as before, with `NO_PLANNING_AGENT`. */ export class PlanningCheck { readonly #probe: (vendor: Provider, signal: AbortSignal) => Promise; readonly #vendor: () => Provider | null; readonly #recheckMs: number; + readonly #waitMs: number; readonly #abort = new AbortController(); readonly #running = new Set>(); #current?: Check; constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, - options: { recheckMs?: number } = {}) { - this.#probe = probe; this.#vendor = vendor; this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; + options: { recheckMs?: number; waitMs?: number } = {}) { + this.#probe = probe; this.#vendor = vendor; + this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; this.#waitMs = options.waitMs ?? PLANNING_ADMIT_WAIT_MS; } /** Begin the check for the chosen vendor, if any, without waiting for it. */ start(): void { @@ -117,9 +122,12 @@ export class PlanningCheck { if (current && current.vendor === vendor && !(current.failure && performance.now() - current.failure.at >= this.#recheckMs)) return current; if (this.#abort.signal.aborted) throw new GuardRefusal('Planning is stopping.'); - const check: Check = { vendor, done: Promise.resolve() }; - // Never rejects: a failure is kept on the check, so every waiting request is refused with it. - const done = Promise.resolve().then(() => this.#probe(vendor, this.#abort.signal)).then(() => undefined, (error: unknown) => { + const check: Check = { vendor, done: Promise.resolve(), passed: false }; + let probe: Promise; + // Started now, so close can cancel it; a probe that throws instead of rejecting fails the check the same way. + try { probe = this.#probe(vendor, this.#abort.signal); } catch (error) { probe = Promise.reject(error); } + // Never rejects: the outcome is kept on the check, so every waiting request gets it. + const done = probe.then(() => { check.passed = true; }, (error: unknown) => { check.failure = { error, at: performance.now() }; }); check.done = done; @@ -130,28 +138,45 @@ export class PlanningCheck { } /** * Resolve once the check for the vendor chosen now has passed; reject with a `GuardRefusal` naming why when it - * failed. `signal` stops only this wait, never the check, which other requests may share. + * failed, or when it is still running after `waitMs`. `signal` stops only this wait, never the check, which other + * requests may share. */ async admit(signal: AbortSignal): Promise { for (;;) { const vendor = this.#vendor(); if (!vendor) return; const check = this.#check(vendor); - let release!: () => void; - const aborted = new Promise((_, reject) => { + let release!: () => void, timer: ReturnType | undefined; + const stopped = new Promise<'timeout'>((resolve, reject) => { release = () => reject(signal.reason); signal.addEventListener('abort', release, { once: true }); + timer = setTimeout(() => resolve('timeout'), this.#waitMs); }); if (signal.aborted) release(); - try { await Promise.race([check.done, aborted]); } - finally { signal.removeEventListener('abort', release); } + let outcome: void | 'timeout'; + try { outcome = await Promise.race([check.done, stopped]); } + finally { signal.removeEventListener('abort', release); clearTimeout(timer); } // Settings changed while this request waited: check the vendor it will run with. if (this.#vendor() !== vendor) continue; - if (!check.failure) return; - const reason = check.failure.error instanceof Error ? check.failure.error.message : String(check.failure.error); - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ${reason} ` - + 'Planning stays off until a check passes; retry in a minute.'); + // This request's own check: a recheck started since must not replace the outcome it waited for. + return PlanningCheck.#verdict(check); } } + /** + * Throw a `GuardRefusal` unless the check for the vendor chosen now has passed (or no vendor is chosen). Requests call + * it again just before they start, so a vendor chosen after `admit` is never run unchecked; that vendor's check + * starts here. + */ + assertPassed(): void { + const vendor = this.#vendor(); + if (!vendor) return; + PlanningCheck.#verdict(this.#check(vendor)); + } + static #verdict(check: Check): void { + if (check.passed) return; + if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` + + `${messageOf(check.failure.error)} codeboost checks again on the next request after a minute.`); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`); + } /** Cancel any running check and wait up to `waitMs` for it to stop. No check starts afterwards. */ async close(waitMs = PLANNING_CHECK_CLOSE_MS): Promise { this.#abort.abort(new Error('Planning stopped.')); diff --git a/test/fixtures/question-worker-stub.ts b/test/fixtures/question-worker-stub.ts index e0600345..f6f56a28 100644 --- a/test/fixtures/question-worker-stub.ts +++ b/test/fixtures/question-worker-stub.ts @@ -43,7 +43,9 @@ parentPort!.on('message', (message: WorkerRequest) => { const { prompt, provider, noteId, attemptId } = job; // The planning check (#164) passes with the schema's own answer. if (message.type === 'plan' && message.run.taskId.startsWith('planning-probe-')) { - parentPort!.postMessage({ id: message.id, attemptId, ok: true, text: '{"ok":true}' }); + // With this token the agent answers something else, which must fail the check. + const wrong = workerData?.credentials?.CLAUDE_CODE_OAUTH_TOKEN === 'probe-wrong-answer'; + parentPort!.postMessage({ id: message.id, attemptId, ok: true, text: wrong ? '{"ok":false}' : '{"ok":true}' }); return; } if (prompt === 'crash') throw new Error('stub crashed'); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 90eb9b40..8943133b 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -13,7 +13,7 @@ import { DatabaseSync } from 'node:sqlite'; import { PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; -import { PLANNING_UNAVAILABLE } from '../runner/planning.ts'; +import { PLANNING_UNAVAILABLE, PlanningCheck } from '../runner/planning.ts'; // Production planning wiring (#117): when it is on, what each request is told, and how the server awaits the issue. vi.setConfig({ testTimeout: 20_000 }); @@ -318,13 +318,13 @@ it('ends a planning request that does not settle after shutdown aborts it by clo describe('the planning check (#164)', () => { /** The production setup on a review whose Settings chose `vendor`, with a stand-in agent and issue reads. */ function checked(probe: (vendor: string, signal: AbortSignal) => Promise, vendor: 'claude' | 'codex' | null = 'claude', - recheckMs?: number) { + recheckMs?: number, admitWaitMs?: number, issueText = vi.fn(async (number: number) => text(number))) { const config = production(), service = new ReviewService(config); closers.push(() => service.close()); if (vendor) service.store.setQuestionProvider(vendor); const issueAccess = vi.fn(async (number: number) => ({ number, authorLogin: 'outside', collaborator: true })); - const issueText = vi.fn(async (number: number) => text(number)); const close = vi.fn(async () => undefined), agent = closable(close, vi.fn(probe)); - const deps = productionPlanning(config, { ...verified, issues: { issueAccess, issueText }, agent: () => agent, recheckMs })!(service); + const deps = productionPlanning(config, { ...verified, issues: { issueAccess, issueText }, agent: () => agent, recheckMs, + admitWaitMs })!(service); return { deps, service, agent, issueAccess, close, probe: vi.mocked(agent.probe) }; } const signal = () => new AbortController().signal; @@ -404,16 +404,54 @@ describe('the planning check (#164)', () => { expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); }); - it('cancels a running check on close, before it closes the agent', async () => { + it('cancels a running check on close, and closes the agent only once it stopped', async () => { const order: string[] = []; + // The check settles a while after it is cancelled, as a worker job does. const { deps, probe, close } = checked((_vendor, probeSignal) => new Promise((_, reject) => - probeSignal.addEventListener('abort', () => { order.push('check stopped'); reject(probeSignal.reason); }, { once: true }))); + probeSignal.addEventListener('abort', () => setTimeout(() => { order.push('check stopped'); reject(probeSignal.reason); }, 50), + { once: true }))); close.mockImplementation(async () => { order.push('agent closed'); }); await deps.close!(); expect(probe.mock.calls[0]![1].aborted).toBe(true); expect(order).toEqual(['check stopped', 'agent closed']); }); + it('waits a bounded time on close for a check that never stops', async () => { + const check = new PlanningCheck(() => new Promise(() => undefined), () => 'claude'); + check.start(); + const started = performance.now(); + await check.close(100); + expect(performance.now() - started).toBeLessThan(2_000); + }); + + it('refuses a request as still starting when the check outlasts its wait, and admits it once the check passed', async () => { + let pass!: () => void; + const { deps, issueAccess } = checked(() => new Promise(resolve => { pass = resolve; }), 'claude', undefined, 50); + const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(GuardRefusal); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`); + expect(issueAccess).not.toHaveBeenCalled(); + pass(); + await expect(deps.describe(signal())).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); + }); + + it('refuses at the start a vendor chosen while the issue was read, and checks it (#164)', async () => { + let service!: ReviewService; + const issueText = vi.fn(async (number: number) => { + vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); + return text(number); + }); + const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); + service = made.service; + const described = await made.deps.describe(signal()); + expect(() => described.validate()).toThrow(GuardRefusal); + expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); + // Once that check passed, the same request is admitted. + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(() => described.validate()).not.toThrow(); + }); + it('refuses a start through the server with 409, recording no request and starting no agent', async () => { const config = production(), probe = vi.fn(async () => { throw new Error('Claude could not sign in.'); }); const service = new ReviewService(config); service.store.setQuestionProvider('claude'); service.close(); diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index f52572fb..b2030255 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -171,6 +171,12 @@ it('fails the planning check without a sign-in or with Codex, before any lock, w expect(acquire).not.toHaveBeenCalled(); }); +it('fails the planning check when the agent answers anything but the schema\'s answer', async () => { + const service = review(), wrong = new PlanningAgent(service, { url: STUB, env: { CLAUDE_CODE_OAUTH_TOKEN: 'probe-wrong-answer' } }); + closers.push(() => wrong.close()); + await expect(wrong.probe('claude', new AbortController().signal)).rejects.toThrow('did not give the check\'s answer'); +}); + it('passes the planning check only with exactly the schema\'s answer', () => { expect(isPlanningProbeAnswer('{"ok":true}')).toBe(true); expect(isPlanningProbeAnswer(' { "ok": true }\n')).toBe(true); diff --git a/web/planning.ts b/web/planning.ts index 0e45412c..a907dd6a 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -20,6 +20,8 @@ export function productionPlanning(config: ReviewConfig, options: { agent?: (service: ReviewService) => PlanningAgent; /** How long a failed planning check refuses requests; `PLANNING_RECHECK_MS` by default. */ recheckMs?: number; + /** How long a request waits for a running check; `PLANNING_ADMIT_WAIT_MS` by default. */ + admitWaitMs?: number; }): PlanningSetup | undefined { const github = config.github; if (config.demo || !github) return undefined; @@ -31,8 +33,10 @@ export function productionPlanning(config: ReviewConfig, options: { options.verifyLock(); service.store.settleInterruptedRequests(); const provider = options.agent?.(service) ?? new PlanningAgent(service); + // Started now, before the server listens. If startup fails after this, the process exits, and the next start's + // planning recovery removes what the check left (its objects carry planning's owner). const check = new PlanningCheck((vendor, signal) => provider.probe(vendor, signal), () => service.store.questionProvider(), - { recheckMs: options.recheckMs }); + { recheckMs: options.recheckMs, waitMs: options.admitWaitMs }); check.start(); return { provider, @@ -54,8 +58,10 @@ export function productionPlanning(config: ReviewConfig, options: { validate(); // The configured base branch (#103), or the base commit when none (or an empty one) is configured. const baseRef = github.baseBranch || service.store.getSnapshot(config.identity).base; + // Run again just before the request starts: Settings may have chosen an unchecked vendor during the read. + const admitted = () => { validate(); check.assertPassed(); }; return { issue: { number: text.number, title: text.title, body: text.body, comments: [...text.comments] }, - approvedLessons: [], repo: { name: github.repository, baseRef }, validate }; + approvedLessons: [], repo: { name: github.repository, baseRef }, validate: admitted }; }); }, // The check runs in the provider's worker, so it stops before the worker does. From 5af6cd258736e979b5d560392ece49229552c0be Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 18:46:08 -0700 Subject: [PATCH 04/39] Start no check inside the Store's transaction; cancel replaced checks (#164) Review round 2: assertPassed, which the server calls inside its recorded action, refuses and starts a needed check on the next turn, so no worker, ledger or owner-token write runs inside the transaction; a check another vendor's replaces is cancelled; one deadline bounds a request's whole wait; the stub answers only the check's own prompt and schema; tests for a probe that throws and a replaced check. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 9 ++-- runner/planning.ts | 53 ++++++++++++++-------- test/fixtures/question-worker-stub.ts | 4 +- test/planning-production.test.ts | 27 ++++++++++- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index b6c8348c..f71de0e8 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -90,10 +90,11 @@ real request's budget, since a process's first job may build the agent image. It asks for the schema's own answer, `{"ok": true}`, so it covers sign-in, the schema-constrained answer and the read-only profile (`PlanningAgent.probe`). `PlanningCheck` starts it when the server starts, before it listens, and again -when the vendor changes. `productionPlanning`'s `describe` waits for it, at most -30 s, before it reads the issue or records anything, and the request checks again -just before it starts, so a vendor chosen during the issue read never runs -unchecked. A failed check refuses the request with a `GuardRefusal` that starts +on the first request after the vendor changes, cancelling the check it replaces. `productionPlanning`'s `describe` waits for it, at most +30 s in all, before it reads the issue or records anything, and the request checks +again just before it starts, so a vendor chosen during the issue read never runs +unchecked. That last check runs inside the Store's transaction, so it starts no +check itself: it refuses, and the check it needs starts on the next turn. A failed check refuses the request with a `GuardRefusal` that starts "Planning is unavailable:" and names the reason, and so does a check still running after the wait; the server records the refusal and starts no agent. Requests within a minute of a failure are refused with it; the next request after diff --git a/runner/planning.ts b/runner/planning.ts index 3170794a..47479c09 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -88,15 +88,17 @@ export const PLANNING_ADMIT_WAIT_MS = 30_000; export const PLANNING_CHECK_CLOSE_MS = 20_000; /** One run of the planning check, for one vendor. `passed` or `failure` is set once it settles. */ -type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number } }; +type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number }; + abort: AbortController }; const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); +const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`; /** * Admission for planning requests (#164). The check runs for the vendor chosen in Settings: `start` begins it at * startup, and `admit`, called before anything about a request is recorded, waits for it. The last check is kept: a * passed one admits every request for its vendor; a failed one refuses them for `recheckMs`, after which the next - * request checks again; a check for another vendor starts when the chosen vendor changes. A request waits for a - * running check for at most `waitMs`, then is refused as still starting. With no vendor chosen nothing is checked, - * and the request is refused later, as before, with `NO_PLANNING_AGENT`. + * request checks again. The first request after the vendor changes starts that vendor's check and cancels the one it + * replaces. A request waits for a check for at most `waitMs` in all, then is refused as still starting. With no vendor + * chosen nothing is checked, and the request is refused later, as before, with `NO_PLANNING_AGENT`. */ export class PlanningCheck { readonly #probe: (vendor: Provider, signal: AbortSignal) => Promise; @@ -116,20 +118,30 @@ export class PlanningCheck { const vendor = this.#vendor(); if (vendor) this.#check(vendor); } - // The check that covers `vendor` now: the current one, unless it was for another vendor, or failed `recheckMs` ago. - #check(vendor: Provider) { + // The current check if it covers `vendor`: for that vendor, and not failed `recheckMs` ago. + #covering(vendor: Provider) { const current = this.#current; - if (current && current.vendor === vendor - && !(current.failure && performance.now() - current.failure.at >= this.#recheckMs)) return current; + return current && current.vendor === vendor + && !(current.failure && performance.now() - current.failure.at >= this.#recheckMs) ? current : undefined; + } + // The check that covers `vendor`, started now if none does. + #check(vendor: Provider) { + const covering = this.#covering(vendor); + if (covering) return covering; if (this.#abort.signal.aborted) throw new GuardRefusal('Planning is stopping.'); - const check: Check = { vendor, done: Promise.resolve(), passed: false }; + // A check for another vendor no longer admits anything: stop it, so replaced checks do not pile up in the worker. + if (this.#current && !this.#current.passed && !this.#current.failure) + this.#current.abort.abort(new Error('The planning agent chosen in Settings changed.')); + const abort = new AbortController(), stop = () => abort.abort(this.#abort.signal.reason); + this.#abort.signal.addEventListener('abort', stop, { once: true }); + const check: Check = { vendor, done: Promise.resolve(), passed: false, abort }; let probe: Promise; // Started now, so close can cancel it; a probe that throws instead of rejecting fails the check the same way. - try { probe = this.#probe(vendor, this.#abort.signal); } catch (error) { probe = Promise.reject(error); } + try { probe = this.#probe(vendor, abort.signal); } catch (error) { probe = Promise.reject(error); } // Never rejects: the outcome is kept on the check, so every waiting request gets it. const done = probe.then(() => { check.passed = true; }, (error: unknown) => { check.failure = { error, at: performance.now() }; - }); + }).finally(() => this.#abort.signal.removeEventListener('abort', stop)); check.done = done; this.#running.add(done); void done.finally(() => this.#running.delete(done)); @@ -138,10 +150,11 @@ export class PlanningCheck { } /** * Resolve once the check for the vendor chosen now has passed; reject with a `GuardRefusal` naming why when it - * failed, or when it is still running after `waitMs`. `signal` stops only this wait, never the check, which other + * failed, or when no check passed within `waitMs`. `signal` stops only this wait, never the check, which other * requests may share. */ async admit(signal: AbortSignal): Promise { + const deadline = performance.now() + this.#waitMs; for (;;) { const vendor = this.#vendor(); if (!vendor) return; @@ -149,13 +162,14 @@ export class PlanningCheck { let release!: () => void, timer: ReturnType | undefined; const stopped = new Promise<'timeout'>((resolve, reject) => { release = () => reject(signal.reason); signal.addEventListener('abort', release, { once: true }); - timer = setTimeout(() => resolve('timeout'), this.#waitMs); + timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - performance.now())); }); if (signal.aborted) release(); let outcome: void | 'timeout'; try { outcome = await Promise.race([check.done, stopped]); } finally { signal.removeEventListener('abort', release); clearTimeout(timer); } - // Settings changed while this request waited: check the vendor it will run with. + if (outcome === 'timeout') throw new GuardRefusal(STILL_CHECKING); + // Settings changed while this request waited: check the vendor it will run with, within the same wait. if (this.#vendor() !== vendor) continue; // This request's own check: a recheck started since must not replace the outcome it waited for. return PlanningCheck.#verdict(check); @@ -163,19 +177,22 @@ export class PlanningCheck { } /** * Throw a `GuardRefusal` unless the check for the vendor chosen now has passed (or no vendor is chosen). Requests call - * it again just before they start, so a vendor chosen after `admit` is never run unchecked; that vendor's check - * starts here. + * it again inside the action that starts them, so a vendor chosen after `admit` never runs unchecked. It starts no + * check there (that runs inside the Store's transaction): one it needs is started on the next turn. */ assertPassed(): void { const vendor = this.#vendor(); if (!vendor) return; - PlanningCheck.#verdict(this.#check(vendor)); + const covering = this.#covering(vendor); + if (covering) return PlanningCheck.#verdict(covering); + setImmediate(() => { try { this.#check(vendor); } catch { /* stopping: nothing to start */ } }); + throw new GuardRefusal(STILL_CHECKING); } static #verdict(check: Check): void { if (check.passed) return; if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` + `${messageOf(check.failure.error)} codeboost checks again on the next request after a minute.`); - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`); + throw new GuardRefusal(STILL_CHECKING); } /** Cancel any running check and wait up to `waitMs` for it to stop. No check starts afterwards. */ async close(waitMs = PLANNING_CHECK_CLOSE_MS): Promise { diff --git a/test/fixtures/question-worker-stub.ts b/test/fixtures/question-worker-stub.ts index f6f56a28..bb779a6c 100644 --- a/test/fixtures/question-worker-stub.ts +++ b/test/fixtures/question-worker-stub.ts @@ -44,7 +44,9 @@ parentPort!.on('message', (message: WorkerRequest) => { // The planning check (#164) passes with the schema's own answer. if (message.type === 'plan' && message.run.taskId.startsWith('planning-probe-')) { // With this token the agent answers something else, which must fail the check. - const wrong = workerData?.credentials?.CLAUDE_CODE_OAUTH_TOKEN === 'probe-wrong-answer'; + // The agent answers the check only when asked for it: the prompt names the answer and the schema requires it. + const asked = message.run.prompt.includes('{"ok": true}') && message.run.schemaText.includes('"ok":{"const":true}'); + const wrong = !asked || workerData?.credentials?.CLAUDE_CODE_OAUTH_TOKEN === 'probe-wrong-answer'; parentPort!.postMessage({ id: message.id, attemptId, ok: true, text: wrong ? '{"ok":false}' : '{"ok":true}' }); return; } diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 8943133b..e705b028 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -416,6 +416,27 @@ describe('the planning check (#164)', () => { expect(order).toEqual(['check stopped', 'agent closed']); }); + it('fails the check, and refuses with its reason, when the probe throws instead of rejecting', async () => { + const check = new PlanningCheck(() => { throw new Error('No worker could start.'); }, () => 'claude'); + expect(() => check.start()).not.toThrow(); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(GuardRefusal); + expect((refusal as Error).message).toContain('No worker could start.'); + }); + + it('cancels a running check that a check for another vendor replaced', async () => { + let vendor: 'claude' | 'codex' = 'claude'; + const signals: AbortSignal[] = []; + const check = new PlanningCheck((_vendor, probeSignal) => { signals.push(probeSignal); return new Promise(() => undefined); }, + () => vendor, { waitMs: 10 }); + check.start(); + vendor = 'codex'; + await expect(check.admit(signal())).rejects.toThrow('still checking'); + expect(signals.map(probeSignal => probeSignal.aborted)).toEqual([true, false]); + await check.close(10); + expect(signals[1]!.aborted).toBe(true); + }); + it('waits a bounded time on close for a check that never stops', async () => { const check = new PlanningCheck(() => new Promise(() => undefined), () => 'claude'); check.start(); @@ -444,10 +465,12 @@ describe('the planning check (#164)', () => { const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); - expect(() => described.validate()).toThrow(GuardRefusal); + expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent.`); + // validate runs inside the Store's transaction, so the new vendor's check starts only on the next turn. + expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude']); + await new Promise(resolve => setImmediate(resolve)); expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); // Once that check passed, the same request is admitted. - await Promise.resolve(); await new Promise(resolve => setTimeout(resolve, 0)); expect(() => described.validate()).not.toThrow(); }); From cd918e6d5fb7d56b0377a2489387122f0ec4e6dd Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 18:56:22 -0700 Subject: [PATCH 05/39] Test the stop after close and the single wait deadline; refusal wording (#164) Review round 3: tests show no check starts after close and one deadline bounds a wait across a vendor change; the deferred check reads the vendor again; the refusal ends its reason with a period and says a new agent is checked at once. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 36 ++++++++++++---------- runner/planning.ts | 14 +++++++-- test/planning-production.test.ts | 31 ++++++++++++++++++- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index f71de0e8..e9815524 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -89,23 +89,25 @@ egress, a mounted schema), on the review's code at the snapshot's head, with a real request's budget, since a process's first job may build the agent image. It asks for the schema's own answer, `{"ok": true}`, so it covers sign-in, the schema-constrained answer and the read-only profile (`PlanningAgent.probe`). -`PlanningCheck` starts it when the server starts, before it listens, and again -on the first request after the vendor changes, cancelling the check it replaces. `productionPlanning`'s `describe` waits for it, at most -30 s in all, before it reads the issue or records anything, and the request checks -again just before it starts, so a vendor chosen during the issue read never runs -unchecked. That last check runs inside the Store's transaction, so it starts no -check itself: it refuses, and the check it needs starts on the next turn. A failed check refuses the request with a `GuardRefusal` that starts -"Planning is unavailable:" and names the reason, and so does a check still -running after the wait; the server records the refusal and starts no agent. -Requests within a minute of a failure are refused with it; the next request after -that checks again. With no vendor chosen nothing is checked, and the request is -refused later, as before. Shutdown cancels a running check and waits up to 20 s -for it before closing the worker. If startup fails after planning's setup, the -process exits, and the next start's planning recovery removes what the check left. -Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a -check that passes, one that fails and one that does not finish), -`test/planning-worker.test.ts` (the check in planning's worker, including a wrong -answer) and `test/agent-planning.test.ts` (against the vendor, real Docker). +`PlanningCheck` starts it when the server starts, before it listens, and again on +the first request after the vendor changes, cancelling the check it replaces. +`productionPlanning`'s `describe` waits for it, at most 30 s in all, before it +reads the issue or records anything, and the request checks again just before it +starts, so a vendor chosen during the issue read never runs unchecked. That last +check runs inside the Store's transaction, so it starts no check itself: it +refuses, and the check it needs starts on the next turn. A failed check refuses +the request with a `GuardRefusal` that starts "Planning is unavailable:" and names +the reason, and so does a check still running after the wait; the server records +the refusal and starts no agent. Requests within a minute of a failure are refused +with it; the next request after that checks again. With no vendor chosen nothing +is checked, and the request is refused later, as before. Shutdown cancels a +running check and waits up to 20 s for it before closing the worker. If startup +fails after planning's setup, the process exits, and the next start's planning +recovery removes what the check left. Tests: `test/planning-production.test.ts` +("the planning check (#164)", with a check that passes, one that fails and one +that does not finish), `test/planning-worker.test.ts` (the check in planning's +worker, including a wrong answer) and `test/agent-planning.test.ts` (against the +vendor, real Docker). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/runner/planning.ts b/runner/planning.ts index 47479c09..c28d09ed 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -185,13 +185,21 @@ export class PlanningCheck { if (!vendor) return; const covering = this.#covering(vendor); if (covering) return PlanningCheck.#verdict(covering); - setImmediate(() => { try { this.#check(vendor); } catch { /* stopping: nothing to start */ } }); + // The vendor chosen then, which Settings may have changed again since. + setImmediate(() => { + const now = this.#vendor(); + try { if (now) this.#check(now); } catch { /* stopping: nothing to start */ } + }); throw new GuardRefusal(STILL_CHECKING); } static #verdict(check: Check): void { if (check.passed) return; - if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` - + `${messageOf(check.failure.error)} codeboost checks again on the next request after a minute.`); + if (check.failure) { + const reason = messageOf(check.failure.error).trim(); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` + + `${reason}${/[.!?]$/.test(reason) ? '' : '.'} codeboost checks again on the next request after a minute, ` + + 'or at once when another agent is chosen in Settings.'); + } throw new GuardRefusal(STILL_CHECKING); } /** Cancel any running check and wait up to `waitMs` for it to stop. No check starts afterwards. */ diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index e705b028..7e67f7b3 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -329,7 +329,7 @@ describe('the planning check (#164)', () => { } const signal = () => new AbortController().signal; - it('starts at setup with the vendor chosen in Settings, and runs once for every request after', async () => { + it('starts at setup with the vendor chosen in Settings, and that one check serves every later request', async () => { const { deps, probe, issueAccess } = checked(async () => undefined); expect(probe).toHaveBeenCalledTimes(1); expect(probe.mock.calls[0]![0]).toBe('claude'); @@ -437,6 +437,35 @@ describe('the planning check (#164)', () => { expect(signals[1]!.aborted).toBe(true); }); + it('starts no check after close', async () => { + let vendor: 'claude' | 'codex' = 'claude'; + const probe = vi.fn(async () => undefined), check = new PlanningCheck(probe, () => vendor); + check.start(); + await check.close(); + vendor = 'codex'; + await expect(check.admit(signal())).rejects.toThrow('Planning is stopping.'); + expect(() => check.assertPassed()).toThrow('still checking'); + await new Promise(resolve => setImmediate(resolve)); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { + let vendor: 'claude' | 'codex' = 'claude'; + // Claude's check passes at 150 ms, after the vendor changed; Codex's never ends. + const check = new PlanningCheck(probed => new Promise(resolve => { + if (probed === 'claude') setTimeout(resolve, 150); + }), () => vendor, { waitMs: 200 }); + const started = performance.now(), waiting = check.admit(signal()).catch((error: unknown) => error); + await new Promise(resolve => setTimeout(resolve, 50)); + vendor = 'codex'; + // One deadline ends the wait at 200 ms; a fresh 200 ms for Codex would end it at 350 ms. + const refusal = await waiting; + expect(refusal).toBeInstanceOf(GuardRefusal); + expect((refusal as Error).message).toContain('still checking'); + expect(performance.now() - started).toBeLessThan(320); + await check.close(10); + }); + it('waits a bounded time on close for a check that never stops', async () => { const check = new PlanningCheck(() => new Promise(() => undefined), () => 'claude'); check.start(); From a58a0962479c31272d9ba63900db169b14adc457 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:05:23 -0700 Subject: [PATCH 06/39] Put the check's reason last and whole; keep waiting past a replaced check (#164) Review round 4: a reason ending with a command is no longer followed by text; a request whose check was replaced while the vendor changed and changed back waits for the current check; the provider checks again as it invokes; the deadline test has margins a busy machine keeps. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 10 +++-- runner/planning.ts | 21 +++++----- test/planning-production.test.ts | 48 ++++++++++++++++++---- test/planning-worker.test.ts | 2 +- web/planning.ts | 4 +- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index e9815524..8cfb8eaa 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -95,10 +95,12 @@ the first request after the vendor changes, cancelling the check it replaces. reads the issue or records anything, and the request checks again just before it starts, so a vendor chosen during the issue read never runs unchecked. That last check runs inside the Store's transaction, so it starts no check itself: it -refuses, and the check it needs starts on the next turn. A failed check refuses -the request with a `GuardRefusal` that starts "Planning is unavailable:" and names -the reason, and so does a check still running after the wait; the server records -the refusal and starts no agent. Requests within a minute of a failure are refused +refuses, and the check it needs starts on the next turn. The provider checks once +more as it invokes the agent, in the turn after the recorded action. A failed +check refuses the request with a `GuardRefusal` that starts "Planning is +unavailable:" and ends with the reason exactly as given (some end with a command +to copy), and so does a check still running after the wait; the server records the +refusal and starts no agent. Requests within a minute of a failure are refused with it; the next request after that checks again. With no vendor chosen nothing is checked, and the request is refused later, as before. Shutdown cancels a running check and waits up to 20 s for it before closing the worker. If startup diff --git a/runner/planning.ts b/runner/planning.ts index c28d09ed..fb6e1412 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -89,7 +89,7 @@ export const PLANNING_CHECK_CLOSE_MS = 20_000; /** One run of the planning check, for one vendor. `passed` or `failure` is set once it settles. */ type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number }; - abort: AbortController }; + abort: AbortController; replaced?: boolean }; const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`; /** @@ -130,8 +130,10 @@ export class PlanningCheck { if (covering) return covering; if (this.#abort.signal.aborted) throw new GuardRefusal('Planning is stopping.'); // A check for another vendor no longer admits anything: stop it, so replaced checks do not pile up in the worker. - if (this.#current && !this.#current.passed && !this.#current.failure) + if (this.#current && !this.#current.passed && !this.#current.failure) { + this.#current.replaced = true; this.#current.abort.abort(new Error('The planning agent chosen in Settings changed.')); + } const abort = new AbortController(), stop = () => abort.abort(this.#abort.signal.reason); this.#abort.signal.addEventListener('abort', stop, { once: true }); const check: Check = { vendor, done: Promise.resolve(), passed: false, abort }; @@ -169,8 +171,9 @@ export class PlanningCheck { try { outcome = await Promise.race([check.done, stopped]); } finally { signal.removeEventListener('abort', release); clearTimeout(timer); } if (outcome === 'timeout') throw new GuardRefusal(STILL_CHECKING); - // Settings changed while this request waited: check the vendor it will run with, within the same wait. - if (this.#vendor() !== vendor) continue; + // Settings changed while this request waited, or its check was replaced and cancelled (the vendor changed and + // changed back): check the vendor it will run with, within the same wait. + if (this.#vendor() !== vendor || check.replaced) continue; // This request's own check: a recheck started since must not replace the outcome it waited for. return PlanningCheck.#verdict(check); } @@ -194,12 +197,10 @@ export class PlanningCheck { } static #verdict(check: Check): void { if (check.passed) return; - if (check.failure) { - const reason = messageOf(check.failure.error).trim(); - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` - + `${reason}${/[.!?]$/.test(reason) ? '' : '.'} codeboost checks again on the next request after a minute, ` - + 'or at once when another agent is chosen in Settings.'); - } + // The reason comes last and as it is: some end with a command a person may copy. + if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` + + 'it checks again on the next request after a minute, or at once when another agent is chosen in Settings. ' + + `The check said: ${messageOf(check.failure.error)}`); throw new GuardRefusal(STILL_CHECKING); } /** Cancel any running check and wait up to `waitMs` for it to stop. No check starts afterwards. */ diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 7e67f7b3..2e6eb8aa 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -343,7 +343,7 @@ describe('the planning check (#164)', () => { const { deps, probe, issueAccess } = checked(async () => { throw new Error('Claude could not sign in.'); }); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); - expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*Claude could not sign in\\. `)); + expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*The check said: Claude could not sign in\\.$`)); // Inside the wait, the same failure refuses at once. await expect(deps.describe(signal())).rejects.toThrow('Claude could not sign in.'); expect(probe).toHaveBeenCalledTimes(1); @@ -404,6 +404,15 @@ describe('the planning check (#164)', () => { expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); }); + it('refuses to invoke an agent whose check has not passed, when Settings changed after the recorded action', async () => { + const made = checked(async () => undefined); + await made.deps.describe(signal()); + vi.spyOn(made.service.store, 'questionProvider').mockReturnValue('codex'); + const request = {} as Parameters[0]; + expect(() => made.deps.provider.invoke(request, signal())).toThrow('still checking'); + expect(made.agent.invoke).not.toHaveBeenCalled(); + }); + it('cancels a running check on close, and closes the agent only once it stopped', async () => { const order: string[] = []; // The check settles a while after it is cancelled, as a worker job does. @@ -437,6 +446,30 @@ describe('the planning check (#164)', () => { expect(signals[1]!.aborted).toBe(true); }); + it('keeps a multi-line reason whole, at the end of the refusal', async () => { + const reason = 'Planning stays off until they are deleted. Delete them, then retry:\nrm -rf \'/tmp/codeboost-planning-x\''; + const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message.endsWith(`The check said: ${reason}`)).toBe(true); + }); + + it('keeps waiting, rather than failing, when its check was replaced and the vendor changed back', async () => { + let vendor: 'claude' | 'codex' = 'claude', count = 0; + const check = new PlanningCheck((_probed, probeSignal) => { + count += 1; + // The third check (Claude again) passes; the first two wait until cancelled. + if (count === 3) return Promise.resolve(); + return new Promise((_, reject) => probeSignal.addEventListener('abort', () => reject(probeSignal.reason), { once: true })); + }, () => vendor); + const waiting = check.admit(signal()); + vendor = 'codex'; + const other = check.admit(signal()).catch(() => undefined); + vendor = 'claude'; + await expect(Promise.all([waiting, check.admit(signal())])).resolves.toEqual([undefined, undefined]); + await other; + expect(count).toBe(3); + }); + it('starts no check after close', async () => { let vendor: 'claude' | 'codex' = 'claude'; const probe = vi.fn(async () => undefined), check = new PlanningCheck(probe, () => vendor); @@ -451,18 +484,18 @@ describe('the planning check (#164)', () => { it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; - // Claude's check passes at 150 ms, after the vendor changed; Codex's never ends. + // Claude's check passes at 600 ms, after the vendor changed; Codex's never ends. const check = new PlanningCheck(probed => new Promise(resolve => { - if (probed === 'claude') setTimeout(resolve, 150); - }), () => vendor, { waitMs: 200 }); + if (probed === 'claude') setTimeout(resolve, 600); + }), () => vendor, { waitMs: 1_000 }); const started = performance.now(), waiting = check.admit(signal()).catch((error: unknown) => error); - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 100)); vendor = 'codex'; - // One deadline ends the wait at 200 ms; a fresh 200 ms for Codex would end it at 350 ms. + // One deadline ends the wait at 1 s; a fresh 1 s for Codex would end it at 1.6 s. const refusal = await waiting; expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toContain('still checking'); - expect(performance.now() - started).toBeLessThan(320); + expect(performance.now() - started).toBeLessThan(1_450); await check.close(10); }); @@ -491,6 +524,7 @@ describe('the planning check (#164)', () => { vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); return text(number); }); + // (The provider, too, checks again as the request starts; see the next test.) const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index b2030255..cd6d11f0 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -162,7 +162,7 @@ it('runs the planning check as a planning job in planning\'s worker, and passes await expect(agent(service).probe('claude', new AbortController().signal)).resolves.toBeUndefined(); }); -it('fails the planning check without a sign-in or with Codex, before any lock, worker or Docker work', async () => { +it('fails the planning check without a sign-in or with Codex, before the review\'s lock is taken', async () => { const service = review(), acquire = vi.spyOn(LeftoverLedger.prototype, 'acquire'); const unsigned = new PlanningAgent(service, { url: STUB, env: {} }); closers.push(() => unsigned.close()); diff --git a/web/planning.ts b/web/planning.ts index a907dd6a..2ad299f5 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -39,7 +39,9 @@ export function productionPlanning(config: ReviewConfig, options: { { recheckMs: options.recheckMs, waitMs: options.admitWaitMs }); check.start(); return { - provider, + // Checked once more as the request starts, in the turn after its recorded action: Settings may have changed in + // between, and the provider reads the vendor again. + provider: { invoke(request, signal) { check.assertPassed(); return provider.invoke(request, signal); } }, async describe(signal) { await check.admit(signal); return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { From 8975b292509f88af683af3cca9e675ecb3e0dab8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:11:27 -0700 Subject: [PATCH 07/39] Promise only the recheck that happens; wording and test margins (#164) Review round 5. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 28 +++++++++++----------- runner/planning.ts | 2 +- test/fixtures/question-worker-stub.ts | 2 +- test/planning-production.test.ts | 12 +++++----- web/planning.ts | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 8cfb8eaa..d9dc2051 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -96,20 +96,20 @@ reads the issue or records anything, and the request checks again just before it starts, so a vendor chosen during the issue read never runs unchecked. That last check runs inside the Store's transaction, so it starts no check itself: it refuses, and the check it needs starts on the next turn. The provider checks once -more as it invokes the agent, in the turn after the recorded action. A failed -check refuses the request with a `GuardRefusal` that starts "Planning is -unavailable:" and ends with the reason exactly as given (some end with a command -to copy), and so does a check still running after the wait; the server records the -refusal and starts no agent. Requests within a minute of a failure are refused -with it; the next request after that checks again. With no vendor chosen nothing -is checked, and the request is refused later, as before. Shutdown cancels a -running check and waits up to 20 s for it before closing the worker. If startup -fails after planning's setup, the process exits, and the next start's planning -recovery removes what the check left. Tests: `test/planning-production.test.ts` -("the planning check (#164)", with a check that passes, one that fails and one -that does not finish), `test/planning-worker.test.ts` (the check in planning's -worker, including a wrong answer) and `test/agent-planning.test.ts` (against the -vendor, real Docker). +more when the coordinator invokes it, after the recorded action; a refusal there +settles the request as failed. A failed check refuses the request with a +`GuardRefusal` that starts "Planning is unavailable:" and ends with the reason +exactly as given (some end with a command to copy), and so does a check still +running after the wait; the server records the refusal and starts no agent. +Requests within a minute of a failure are refused with it; the next request after +that checks again. With no vendor chosen nothing is checked, and the request is +refused later, as before. Shutdown cancels a running check and waits up to 20 s +for it before closing the worker. If startup fails after planning's setup, the +process exits, and the next start's planning recovery removes what the check left. +Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a +check that passes, one that fails and one that does not finish), +`test/planning-worker.test.ts` (the check in planning's worker, including a wrong +answer) and `test/agent-planning.test.ts` (against the vendor, real Docker). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/runner/planning.ts b/runner/planning.ts index fb6e1412..377f39a1 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -199,7 +199,7 @@ export class PlanningCheck { if (check.passed) return; // The reason comes last and as it is: some end with a command a person may copy. if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` - + 'it checks again on the next request after a minute, or at once when another agent is chosen in Settings. ' + + 'it checks again on the next request after a minute. ' + `The check said: ${messageOf(check.failure.error)}`); throw new GuardRefusal(STILL_CHECKING); } diff --git a/test/fixtures/question-worker-stub.ts b/test/fixtures/question-worker-stub.ts index bb779a6c..c6c44287 100644 --- a/test/fixtures/question-worker-stub.ts +++ b/test/fixtures/question-worker-stub.ts @@ -43,9 +43,9 @@ parentPort!.on('message', (message: WorkerRequest) => { const { prompt, provider, noteId, attemptId } = job; // The planning check (#164) passes with the schema's own answer. if (message.type === 'plan' && message.run.taskId.startsWith('planning-probe-')) { - // With this token the agent answers something else, which must fail the check. // The agent answers the check only when asked for it: the prompt names the answer and the schema requires it. const asked = message.run.prompt.includes('{"ok": true}') && message.run.schemaText.includes('"ok":{"const":true}'); + // With this token the agent answers something else, which must fail the check. const wrong = !asked || workerData?.credentials?.CLAUDE_CODE_OAUTH_TOKEN === 'probe-wrong-answer'; parentPort!.postMessage({ id: message.id, attemptId, ok: true, text: wrong ? '{"ok":false}' : '{"ok":true}' }); return; diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 2e6eb8aa..7ef0dbef 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -484,18 +484,18 @@ describe('the planning check (#164)', () => { it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; - // Claude's check passes at 600 ms, after the vendor changed; Codex's never ends. + // Claude's check passes at 500 ms, after the vendor changed; Codex's never ends. const check = new PlanningCheck(probed => new Promise(resolve => { - if (probed === 'claude') setTimeout(resolve, 600); - }), () => vendor, { waitMs: 1_000 }); + if (probed === 'claude') setTimeout(resolve, 500); + }), () => vendor, { waitMs: 600 }); const started = performance.now(), waiting = check.admit(signal()).catch((error: unknown) => error); await new Promise(resolve => setTimeout(resolve, 100)); vendor = 'codex'; - // One deadline ends the wait at 1 s; a fresh 1 s for Codex would end it at 1.6 s. + // One deadline ends the wait at 600 ms; a fresh 600 ms for Codex would end it at 1.1 s. const refusal = await waiting; expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toContain('still checking'); - expect(performance.now() - started).toBeLessThan(1_450); + expect(performance.now() - started).toBeLessThan(950); await check.close(10); }); @@ -524,7 +524,7 @@ describe('the planning check (#164)', () => { vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); return text(number); }); - // (The provider, too, checks again as the request starts; see the next test.) + // (The provider, too, checks again when it is invoked: "refuses to invoke an agent whose check has not passed".) const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); diff --git a/web/planning.ts b/web/planning.ts index 2ad299f5..d319065c 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -39,8 +39,8 @@ export function productionPlanning(config: ReviewConfig, options: { { recheckMs: options.recheckMs, waitMs: options.admitWaitMs }); check.start(); return { - // Checked once more as the request starts, in the turn after its recorded action: Settings may have changed in - // between, and the provider reads the vendor again. + // Checked once more when the coordinator invokes the provider, after the recorded action: Settings may have changed + // in between, and the provider reads the vendor again. A refusal here settles the request as failed. provider: { invoke(request, signal) { check.assertPassed(); return provider.invoke(request, signal); } }, async describe(signal) { await check.admit(signal); From 02e32b901a3daabd3f887885e3c9e83079f16224 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:18:54 -0700 Subject: [PATCH 08/39] Move a waiting request to a newer check at once; deferred check never throws (#164) Review round 6: a request waiting on a replaced check moves to the current one without waiting for the old one to stop; the deferred check reads the vendor inside its try; the refusal says 'Retry shortly' and names the configured recheck window; wider timing margins; the vendor test checks the leftovers record is gone. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 28 ++++++++++++--------- test/agent-planning.test.ts | 1 + test/planning-production.test.ts | 42 +++++++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 377f39a1..c260ca2d 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -91,7 +91,8 @@ export const PLANNING_CHECK_CLOSE_MS = 20_000; type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number }; abort: AbortController; replaced?: boolean }; const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); -const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`; +const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry shortly.`; +const duration = (ms: number) => ms === 60_000 ? 'a minute' : `${Math.max(1, Math.round(ms / 1_000))} s`; /** * Admission for planning requests (#164). The check runs for the vendor chosen in Settings: `start` begins it at * startup, and `admit`, called before anything about a request is recorded, waits for it. The last check is kept: a @@ -108,6 +109,8 @@ export class PlanningCheck { readonly #abort = new AbortController(); readonly #running = new Set>(); #current?: Check; + // Settles when `#current` is replaced, so a request waiting on an older check moves to the new one at once. + #replaced = Promise.withResolvers(); constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, options: { recheckMs?: number; waitMs?: number } = {}) { this.#probe = probe; this.#vendor = vendor; @@ -148,6 +151,8 @@ export class PlanningCheck { this.#running.add(done); void done.finally(() => this.#running.delete(done)); this.#current = check; + this.#replaced.resolve(); + this.#replaced = Promise.withResolvers(); return check; } /** @@ -168,14 +173,15 @@ export class PlanningCheck { }); if (signal.aborted) release(); let outcome: void | 'timeout'; - try { outcome = await Promise.race([check.done, stopped]); } + try { outcome = await Promise.race([check.done, this.#replaced.promise, stopped]); } finally { signal.removeEventListener('abort', release); clearTimeout(timer); } if (outcome === 'timeout') throw new GuardRefusal(STILL_CHECKING); - // Settings changed while this request waited, or its check was replaced and cancelled (the vendor changed and - // changed back): check the vendor it will run with, within the same wait. - if (this.#vendor() !== vendor || check.replaced) continue; + // Settings changed while this request waited, or its check was replaced (the vendor changed and changed back): + // check the vendor it will run with, within the same wait. + if (this.#vendor() !== vendor || check.replaced || check !== this.#current) continue; + if (!check.passed && !check.failure) continue; // This request's own check: a recheck started since must not replace the outcome it waited for. - return PlanningCheck.#verdict(check); + return this.#verdict(check); } } /** @@ -187,19 +193,19 @@ export class PlanningCheck { const vendor = this.#vendor(); if (!vendor) return; const covering = this.#covering(vendor); - if (covering) return PlanningCheck.#verdict(covering); + if (covering) return this.#verdict(covering); // The vendor chosen then, which Settings may have changed again since. setImmediate(() => { - const now = this.#vendor(); - try { if (now) this.#check(now); } catch { /* stopping: nothing to start */ } + // Never throws: an exception here would end the process. A Store that cannot be read starts nothing. + try { const now = this.#vendor(); if (now) this.#check(now); } catch { /* stopping, or no Store: nothing to start */ } }); throw new GuardRefusal(STILL_CHECKING); } - static #verdict(check: Check): void { + #verdict(check: Check): void { if (check.passed) return; // The reason comes last and as it is: some end with a command a person may copy. if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` - + 'it checks again on the next request after a minute. ' + + `it checks again on the next request after ${duration(this.#recheckMs)}. ` + `The check said: ${messageOf(check.failure.error)}`); throw new GuardRefusal(STILL_CHECKING); } diff --git a/test/agent-planning.test.ts b/test/agent-planning.test.ts index 3c28d7a6..fad268e6 100644 --- a/test/agent-planning.test.ts +++ b/test/agent-planning.test.ts @@ -56,5 +56,6 @@ describe('planning in the agent container', () => { const owner = owners.at(-1)!; for (const kind of ['container', 'volume', 'network']) expect(docker(kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '--quiet', '--filter', `label=io.codeboost.runner=${owner}`).stdout.trim(), kind).toBe(''); + expect(existsSync(`${realpathSync(service.config.database)}.planning-leftovers.json`)).toBe(false); }, 11 * 60_000); }); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 7ef0dbef..fc2eb26c 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -470,6 +470,36 @@ describe('the planning check (#164)', () => { expect(count).toBe(3); }); + it('moves a waiting request to a newer check at once, without waiting for the replaced one to stop', async () => { + let vendor: 'claude' | 'codex' = 'claude', count = 0; + // The first check ignores its cancellation, as a worker job stuck in a synchronous build does; the third passes. + const check = new PlanningCheck(() => { count += 1; return count === 3 ? Promise.resolve() : new Promise(() => undefined); }, + () => vendor, { waitMs: 5_000 }); + const started = performance.now(), waiting = check.admit(signal()); + vendor = 'codex'; + void check.admit(signal()).catch(() => undefined); + vendor = 'claude'; + void check.admit(signal()).catch(() => undefined); + await expect(waiting).resolves.toBeUndefined(); + expect(performance.now() - started).toBeLessThan(2_000); + await check.close(10); + }); + + it('never throws from the deferred check when the Store cannot be read', async () => { + let fail = false; + const probe = vi.fn(async () => undefined); + const check = new PlanningCheck(probe, () => { if (fail) throw new Error('database is closed'); return 'claude'; }); + const uncaught = vi.fn(); + process.on('uncaughtException', uncaught); + try { + expect(() => check.assertPassed()).toThrow('still checking'); + fail = true; + await new Promise(resolve => setImmediate(resolve)); + expect(uncaught).not.toHaveBeenCalled(); + expect(probe).not.toHaveBeenCalled(); + } finally { process.off('uncaughtException', uncaught); } + }); + it('starts no check after close', async () => { let vendor: 'claude' | 'codex' = 'claude'; const probe = vi.fn(async () => undefined), check = new PlanningCheck(probe, () => vendor); @@ -484,18 +514,18 @@ describe('the planning check (#164)', () => { it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; - // Claude's check passes at 500 ms, after the vendor changed; Codex's never ends. + // Claude's check passes at 1 s, after the vendor changed; Codex's never ends. const check = new PlanningCheck(probed => new Promise(resolve => { - if (probed === 'claude') setTimeout(resolve, 500); - }), () => vendor, { waitMs: 600 }); + if (probed === 'claude') setTimeout(resolve, 1_000); + }), () => vendor, { waitMs: 1_200 }); const started = performance.now(), waiting = check.admit(signal()).catch((error: unknown) => error); await new Promise(resolve => setTimeout(resolve, 100)); vendor = 'codex'; - // One deadline ends the wait at 600 ms; a fresh 600 ms for Codex would end it at 1.1 s. + // One deadline ends the wait at 1.2 s; a fresh 1.2 s for Codex would end it at 2.2 s. const refusal = await waiting; expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toContain('still checking'); - expect(performance.now() - started).toBeLessThan(950); + expect(performance.now() - started).toBeLessThan(1_900); await check.close(10); }); @@ -512,7 +542,7 @@ describe('the planning check (#164)', () => { const { deps, issueAccess } = checked(() => new Promise(resolve => { pass = resolve; }), 'claude', undefined, 50); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry in a moment.`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry shortly.`); expect(issueAccess).not.toHaveBeenCalled(); pass(); await expect(deps.describe(signal())).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); From d2b4a949278797b5c4355f060f9e1d3b4f62636b Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:27:08 -0700 Subject: [PATCH 09/39] Add a live Claude probe of the planning check; detach waits; wording (#164) Review round 7: the planning check against the real Claude service runs as a live probe in the Live probes workflow; a request no longer stays attached to the check after it settles; the still-checking refusal says the first check can take minutes; the shutdown waits are documented; wider timing margins. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/live-probes.yml | 2 +- docs/implementation/agent-isolation.md | 2 +- docs/implementation/ci.md | 7 +++-- docs/implementation/plan-library-alignment.md | 2 +- docs/implementation/planning-acceptance.md | 13 +++++---- runner/planning.ts | 28 +++++++++++-------- scripts/live-probes.ts | 2 ++ test/agent-planning.test.ts | 11 ++++++++ test/planning-production.test.ts | 12 ++++---- 9 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.github/workflows/live-probes.yml b/.github/workflows/live-probes.yml index 78e18b52..31728b85 100644 --- a/.github/workflows/live-probes.yml +++ b/.github/workflows/live-probes.yml @@ -42,7 +42,7 @@ jobs: env: CODEBOOST_RUN_AUTH_PROBES: '1' CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - run: npx vitest run --project docker -t '\(live Claude\)$' --reporter=default --reporter=json --outputFile=live-probes.json test/agent-container.test.ts test/agent-question.test.ts test/agent-supervisor.test.ts + run: npx vitest run --project docker -t '\(live Claude\)$' --reporter=default --reporter=json --outputFile=live-probes.json test/agent-container.test.ts test/agent-question.test.ts test/agent-supervisor.test.ts test/agent-planning.test.ts # Vitest exits 0 when it skips every probe, so check that each probe passed and nothing else ran. - name: Check that every probe passed run: node scripts/live-probes.ts check live-probes.json diff --git a/docs/implementation/agent-isolation.md b/docs/implementation/agent-isolation.md index 48460295..a1b14a77 100644 --- a/docs/implementation/agent-isolation.md +++ b/docs/implementation/agent-isolation.md @@ -41,7 +41,7 @@ Each row is a T9 requirement for the Docker suite. The suite fails if any row fa | Task and scratch byte and inode limits hold | `agent-container`: task capacity; scratch capacity for Codex and Claude (`/tmp`, `HOME`, `CODEX_HOME`, output directory) | | Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after | | Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) | -| The Claude startup probes read the schema and return bounded valid output through their documented channel | `agent-supervisor` live probes (credentials required): Claude in questions through its stdout envelope; Claude in planning returns a schema-constrained answer as bare JSON; `agent-container`: the authenticated Claude startup path. Codex has no live probe while it is refused (#93). | +| The Claude startup probes read the schema and return bounded valid output through their documented channel | `agent-supervisor` live probes (credentials required): Claude in questions through its stdout envelope; Claude in planning returns a schema-constrained answer as bare JSON; `agent-planning`: the planning check (#164) passes with a real sign-in; `agent-container`: the authenticated Claude startup path. Codex has no live probe while it is refused (#93). | | Nothing can be written beneath a gitlink, and execute and fix launch only on a clean tree (#81) | `agent-container`: the gitlink probe (writes, `mkdir`, `rmdir` and `mv` at every gitlink fail; the rest of `/work` stays writable); a pre-populated nested checkout, a restart with an untracked symlink parent and a restart with an occupied add destination are refused; a gitlink name Docker cannot mount, more than 256 gitlinks and an unreadable tree are refused; each check serves one profile over its own storage and head; the validator rejects a gitlink mount that is missing, writable, or of another size or mode; every directory above a nested gitlink is pinned, writable and `nosuid,nodev`, so renaming one fails (#99); the validator rejects a pin that is missing, read-only, at another subpath, from another volume, `volume-nocopy`, or not a volume | | Hostile input stays inside the boundary | `agent-container`: repositories with links that leave the checkout are refused, links inside the checkout still work, oversized repositories fail closed; `agent-policy`: option-like prompts; `agent-proxy`: hostile CONNECT traffic; `agent-supervisor`: hostile output | diff --git a/docs/implementation/ci.md b/docs/implementation/ci.md index 27524937..bc66b44a 100644 --- a/docs/implementation/ci.md +++ b/docs/implementation/ci.md @@ -10,7 +10,7 @@ settings are not stored in the repository. | --- | --- | --- | --- | | `.github/workflows/ci.yml` | `test` | every pull request; pushes to `main` and `codex/**` | Type check; every top-level `test/*.test.ts` file except the Docker-backed files; the Playwright browser suite | | `.github/workflows/agent-isolation.yml` | `real-docker` | every pull request; pushes to `main` and `codex/**` | Type check; the complete Vitest suite against real Docker | -| `.github/workflows/live-probes.yml` | `live-probes` | started by hand (`workflow_dispatch`); every night at 06:23 UTC | The four live Claude startup probes only | +| `.github/workflows/live-probes.yml` | `live-probes` | started by hand (`workflow_dispatch`); every night at 06:23 UTC | The five live Claude startup probes only | `test` and `real-docker` have no path filters. They run on every pull request, so both can be required checks. A required check that a path filter skips @@ -112,8 +112,9 @@ The job: missing. A missing secret never gives a passing run. 2. Runs the Docker Vitest project with `CODEBOOST_RUN_AUTH_PROBES=1`. It selects only the tests whose titles end in `(live Claude)`, in - `test/agent-container.test.ts`, `test/agent-question.test.ts` and - `test/agent-supervisor.test.ts`. The files run one at a time. + `test/agent-container.test.ts`, `test/agent-question.test.ts`, + `test/agent-supervisor.test.ts` and `test/agent-planning.test.ts` (the + planning check, #164). The files run one at a time. 3. Runs `node scripts/live-probes.ts check` on the Vitest JSON report. Vitest exits with success when it skips every test, so this step fails unless each probe listed in `scripts/live-probes.ts` passed and no other test ran. diff --git a/docs/implementation/plan-library-alignment.md b/docs/implementation/plan-library-alignment.md index 5f6b285d..8b9191ec 100644 --- a/docs/implementation/plan-library-alignment.md +++ b/docs/implementation/plan-library-alignment.md @@ -24,7 +24,7 @@ Issue #6 tracked the v1 plan library contract and the runner obligations that ca | Output limits at the exact byte | **This change** (tests; the supervisor already enforced the limits) | Supervisor enforcement: `test/agent-supervisor.test.ts` (real Docker). Decoder behaviour only, not a limit: the Claude decoder handles an envelope of exactly the stdout ceiling, and the 1 MiB parser limit applies to the decoded answer at the exact byte: `test/agent-adapter.test.ts`. At #6 the decoder re-serialized the answer, so the limit applied to the re-encoded text. *Later:* #166 returns the exact text of `structured_output` in the CLI's envelope, so the limit applies to those bytes | | Prompt byte budget before spawn | Done before this change | `MAX_PROMPT_BYTES` in `core/planning-author.ts`; `test/planning-author.test.ts` ("accepts exactly the prompt byte limit and rejects the next byte without truncation") | | Model budget before spawn | Moved to #163 | — | -| Planning profile probed at startup | Moved to #164. *Later:* fixed in #164, checked at startup and before the first request of each vendor | `runner/planning.ts` (`PlanningAgent.probe`, `PlanningCheck`), `web/planning.ts`; `test/planning-production.test.ts` ("the planning check (#164)"), `test/planning-worker.test.ts`; `docs/implementation/planning-acceptance.md` | +| Planning profile probed at startup | Moved to #164. *Later:* fixed in #164, checked at startup, on the first request after the vendor changes and on the first request a minute after a failure | `runner/planning.ts` (`PlanningAgent.probe`, `PlanningCheck`), `web/planning.ts`; `test/planning-production.test.ts` ("the planning check (#164)"), `test/planning-worker.test.ts`, `test/agent-planning.test.ts` (live Claude probe); `docs/implementation/planning-acceptance.md` | | Docker daemon log limits | Moved to #165. *Later:* fixed in #165 with `--log-driver=none`, checked before launch | `agents/container/profile.ts`, `agents/container/run.ts`; `test/agent-container.test.ts` ("creates the agent container with no Docker daemon log", "refuses an agent container with %s at validation") (real Docker), `test/agent-daemon-log.test.ts`. Agent containers only; the runner's other containers (task-storage scripts, the keeper, the egress proxy) keep the daemon's default log | | Claude structured output under the v1 parsing contract | Moved to #166 (found during this change). *Later:* fixed in #166 | `agents/adapters/claude.ts`; `test/agent-adapter.test.ts` ("lets draft validation refuse %s in a structured answer") | diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index d9dc2051..00f34545 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -104,12 +104,15 @@ running after the wait; the server records the refusal and starts no agent. Requests within a minute of a failure are refused with it; the next request after that checks again. With no vendor chosen nothing is checked, and the request is refused later, as before. Shutdown cancels a running check and waits up to 20 s -for it before closing the worker. If startup fails after planning's setup, the -process exits, and the next start's planning recovery removes what the check left. -Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a -check that passes, one that fails and one that does not finish), +for it before closing the worker; that wait comes after the server's own 20 s +grace for planning requests, so the two add up. If startup fails after planning's +setup, the process exits, and the next start's planning recovery removes what the +check left. Tests: `test/planning-production.test.ts` ("the planning check +(#164)", with a check that passes, one that fails and one that does not finish), `test/planning-worker.test.ts` (the check in planning's worker, including a wrong -answer) and `test/agent-planning.test.ts` (against the vendor, real Docker). +answer) and `test/agent-planning.test.ts` (against the vendor, real Docker: an +invalid sign-in fails, and a live probe in the Live probes workflow passes with a +real one). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/runner/planning.ts b/runner/planning.ts index c260ca2d..dba4c7af 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -91,7 +91,8 @@ export const PLANNING_CHECK_CLOSE_MS = 20_000; type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number }; abort: AbortController; replaced?: boolean }; const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); -const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry shortly.`; +const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. The first check after ` + + 'codeboost starts can take several minutes while the agent image is built. Retry shortly.'; const duration = (ms: number) => ms === 60_000 ? 'a minute' : `${Math.max(1, Math.round(ms / 1_000))} s`; /** * Admission for planning requests (#164). The check runs for the vendor chosen in Settings: `start` begins it at @@ -109,8 +110,9 @@ export class PlanningCheck { readonly #abort = new AbortController(); readonly #running = new Set>(); #current?: Check; - // Settles when `#current` is replaced, so a request waiting on an older check moves to the new one at once. - #replaced = Promise.withResolvers(); + // Tells a request waiting on an older check that `#current` was replaced, so it moves to the new one at once. A + // listener, removed when the wait ends, so no settled request stays attached. + readonly #events = new EventTarget(); constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, options: { recheckMs?: number; waitMs?: number } = {}) { this.#probe = probe; this.#vendor = vendor; @@ -151,8 +153,7 @@ export class PlanningCheck { this.#running.add(done); void done.finally(() => this.#running.delete(done)); this.#current = check; - this.#replaced.resolve(); - this.#replaced = Promise.withResolvers(); + this.#events.dispatchEvent(new Event('replaced')); return check; } /** @@ -166,15 +167,20 @@ export class PlanningCheck { const vendor = this.#vendor(); if (!vendor) return; const check = this.#check(vendor); - let release!: () => void, timer: ReturnType | undefined; - const stopped = new Promise<'timeout'>((resolve, reject) => { + // A settled check gives its verdict at once. + if (check.passed || check.failure) return this.#verdict(check); + let release!: () => void, moved!: () => void, timer: ReturnType | undefined; + const stopped = new Promise<'timeout' | 'replaced'>((resolve, reject) => { release = () => reject(signal.reason); signal.addEventListener('abort', release, { once: true }); + moved = () => resolve('replaced'); this.#events.addEventListener('replaced', moved, { once: true }); timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - performance.now())); }); if (signal.aborted) release(); - let outcome: void | 'timeout'; - try { outcome = await Promise.race([check.done, this.#replaced.promise, stopped]); } - finally { signal.removeEventListener('abort', release); clearTimeout(timer); } + let outcome: void | 'timeout' | 'replaced'; + try { outcome = await Promise.race([check.done, stopped]); } + finally { + signal.removeEventListener('abort', release); this.#events.removeEventListener('replaced', moved); clearTimeout(timer); + } if (outcome === 'timeout') throw new GuardRefusal(STILL_CHECKING); // Settings changed while this request waited, or its check was replaced (the vendor changed and changed back): // check the vendor it will run with, within the same wait. @@ -194,7 +200,7 @@ export class PlanningCheck { if (!vendor) return; const covering = this.#covering(vendor); if (covering) return this.#verdict(covering); - // The vendor chosen then, which Settings may have changed again since. + // Checks the vendor chosen when this runs, which Settings may have changed since the refusal. setImmediate(() => { // Never throws: an exception here would end the process. A Store that cannot be read starts nothing. try { const now = this.#vendor(); if (now) this.#check(now); } catch { /* stopping, or no Store: nothing to start */ } diff --git a/scripts/live-probes.ts b/scripts/live-probes.ts index 9547a76a..a2929270 100644 --- a/scripts/live-probes.ts +++ b/scripts/live-probes.ts @@ -24,6 +24,8 @@ export const LIVE_PROBES = [ { file: 'test/agent-supervisor.test.ts', name: 'container invocation supervisor returns a schema-constrained Claude planning answer as bare JSON from the ' + 'mounted schema (live Claude)' }, + { file: 'test/agent-planning.test.ts', + name: 'planning in the agent container passes the planning check with a real sign-in (live Claude)' }, ] as const; export const LIVE_PROBE_FILES = [...new Set(LIVE_PROBES.map(probe => probe.file))]; diff --git a/test/agent-planning.test.ts b/test/agent-planning.test.ts index fad268e6..be069011 100644 --- a/test/agent-planning.test.ts +++ b/test/agent-planning.test.ts @@ -58,4 +58,15 @@ describe('planning in the agent container', () => { expect(docker(kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '--quiet', '--filter', `label=io.codeboost.runner=${owner}`).stdout.trim(), kind).toBe(''); expect(existsSync(`${realpathSync(service.config.database)}.planning-leftovers.json`)).toBe(false); }, 11 * 60_000); + + // #164: the check's own prompt and schema, answered by the real Claude service. Runs in the Live probes workflow. + it.runIf(process.env.CODEBOOST_RUN_AUTH_PROBES === '1')('passes the planning check with a real sign-in (live Claude)', async () => { + const root = mkdtempSync(join(tmpdir(), 'agent-planning-')); roots.push(root); + const service = new ReviewService(createDemo(join(root, 'demo'))); services.push(service); + owners.push(service.store.planningOwnerToken(statSync(realpathSync(service.config.database), { bigint: true }))); + const agent = new PlanningAgent(service, { env: { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN } }); + try { await expect(agent.probe('claude', new AbortController().signal)).resolves.toBeUndefined(); } + finally { await agent.close(); } + expect(existsSync(`${realpathSync(service.config.database)}.planning-leftovers.json`)).toBe(false); + }, 11 * 60_000); }); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index fc2eb26c..a052bdef 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -514,18 +514,18 @@ describe('the planning check (#164)', () => { it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; - // Claude's check passes at 1 s, after the vendor changed; Codex's never ends. + // Claude's check passes at 2 s, after the vendor changed; Codex's never ends. const check = new PlanningCheck(probed => new Promise(resolve => { - if (probed === 'claude') setTimeout(resolve, 1_000); - }), () => vendor, { waitMs: 1_200 }); + if (probed === 'claude') setTimeout(resolve, 2_000); + }), () => vendor, { waitMs: 2_200 }); const started = performance.now(), waiting = check.admit(signal()).catch((error: unknown) => error); await new Promise(resolve => setTimeout(resolve, 100)); vendor = 'codex'; - // One deadline ends the wait at 1.2 s; a fresh 1.2 s for Codex would end it at 2.2 s. + // One deadline ends the wait at 2.2 s; a fresh 2.2 s for Codex would end it at 4.2 s. const refusal = await waiting; expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toContain('still checking'); - expect(performance.now() - started).toBeLessThan(1_900); + expect(performance.now() - started).toBeLessThan(3_500); await check.close(10); }); @@ -542,7 +542,7 @@ describe('the planning check (#164)', () => { const { deps, issueAccess } = checked(() => new Promise(resolve => { pass = resolve; }), 'claude', undefined, 50); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. Retry shortly.`); + expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent\\. .* Retry shortly\\.$`)); expect(issueAccess).not.toHaveBeenCalled(); pass(); await expect(deps.describe(signal())).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); From 50b401738af857c5d5f19d05708a388403fac754 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:34:18 -0700 Subject: [PATCH 10/39] Say when planning recovery runs; no listener warning for many waiters (#164) Review round 8: docs and comments say the planning check at startup is the process's first planning job; waiting requests no longer trigger Node's listener-leak warning; the stopping refusal carries the unavailable prefix. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 10 +++++++--- runner/question-leftovers.ts | 3 ++- test/planning-production.test.ts | 17 ++++++++++++++++- web/planning.ts | 5 +++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 75109e79..2d4e30b2 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner before the first planning request of the next process, not at startup; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job: the planning check at startup when a vendor is chosen (#164, which also takes planning's lock from then until close), otherwise the first planning request; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index dba4c7af..9150f4f7 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { setMaxListeners } from 'node:events'; import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts'; import { GuardRefusal } from './lifecycle.ts'; import { AgentWorker, type Provider } from './question-agent.ts'; @@ -30,8 +31,9 @@ export function isPlanningProbeAnswer(text: string): boolean { /** * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous * setup never blocks the server. The worker's TMPDIR is a planning root recorded in a ledger beside the review - * database, and every Docker object it creates carries the database's planning owner. The first request of a process - * removes what earlier sessions left, by that owner only, so Ask's and the runner's agents are never touched. + * database, and every Docker object it creates carries the database's planning owner. The first job of a process (the + * planning check at startup, or the first request) removes what earlier sessions left, by that owner only, so Ask's + * and the runner's agents are never touched. * * The code is the review's repository at the current snapshot's head; E3 discards a reply whose snapshot or revision * changed meanwhile. The vendor is the one chosen in Settings for Ask; Codex is refused (#93). @@ -115,6 +117,8 @@ export class PlanningCheck { readonly #events = new EventTarget(); constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, options: { recheckMs?: number; waitMs?: number } = {}) { + // One listener per waiting request and per running check, each removed when it ends: many at once are no leak. + setMaxListeners(0, this.#events, this.#abort.signal); this.#probe = probe; this.#vendor = vendor; this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; this.#waitMs = options.waitMs ?? PLANNING_ADMIT_WAIT_MS; } @@ -133,7 +137,7 @@ export class PlanningCheck { #check(vendor: Provider) { const covering = this.#covering(vendor); if (covering) return covering; - if (this.#abort.signal.aborted) throw new GuardRefusal('Planning is stopping.'); + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); // A check for another vendor no longer admits anything: stop it, so replaced checks do not pile up in the worker. if (this.#current && !this.#current.passed && !this.#current.failure) { this.#current.replaced = true; diff --git a/runner/question-leftovers.ts b/runner/question-leftovers.ts index c3163789..3e780f13 100644 --- a/runner/question-leftovers.ts +++ b/runner/question-leftovers.ts @@ -190,7 +190,8 @@ function parse(text: string, naming: WorkerNaming): LedgerRecord { /** * Durable record of a feature's host copies (worker roots) that outlived their worker, and the review's lock for that * feature. Docker leftovers need no record: the feature labels them with the review's own owner token for it, and the - * first request of each process removes them through lane D's scoped recovery (#51 item 4, #65). Ask and planning each + * first job of each process (for planning, usually its startup check, #164) removes them through lane D's scoped + * recovery (#51 item 4, #65). Ask and planning each * have their own ledger, lock and owner, so neither one's recovery or refusals affect the other (#117). */ export class LeftoverLedger { diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index a052bdef..41c2c6fa 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -500,13 +500,28 @@ describe('the planning check (#164)', () => { } finally { process.off('uncaughtException', uncaught); } }); + it('lets many requests wait on one check without a listener warning', async () => { + let pass!: () => void; + const check = new PlanningCheck(() => new Promise(resolve => { pass = resolve; }), () => 'claude'); + const warnings: string[] = [], warned = (warning: Error) => warnings.push(warning.name); + process.on('warning', warned); + try { + const waiting = Array.from({ length: 20 }, () => check.admit(signal())); + await new Promise(resolve => setTimeout(resolve, 10)); + pass(); + await Promise.all(waiting); + await new Promise(resolve => setImmediate(resolve)); + expect(warnings).not.toContain('MaxListenersExceededWarning'); + } finally { process.off('warning', warned); } + }); + it('starts no check after close', async () => { let vendor: 'claude' | 'codex' = 'claude'; const probe = vi.fn(async () => undefined), check = new PlanningCheck(probe, () => vendor); check.start(); await check.close(); vendor = 'codex'; - await expect(check.admit(signal())).rejects.toThrow('Planning is stopping.'); + await expect(check.admit(signal())).rejects.toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); expect(() => check.assertPassed()).toThrow('still checking'); await new Promise(resolve => setImmediate(resolve)); expect(probe).toHaveBeenCalledTimes(1); diff --git a/web/planning.ts b/web/planning.ts index d319065c..a40e327d 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -10,8 +10,9 @@ export { ISSUE_READ_TIMEOUT_MS } from '../github/issues.ts'; * Production planning (#117): on for a review with a github block, never in a demo. It needs no runner block, since * planning only reads code, as Ask does. The issue text is read from GitHub for each request (current collaborators' * comments, or every comment under explicit author-bound trust), bounded like an execute prompt; approved lessons stay - * empty until lessons exist (L1 to L4). The planning check (#164) starts with the server and must pass before a request - * reads the issue or records anything; a failed check refuses the request with its reason. + * empty until lessons exist (L1 to L4). When a vendor is chosen in Settings, the planning check (#164) starts with the + * server and must pass before a request reads the issue or records anything; a failed check refuses the request with + * its reason. */ export function productionPlanning(config: ReviewConfig, options: { /** The single-runner lock's check that the database path still names the locked file (runner/recovery.ts). */ From e237aab8b0060b1d9fc630c2dc9902d25cb342f3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:43:35 -0700 Subject: [PATCH 11/39] Reject from the wrapped invoke; say recovery needs a sign-in; wording (#164) Review round 9: the wrapped provider rejects instead of throwing; docs and comments say the startup check recovers only when it can sign in, holds the lock until the process ends, and builds the image beside the runner's; assertPassed after close says planning is stopping; wider timing margins. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 7 ++++--- runner/question-leftovers.ts | 4 ++-- test/planning-production.test.ts | 16 +++++++++------- web/planning.ts | 2 +- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 2d4e30b2..ec691fb0 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job: the planning check at startup when a vendor is chosen (#164, which also takes planning's lock from then until close), otherwise the first planning request; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until the process ends, and builds the agent image in planning's worker while the runner builds its own), otherwise the first planning request that can sign in; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index 9150f4f7..997c26f4 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -31,9 +31,9 @@ export function isPlanningProbeAnswer(text: string): boolean { /** * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous * setup never blocks the server. The worker's TMPDIR is a planning root recorded in a ledger beside the review - * database, and every Docker object it creates carries the database's planning owner. The first job of a process (the - * planning check at startup, or the first request) removes what earlier sessions left, by that owner only, so Ask's - * and the runner's agents are never touched. + * database, and every Docker object it creates carries the database's planning owner. The first job of a process that + * can sign in (the planning check at startup, or the first request) removes what earlier sessions left, by that owner + * only, so Ask's and the runner's agents are never touched. * * The code is the review's repository at the current snapshot's head; E3 discards a reply whose snapshot or revision * changed meanwhile. The vendor is the one chosen in Settings for Ask; Codex is refused (#93). @@ -204,6 +204,7 @@ export class PlanningCheck { if (!vendor) return; const covering = this.#covering(vendor); if (covering) return this.#verdict(covering); + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); // Checks the vendor chosen when this runs, which Settings may have changed since the refusal. setImmediate(() => { // Never throws: an exception here would end the process. A Store that cannot be read starts nothing. diff --git a/runner/question-leftovers.ts b/runner/question-leftovers.ts index 3e780f13..6ed99608 100644 --- a/runner/question-leftovers.ts +++ b/runner/question-leftovers.ts @@ -190,8 +190,8 @@ function parse(text: string, naming: WorkerNaming): LedgerRecord { /** * Durable record of a feature's host copies (worker roots) that outlived their worker, and the review's lock for that * feature. Docker leftovers need no record: the feature labels them with the review's own owner token for it, and the - * first job of each process (for planning, usually its startup check, #164) removes them through lane D's scoped - * recovery (#51 item 4, #65). Ask and planning each + * first job of each process that can sign in (for planning, usually its startup check, #164) removes them through lane + * D's scoped recovery (#51 item 4, #65). Ask and planning each * have their own ledger, lock and owner, so neither one's recovery or refusals affect the other (#117). */ export class LeftoverLedger { diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 41c2c6fa..69d65f87 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -409,7 +409,9 @@ describe('the planning check (#164)', () => { await made.deps.describe(signal()); vi.spyOn(made.service.store, 'questionProvider').mockReturnValue('codex'); const request = {} as Parameters[0]; - expect(() => made.deps.provider.invoke(request, signal())).toThrow('still checking'); + // A rejection, as `AuthorProvider.invoke` promises: E3 then settles the request as failed with this reason + // (test/planning-suggestions.test.ts covers a provider whose invoke fails). + await expect(made.deps.provider.invoke(request, signal())).rejects.toThrow('still checking'); expect(made.agent.invoke).not.toHaveBeenCalled(); }); @@ -522,25 +524,25 @@ describe('the planning check (#164)', () => { await check.close(); vendor = 'codex'; await expect(check.admit(signal())).rejects.toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); - expect(() => check.assertPassed()).toThrow('still checking'); + expect(() => check.assertPassed()).toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); await new Promise(resolve => setImmediate(resolve)); expect(probe).toHaveBeenCalledTimes(1); }); it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; - // Claude's check passes at 2 s, after the vendor changed; Codex's never ends. + // Claude's check passes at 3 s, after the vendor changed; Codex's never ends. const check = new PlanningCheck(probed => new Promise(resolve => { - if (probed === 'claude') setTimeout(resolve, 2_000); - }), () => vendor, { waitMs: 2_200 }); + if (probed === 'claude') setTimeout(resolve, 3_000); + }), () => vendor, { waitMs: 3_200 }); const started = performance.now(), waiting = check.admit(signal()).catch((error: unknown) => error); await new Promise(resolve => setTimeout(resolve, 100)); vendor = 'codex'; - // One deadline ends the wait at 2.2 s; a fresh 2.2 s for Codex would end it at 4.2 s. + // One deadline ends the wait at 3.2 s; a fresh 3.2 s for Codex would end it at 6.2 s. const refusal = await waiting; expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toContain('still checking'); - expect(performance.now() - started).toBeLessThan(3_500); + expect(performance.now() - started).toBeLessThan(5_000); await check.close(10); }); diff --git a/web/planning.ts b/web/planning.ts index a40e327d..1e2dd88e 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -42,7 +42,7 @@ export function productionPlanning(config: ReviewConfig, options: { return { // Checked once more when the coordinator invokes the provider, after the recorded action: Settings may have changed // in between, and the provider reads the vendor again. A refusal here settles the request as failed. - provider: { invoke(request, signal) { check.assertPassed(); return provider.invoke(request, signal); } }, + provider: { async invoke(request, signal) { check.assertPassed(); return provider.invoke(request, signal); } }, async describe(signal) { await check.admit(signal); return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { From 44c3fae58c89839312c2b797196617564fde27b1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:51:23 -0700 Subject: [PATCH 12/39] Exact wording on the planning lock, the image build and the verdict (#164) Review round 10. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index ec691fb0..8f9974f9 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until the process ends, and builds the agent image in planning's worker while the runner builds its own), otherwise the first planning request that can sign in; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first planning request that can sign in; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index 997c26f4..9d4ed47f 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -190,7 +190,7 @@ export class PlanningCheck { // check the vendor it will run with, within the same wait. if (this.#vendor() !== vendor || check.replaced || check !== this.#current) continue; if (!check.passed && !check.failure) continue; - // This request's own check: a recheck started since must not replace the outcome it waited for. + // This check's own verdict: calling #check again could start a recheck once the window has passed. return this.#verdict(check); } } From 8f08e4aca653e4857f587139d5e4a4d48f3bb971 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 19:58:51 -0700 Subject: [PATCH 13/39] First job is always a check; exact shutdown wait; live probe checks Docker (#164) Review round 11. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 4 ++-- runner/planning.ts | 6 +++--- test/agent-planning.test.ts | 3 +++ test/planning-production.test.ts | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 8f9974f9..7be1b288 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,14 +361,14 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first planning request that can sign in; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | | `POST /api/plan/drafts/:id/cancel` | as suggestion cancel | request is a draft; a `pending` or `ready` one is cancelled, any other state is returned unchanged | | `POST /api/plan/drafts/:id/apply` | `Store.applyDraft`: imports the draft as revision + 1 with every import check | request is a draft, `ready` and bound to the current revision and snapshot | -Live planning runs for a non-demo review with a `github` block (#117): `web/planning.ts` reads the issue from GitHub before each start, and `runner/planning.ts` runs the request in planning's worker. E3's suggestion timer and the request share one ten-minute budget (`PLANNING_BUDGET_MS`). At shutdown E3 aborts every request; one that has not settled within 20 seconds (`PLANNING_SHUTDOWN_GRACE_MS`, as Ask) is ended by abandoning planning's worker. A start replays its recorded outcome before reading GitHub; a GitHub failure is a 502 and is recorded for replay like a refusal (`web/server.ts`; `test/planning-production.test.ts`). Without a `github` block, or in a demo, the server returns "Planning agent not available yet". Drafts follow the same rules (#124). A draft and a suggestion are both rows of `requests`, told apart by `mode` (schema v11); each kind's routes refuse the other's IDs. At startup, once the single-runner lock is verified to name the database (`web/planning.ts`), every request an earlier process left `pending` is failed ("The server stopped before this request finished. Ask again."): its provider ran in that process's planning worker, so nothing can complete it. +Live planning runs for a non-demo review with a `github` block (#117): `web/planning.ts` reads the issue from GitHub before each start, and `runner/planning.ts` runs the request in planning's worker. E3's suggestion timer and the request share one ten-minute budget (`PLANNING_BUDGET_MS`). At shutdown E3 aborts every request; one that has not settled within 20 seconds (`PLANNING_SHUTDOWN_GRACE_MS`, as Ask) is ended by abandoning planning's worker, after the planning check (#164) has had up to 20 seconds more to stop. A start replays its recorded outcome before reading GitHub; a GitHub failure is a 502 and is recorded for replay like a refusal (`web/server.ts`; `test/planning-production.test.ts`). Without a `github` block, or in a demo, the server returns "Planning agent not available yet". Drafts follow the same rules (#124). A draft and a suggestion are both rows of `requests`, told apart by `mode` (schema v11); each kind's routes refuse the other's IDs. At startup, once the single-runner lock is verified to name the database (`web/planning.ts`), every request an earlier process left `pending` is failed ("The server stopped before this request finished. Ask again."): its provider ran in that process's planning worker, so nothing can complete it. ## Feedback-event contract (for lane J) diff --git a/runner/planning.ts b/runner/planning.ts index 9d4ed47f..0d68800e 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -32,8 +32,8 @@ export function isPlanningProbeAnswer(text: string): boolean { * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous * setup never blocks the server. The worker's TMPDIR is a planning root recorded in a ledger beside the review * database, and every Docker object it creates carries the database's planning owner. The first job of a process that - * can sign in (the planning check at startup, or the first request) removes what earlier sessions left, by that owner - * only, so Ask's and the runner's agents are never touched. + * can sign in (the planning check at startup, or the first check a request starts) removes what earlier sessions left, + * by that owner only, so Ask's and the runner's agents are never touched. * * The code is the review's repository at the current snapshot's head; E3 discards a reply whose snapshot or revision * changed meanwhile. The vendor is the one chosen in Settings for Ask; Codex is refused (#93). @@ -94,7 +94,7 @@ type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: abort: AbortController; replaced?: boolean }; const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. The first check after ` - + 'codeboost starts can take several minutes while the agent image is built. Retry shortly.'; + + 'codeboost starts can take several minutes while the agent image is built. Retry in a minute.'; const duration = (ms: number) => ms === 60_000 ? 'a minute' : `${Math.max(1, Math.round(ms / 1_000))} s`; /** * Admission for planning requests (#164). The check runs for the vendor chosen in Settings: `start` begins it at diff --git a/test/agent-planning.test.ts b/test/agent-planning.test.ts index be069011..a0b1901c 100644 --- a/test/agent-planning.test.ts +++ b/test/agent-planning.test.ts @@ -67,6 +67,9 @@ describe('planning in the agent container', () => { const agent = new PlanningAgent(service, { env: { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN } }); try { await expect(agent.probe('claude', new AbortController().signal)).resolves.toBeUndefined(); } finally { await agent.close(); } + const owner = owners.at(-1)!; + for (const kind of ['container', 'volume', 'network']) + expect(docker(kind, 'ls', ...(kind === 'container' ? ['-a'] : []), '--quiet', '--filter', `label=io.codeboost.runner=${owner}`).stdout.trim(), kind).toBe(''); expect(existsSync(`${realpathSync(service.config.database)}.planning-leftovers.json`)).toBe(false); }, 11 * 60_000); }); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 69d65f87..d1d632ed 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -559,7 +559,7 @@ describe('the planning check (#164)', () => { const { deps, issueAccess } = checked(() => new Promise(resolve => { pass = resolve; }), 'claude', undefined, 50); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); - expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent\\. .* Retry shortly\\.$`)); + expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent\\. .* Retry in a minute\\.$`)); expect(issueAccess).not.toHaveBeenCalled(); pass(); await expect(deps.describe(signal())).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); From 86dcf82417365210304bbe9a3ca8ff3f77e233fc Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 20:07:15 -0700 Subject: [PATCH 14/39] Bound each check; no recheck promise for a restart; exact claims (#164) Review round 12: a check that has not settled within a real request's budget and a minute fails and is cancelled, so the recheck applies; a reason that holds until a restart gets no recheck promise; the invoke-time check is described as defence in depth; startup failure exits only in the CLI; test names and docs say what they show. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 25 ++++++++-------- runner/planning.ts | 33 ++++++++++++++++++---- test/planning-production.test.ts | 25 ++++++++++++++-- web/planning.ts | 10 ++++--- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 00f34545..f0dc3fbf 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -96,17 +96,20 @@ reads the issue or records anything, and the request checks again just before it starts, so a vendor chosen during the issue read never runs unchecked. That last check runs inside the Store's transaction, so it starts no check itself: it refuses, and the check it needs starts on the next turn. The provider checks once -more when the coordinator invokes it, after the recorded action; a refusal there -settles the request as failed. A failed check refuses the request with a -`GuardRefusal` that starts "Planning is unavailable:" and ends with the reason -exactly as given (some end with a command to copy), and so does a check still -running after the wait; the server records the refusal and starts no agent. -Requests within a minute of a failure are refused with it; the next request after -that checks again. With no vendor chosen nothing is checked, and the request is -refused later, as before. Shutdown cancels a running check and waits up to 20 s -for it before closing the worker; that wait comes after the server's own 20 s -grace for planning requests, so the two add up. If startup fails after planning's -setup, the process exits, and the next start's planning recovery removes what the +more when it is invoked, as defence in depth (E3 invokes it in a microtask right +after the recorded action); a refusal there settles the request as failed. A +failed check refuses the request with a `GuardRefusal` that starts "Planning is +unavailable:" and ends with the reason exactly as given (some end with a command +to copy), and a check still running after the wait is refused with the same +prefix; the server records the refusal and starts no agent. Requests within a +minute of a failure are refused with it; the next request after that checks again. +With no vendor chosen nothing is checked, and the request is refused later, as +before. Shutdown cancels a running check and waits up to 20 s for it before +closing the worker; that wait comes after the server's own 20 s grace for planning +requests, so the two add up. A check that has not settled within a real request's +budget and a minute fails, so the recheck applies to it. A reason that holds until +codeboost restarts gets no promise of a recheck. If startup fails after planning's +setup, the CLI exits, and the next start's planning recovery removes what the check left. Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a check that passes, one that fails and one that does not finish), `test/planning-worker.test.ts` (the check in planning's worker, including a wrong diff --git a/runner/planning.ts b/runner/planning.ts index 0d68800e..32e08c6b 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -95,7 +95,15 @@ type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. The first check after ` + 'codeboost starts can take several minutes while the agent image is built. Retry in a minute.'; -const duration = (ms: number) => ms === 60_000 ? 'a minute' : `${Math.max(1, Math.round(ms / 1_000))} s`; +const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : `${Math.round(ms / 1_000)} s`; +// An abandoned worker (question-agent.ts) and unconfirmed cleanup (planning-provider.ts) refuse every job until codeboost +// restarts, so a recheck cannot help. +const UNTIL_RESTART = /until codeboost restarts/; +/** + * How long one check may take before it fails, so a check that never settles (a stuck recovery, say) cannot keep + * planning "still checking": a real request's budget, as the probe has, and a minute for the worker to settle. + */ +export const PLANNING_CHECK_TIMEOUT_MS = PLANNING_BUDGET_MS + 60_000; /** * Admission for planning requests (#164). The check runs for the vendor chosen in Settings: `start` begins it at * startup, and `admit`, called before anything about a request is recorded, waits for it. The last check is kept: a @@ -109,6 +117,7 @@ export class PlanningCheck { readonly #vendor: () => Provider | null; readonly #recheckMs: number; readonly #waitMs: number; + readonly #timeoutMs: number; readonly #abort = new AbortController(); readonly #running = new Set>(); #current?: Check; @@ -116,11 +125,12 @@ export class PlanningCheck { // listener, removed when the wait ends, so no settled request stays attached. readonly #events = new EventTarget(); constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, - options: { recheckMs?: number; waitMs?: number } = {}) { + options: { recheckMs?: number; waitMs?: number; timeoutMs?: number } = {}) { // One listener per waiting request and per running check, each removed when it ends: many at once are no leak. setMaxListeners(0, this.#events, this.#abort.signal); this.#probe = probe; this.#vendor = vendor; this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; this.#waitMs = options.waitMs ?? PLANNING_ADMIT_WAIT_MS; + this.#timeoutMs = options.timeoutMs ?? PLANNING_CHECK_TIMEOUT_MS; } /** Begin the check for the chosen vendor, if any, without waiting for it. */ start(): void { @@ -149,10 +159,19 @@ export class PlanningCheck { let probe: Promise; // Started now, so close can cancel it; a probe that throws instead of rejecting fails the check the same way. try { probe = this.#probe(vendor, abort.signal); } catch (error) { probe = Promise.reject(error); } + // Bounded: a check that has not settled in time fails, and is cancelled, so the recheck path applies to it. + let timer: ReturnType | undefined; + const late = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(`The planning check did not finish within ${Math.round(this.#timeoutMs / 60_000)} minutes.`); + abort.abort(error); reject(error); + }, this.#timeoutMs); + timer.unref?.(); + }); // Never rejects: the outcome is kept on the check, so every waiting request gets it. - const done = probe.then(() => { check.passed = true; }, (error: unknown) => { + const done = Promise.race([probe, late]).then(() => { check.passed = true; }, (error: unknown) => { check.failure = { error, at: performance.now() }; - }).finally(() => this.#abort.signal.removeEventListener('abort', stop)); + }).finally(() => { clearTimeout(timer); this.#abort.signal.removeEventListener('abort', stop); }); check.done = done; this.#running.add(done); void done.finally(() => this.#running.delete(done)); @@ -214,7 +233,11 @@ export class PlanningCheck { } #verdict(check: Check): void { if (check.passed) return; - // The reason comes last and as it is: some end with a command a person may copy. + // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart + // gets no promise of a recheck, which could not help. + if (check.failure && UNTIL_RESTART.test(messageOf(check.failure.error))) + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` + + `The check said: ${messageOf(check.failure.error)}`); if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` + `it checks again on the next request after ${duration(this.#recheckMs)}. ` + `The check said: ${messageOf(check.failure.error)}`); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index d1d632ed..3625d6fd 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -339,7 +339,7 @@ describe('the planning check (#164)', () => { expect(issueAccess).toHaveBeenCalledTimes(2); }); - it('refuses a request with the check\'s reason, before it reads the issue, and checks again only after the wait', async () => { + it('refuses a request with the check\'s reason, before it reads the issue, and does not check again within the wait', async () => { const { deps, probe, issueAccess } = checked(async () => { throw new Error('Claude could not sign in.'); }); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); @@ -404,7 +404,7 @@ describe('the planning check (#164)', () => { expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); }); - it('refuses to invoke an agent whose check has not passed, when Settings changed after the recorded action', async () => { + it('refuses to invoke an agent whose check has not passed for the vendor chosen at the call', async () => { const made = checked(async () => undefined); await made.deps.describe(signal()); vi.spyOn(made.service.store, 'questionProvider').mockReturnValue('codex'); @@ -517,6 +517,25 @@ describe('the planning check (#164)', () => { } finally { process.off('warning', warned); } }); + it('fails a check that does not settle in time, so the next request after the wait checks again', async () => { + let count = 0; + const check = new PlanningCheck((_vendor, probeSignal) => { + count += 1; + return count === 1 ? new Promise(() => undefined) : Promise.resolve(); + }, () => 'claude', { timeoutMs: 50, recheckMs: 0, waitMs: 1_000 }); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toContain('The planning check did not finish within'); + await expect(check.admit(signal())).resolves.toBeUndefined(); + expect(count).toBe(2); + }); + + it('promises no recheck when the check says planning is off until a restart', async () => { + const reason = 'The agent container worker stopped. Its containers and storage may still exist, so planning is off until codeboost restarts.'; + const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. The check said: ${reason}`); + }); + it('starts no check after close', async () => { let vendor: 'claude' | 'codex' = 'claude'; const probe = vi.fn(async () => undefined), check = new PlanningCheck(probe, () => vendor); @@ -571,7 +590,7 @@ describe('the planning check (#164)', () => { vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); return text(number); }); - // (The provider, too, checks again when it is invoked: "refuses to invoke an agent whose check has not passed".) + // (The provider, too, checks again when it is invoked: "refuses to invoke an agent whose check has not passed …".) const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); diff --git a/web/planning.ts b/web/planning.ts index 1e2dd88e..92c9f481 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -34,14 +34,16 @@ export function productionPlanning(config: ReviewConfig, options: { options.verifyLock(); service.store.settleInterruptedRequests(); const provider = options.agent?.(service) ?? new PlanningAgent(service); - // Started now, before the server listens. If startup fails after this, the process exits, and the next start's - // planning recovery removes what the check left (its objects carry planning's owner). + // Started now, before the server listens. If startup fails after this, the CLI exits (web/cli.ts), and the next start's + // planning recovery removes what the check left (its objects carry planning's owner). Another caller of startServer + // is left with the check running: startServer does not close planning when a later startup step fails (follow-up). const check = new PlanningCheck((vendor, signal) => provider.probe(vendor, signal), () => service.store.questionProvider(), { recheckMs: options.recheckMs, waitMs: options.admitWaitMs }); check.start(); return { - // Checked once more when the coordinator invokes the provider, after the recorded action: Settings may have changed - // in between, and the provider reads the vendor again. A refusal here settles the request as failed. + // Checked once more when the provider is invoked, as defence in depth: E3 invokes it in a microtask right after the + // recorded action, where Settings cannot change, but a later caller might not. A refusal here settles the request as + // failed. provider: { async invoke(request, signal) { check.assertPassed(); return provider.invoke(request, signal); } }, async describe(signal) { await check.admit(signal); From 40de60f148ffd4f7252751f3a3d4ba330253bb7f Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 20:14:38 -0700 Subject: [PATCH 15/39] A stopped wait is a refusal; no recheck promise without a sign-in; test the cancel (#164) Review round 13: an aborted request or Store error while waiting for the check is refused as such, never recorded as a failed GitHub read; a missing token, read only at startup, gets the restart wording; tests show a timed-out check is cancelled and stays failed if its probe answers late; durations under a minute read correctly. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 13 ++++++------ test/planning-production.test.ts | 35 ++++++++++++++++++++++++-------- web/planning.ts | 11 ++++++++-- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 32e08c6b..50cd5291 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -95,10 +95,11 @@ type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. The first check after ` + 'codeboost starts can take several minutes while the agent image is built. Retry in a minute.'; -const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : `${Math.round(ms / 1_000)} s`; +const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' + : ms >= 120_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1_000)} s`; // An abandoned worker (question-agent.ts) and unconfirmed cleanup (planning-provider.ts) refuse every job until codeboost -// restarts, so a recheck cannot help. -const UNTIL_RESTART = /until codeboost restarts/; +// restarts, and the worker reads CLAUDE_CODE_OAUTH_TOKEN only when codeboost starts, so a recheck cannot help these. +const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN/; /** * How long one check may take before it fails, so a check that never settles (a stuck recovery, say) cannot keep * planning "still checking": a real request's budget, as the probe has, and a minute for the worker to settle. @@ -163,7 +164,7 @@ export class PlanningCheck { let timer: ReturnType | undefined; const late = new Promise((_, reject) => { timer = setTimeout(() => { - const error = new Error(`The planning check did not finish within ${Math.round(this.#timeoutMs / 60_000)} minutes.`); + const error = new Error(`The planning check did not finish within ${duration(this.#timeoutMs)}.`); abort.abort(error); reject(error); }, this.#timeoutMs); timer.unref?.(); @@ -236,8 +237,8 @@ export class PlanningCheck { // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart // gets no promise of a recheck, which could not help. if (check.failure && UNTIL_RESTART.test(messageOf(check.failure.error))) - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. ` - + `The check said: ${messageOf(check.failure.error)}`); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart ` + + `of codeboost can change that. The check said: ${messageOf(check.failure.error)}`); if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` + `it checks again on the next request after ${duration(this.#recheckMs)}. ` + `The check said: ${messageOf(check.failure.error)}`); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 3625d6fd..33a20a63 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -371,7 +371,10 @@ describe('the planning check (#164)', () => { })); const leaving = new AbortController(), left = deps.describe(leaving.signal), waiting = deps.describe(signal()); leaving.abort(new Error('The browser went away.')); - await expect(left).rejects.toThrow('The browser went away.'); + // A refusal about the wait, never one the server would record as a failed GitHub read. + const stopped = await Promise.resolve(left).catch((error: unknown) => error); + expect(stopped).toBeInstanceOf(GuardRefusal); + expect((stopped as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost stopped waiting for the planning check: The browser went away.`); pass(); await expect(waiting).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); expect(probe).toHaveBeenCalledTimes(1); @@ -517,23 +520,39 @@ describe('the planning check (#164)', () => { } finally { process.off('warning', warned); } }); - it('fails a check that does not settle in time, so the next request after the wait checks again', async () => { - let count = 0; + it('fails and cancels a check that does not settle in time, and checks again after the wait', async () => { + let count = 0, late!: () => void; + const signals: AbortSignal[] = []; const check = new PlanningCheck((_vendor, probeSignal) => { - count += 1; - return count === 1 ? new Promise(() => undefined) : Promise.resolve(); - }, () => 'claude', { timeoutMs: 50, recheckMs: 0, waitMs: 1_000 }); + count += 1; signals.push(probeSignal); + return count === 1 ? new Promise(resolve => { late = resolve; }) : Promise.resolve(); + }, () => 'claude', { timeoutMs: 50, recheckMs: 300, waitMs: 1_000 }); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toContain('The planning check did not finish within'); + expect((refusal as Error).message).toContain('The planning check did not finish within a moment.'); + expect(signals[0]!.aborted).toBe(true); + expect((signals[0]!.reason as Error).message).toContain('did not finish'); + // A probe that answers after its timeout does not pass the check: within the wait it still refuses. + late(); + await new Promise(resolve => setTimeout(resolve, 10)); + await expect(check.admit(signal())).rejects.toThrow('did not finish'); + expect(count).toBe(1); + await new Promise(resolve => setTimeout(resolve, 350)); await expect(check.admit(signal())).resolves.toBeUndefined(); expect(count).toBe(2); }); + it('promises no recheck when the sign-in is missing, which only a restart can change', async () => { + const reason = 'Planning with Claude Code needs CLAUDE_CODE_OAUTH_TOKEN. Create one with `claude setup-token`.'; + const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that. The check said: ${reason}`); + }); + it('promises no recheck when the check says planning is off until a restart', async () => { const reason = 'The agent container worker stopped. Its containers and storage may still exist, so planning is off until codeboost restarts.'; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed. The check said: ${reason}`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that. The check said: ${reason}`); }); it('starts no check after close', async () => { diff --git a/web/planning.ts b/web/planning.ts index 92c9f481..0db17510 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -1,6 +1,6 @@ import { GhIssueGateway, ISSUE_READ_TIMEOUT_MS, withIssueReadDeadline, type IssueTrustGateway } from '../github/issues.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; -import { PlanningAgent, PlanningCheck } from '../runner/planning.ts'; +import { PLANNING_UNAVAILABLE, PlanningAgent, PlanningCheck } from '../runner/planning.ts'; import type { ReviewConfig, ReviewService } from '../runner/review.ts'; import type { PlanningSetup } from './server.ts'; @@ -46,7 +46,14 @@ export function productionPlanning(config: ReviewConfig, options: { // failed. provider: { async invoke(request, signal) { check.assertPassed(); return provider.invoke(request, signal); } }, async describe(signal) { - await check.admit(signal); + // Only refusals leave the wait: anything else (an aborted request, a Store that cannot be read) would be recorded + // by the server as a failed GitHub read, which this wait is not. + try { await check.admit(signal); } + catch (error) { + if (error instanceof GuardRefusal) throw error; + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost stopped waiting for the planning check: ` + + `${error instanceof Error ? error.message : String(error)}`); + } return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { const access = await issues.issueAccess(github.issue, { signal: readSignal, timeoutMs }); const trust = service.store.issueTrust(github.repository, github.issue); From 97da5c528d5e00a6e555e5d4ef5c33a60bb8bce7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 20:24:10 -0700 Subject: [PATCH 16/39] Restart cases from codeboost's own text; close waits for timed-out probes (#164) Review round 14: a database with hard links gets the restart wording; the restart match ignores vendor output; close also waits, within its bound, for a timed-out check's probe; a Store failure while waiting reads as a failed check, not a stopped wait; shutdown cancels with the shutdown stop; the timeout rejects before it aborts; a test pins the probe's repository, head, prompt and schema; wider recheck margin; 'recheck window' in test names. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 23 +++++++++------ test/planning-production.test.ts | 49 +++++++++++++++++++++++++++++--- test/planning-worker.test.ts | 13 ++++++++- web/planning.ts | 5 ++-- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 50cd5291..331f7607 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -3,6 +3,7 @@ import { setMaxListeners } from 'node:events'; import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts'; import { GuardRefusal } from './lifecycle.ts'; import { AgentWorker, type Provider } from './question-agent.ts'; +import { StopError } from './question-container.ts'; import { LeftoverLedger, PLANNING_NAMING } from './question-leftovers.ts'; import { PLANNING_BUDGET_MS, planningRun } from './planning-provider.ts'; import type { ReviewService } from './review.ts'; @@ -97,9 +98,11 @@ const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the + 'codeboost starts can take several minutes while the agent image is built. Retry in a minute.'; const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : ms >= 120_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1_000)} s`; -// An abandoned worker (question-agent.ts) and unconfirmed cleanup (planning-provider.ts) refuse every job until codeboost -// restarts, and the worker reads CLAUDE_CODE_OAUTH_TOKEN only when codeboost starts, so a recheck cannot help these. -const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN/; +// Reasons a recheck cannot help, so only a restart can: an abandoned worker (question-agent.ts), unconfirmed cleanup +// (planning-provider.ts), a database file with other hard links (question-leftovers.ts), and a missing token, since +// codeboost's environment is fixed while it runs. Matched only on codeboost's own text, before any vendor output. +const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links/; +const ownText = (message: string) => message.split(' Claude said:')[0]!; /** * How long one check may take before it fails, so a check that never settles (a stuck recovery, say) cannot keep * planning "still checking": a real request's budget, as the probe has, and a minute for the worker to settle. @@ -165,7 +168,7 @@ export class PlanningCheck { const late = new Promise((_, reject) => { timer = setTimeout(() => { const error = new Error(`The planning check did not finish within ${duration(this.#timeoutMs)}.`); - abort.abort(error); reject(error); + reject(error); abort.abort(error); }, this.#timeoutMs); timer.unref?.(); }); @@ -174,8 +177,12 @@ export class PlanningCheck { check.failure = { error, at: performance.now() }; }).finally(() => { clearTimeout(timer); this.#abort.signal.removeEventListener('abort', stop); }); check.done = done; - this.#running.add(done); - void done.finally(() => this.#running.delete(done)); + // Close waits for the probe itself too: a check that timed out has settled, but its job may still be in the worker. + const settled = probe.then(() => undefined, () => undefined); + for (const running of [done, settled]) { + this.#running.add(running); + void running.finally(() => this.#running.delete(running)); + } this.#current = check; this.#events.dispatchEvent(new Event('replaced')); return check; @@ -236,7 +243,7 @@ export class PlanningCheck { if (check.passed) return; // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart // gets no promise of a recheck, which could not help. - if (check.failure && UNTIL_RESTART.test(messageOf(check.failure.error))) + if (check.failure && UNTIL_RESTART.test(ownText(messageOf(check.failure.error)))) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart ` + `of codeboost can change that. The check said: ${messageOf(check.failure.error)}`); if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` @@ -246,7 +253,7 @@ export class PlanningCheck { } /** Cancel any running check and wait up to `waitMs` for it to stop. No check starts afterwards. */ async close(waitMs = PLANNING_CHECK_CLOSE_MS): Promise { - this.#abort.abort(new Error('Planning stopped.')); + this.#abort.abort(new StopError('Planning stopped.', 'shutdown')); let timer: ReturnType | undefined; await Promise.race([Promise.all(this.#running), new Promise(done => { timer = setTimeout(done, waitMs); })]); clearTimeout(timer); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 33a20a63..436cb53e 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -339,7 +339,7 @@ describe('the planning check (#164)', () => { expect(issueAccess).toHaveBeenCalledTimes(2); }); - it('refuses a request with the check\'s reason, before it reads the issue, and does not check again within the wait', async () => { + it('refuses a request with the check\'s reason, before it reads the issue, and does not check again within the recheck window', async () => { const { deps, probe, issueAccess } = checked(async () => { throw new Error('Claude could not sign in.'); }); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); @@ -520,13 +520,13 @@ describe('the planning check (#164)', () => { } finally { process.off('warning', warned); } }); - it('fails and cancels a check that does not settle in time, and checks again after the wait', async () => { + it('fails and cancels a check that does not settle in time, and checks again after the recheck window', async () => { let count = 0, late!: () => void; const signals: AbortSignal[] = []; const check = new PlanningCheck((_vendor, probeSignal) => { count += 1; signals.push(probeSignal); return count === 1 ? new Promise(resolve => { late = resolve; }) : Promise.resolve(); - }, () => 'claude', { timeoutMs: 50, recheckMs: 300, waitMs: 1_000 }); + }, () => 'claude', { timeoutMs: 50, recheckMs: 2_000, waitMs: 1_000 }); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message).toContain('The planning check did not finish within a moment.'); expect(signals[0]!.aborted).toBe(true); @@ -536,11 +536,52 @@ describe('the planning check (#164)', () => { await new Promise(resolve => setTimeout(resolve, 10)); await expect(check.admit(signal())).rejects.toThrow('did not finish'); expect(count).toBe(1); - await new Promise(resolve => setTimeout(resolve, 350)); + await new Promise(resolve => setTimeout(resolve, 2_100)); await expect(check.admit(signal())).resolves.toBeUndefined(); expect(count).toBe(2); }); + it.each([ + ['a database file with other hard links', 'Planning is off: the review database /r/db has other hard links, so codeboost cannot tell whether another process is using it. Use a database file without hard links.'], + ])('promises no recheck for %s, which only a restart can change', async (_label, reason) => { + const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toContain('only a restart of codeboost can change that'); + }); + + it('keeps the recheck promise when only the vendor\'s output mentions a restart', async () => { + const reason = 'Claude could not write the plan. Check its sign-in and usage limits. Claude said: planning is off until codeboost restarts'; + const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toContain('it checks again on the next request after a minute'); + }); + + it('reports a Store it cannot read, while waiting, as a failed check, not a stopped wait', async () => { + const config = production(), service = new ReviewService(config); closers.push(() => service.close()); + service.store.setQuestionProvider('claude'); + const deps = productionPlanning(config, { ...verified, issues: issues(async number => text(number)), + agent: () => closable(undefined, (_vendor, probeSignal) => new Promise((_, reject) => + probeSignal.addEventListener('abort', () => reject(probeSignal.reason), { once: true }))) })!(service); + vi.spyOn(service.store, 'questionProvider').mockImplementation(() => { throw new Error('database disk image is malformed'); }); + const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(GuardRefusal); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost could not check the planning agent: database disk image is malformed`); + await deps.close!(); + }); + + it('waits on close for a timed-out check\'s probe to stop, within its bound', async () => { + const order: string[] = []; + const check = new PlanningCheck((_vendor, probeSignal) => new Promise((_, reject) => { + // The probe stops a while after the timeout cancels it, as a worker job busy in setup does. + probeSignal.addEventListener('abort', () => + setTimeout(() => { order.push('probe stopped'); reject(probeSignal.reason); }, 100), { once: true }); + }), () => 'claude', { timeoutMs: 20 }); + await expect(check.admit(signal())).rejects.toThrow('did not finish'); + await check.close(2_000); + order.push('closed'); + expect(order).toEqual(['probe stopped', 'closed']); + }); + it('promises no recheck when the sign-in is missing, which only a restart can change', async () => { const reason = 'Planning with Claude Code needs CLAUDE_CODE_OAUTH_TOKEN. Create one with `claude setup-token`.'; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index cd6d11f0..e51ba788 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -9,7 +9,7 @@ import { createDemo } from '../scripts/demo.ts'; import { AgentWorker } from '../runner/question-agent.ts'; import { ASK_NAMING, createWorkerRoot, LeftoverLedger, PLANNING_NAMING } from '../runner/question-leftovers.ts'; import { CODEX_PLANNING_REFUSED, PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; -import { isPlanningProbeAnswer, NO_PLANNING_AGENT, PlanningAgent } from '../runner/planning.ts'; +import { isPlanningProbeAnswer, NO_PLANNING_AGENT, PLANNING_PROBE_PROMPT, PLANNING_PROBE_SCHEMA, PlanningAgent } from '../runner/planning.ts'; import { ReviewService } from '../runner/review.ts'; // Planning's worker, ledger and owner (#117), driven through the stub worker, which reports what reached it. @@ -171,6 +171,17 @@ it('fails the planning check without a sign-in or with Codex, before the review\ expect(acquire).not.toHaveBeenCalled(); }); +it('runs the planning check on the review\'s code at the snapshot\'s head, read-only, with the check\'s prompt and schema', async () => { + const service = review(), plan = vi.spyOn(AgentWorker.prototype, 'plan'); + await agent(service).probe('claude', new AbortController().signal); + const run = plan.mock.calls[0]![0], snapshot = service.store.getSnapshot(service.config.identity); + expect(run).toMatchObject({ provider: 'claude', repository: service.reviewRepository().path, head: snapshot.head, + prompt: PLANNING_PROBE_PROMPT, schemaText: PLANNING_PROBE_SCHEMA, + context: { snapshotId: snapshot.id, planId: service.config.identity.planId, referencedCodeHash: snapshot.head } }); + expect(run.taskId).toMatch(/^planning-probe-[0-9a-f-]{36}$/); + expect(run.deadline - Date.now()).toBeGreaterThan(PLANNING_BUDGET_MS - 30_000); +}); + it('fails the planning check when the agent answers anything but the schema\'s answer', async () => { const service = review(), wrong = new PlanningAgent(service, { url: STUB, env: { CLAUDE_CODE_OAUTH_TOKEN: 'probe-wrong-answer' } }); closers.push(() => wrong.close()); diff --git a/web/planning.ts b/web/planning.ts index 0db17510..e3f19e02 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -51,8 +51,9 @@ export function productionPlanning(config: ReviewConfig, options: { try { await check.admit(signal); } catch (error) { if (error instanceof GuardRefusal) throw error; - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost stopped waiting for the planning check: ` - + `${error instanceof Error ? error.message : String(error)}`); + const reason = error instanceof Error ? error.message : String(error); + throw new GuardRefusal(signal.aborted ? `${PLANNING_UNAVAILABLE} codeboost stopped waiting for the planning check: ${reason}` + : `${PLANNING_UNAVAILABLE} codeboost could not check the planning agent: ${reason}`); } return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { const access = await issues.issueAccess(github.issue, { signal: readSignal, timeoutMs }); From 82238e4ac486df69fddf2cd494e53030daad1454 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 20:35:23 -0700 Subject: [PATCH 17/39] Restart wording for unsettled cleanup; stopping and Codex wording; limits stated (#164) Review round 15: cleanup that did not settle gets the restart wording; after close the refusal says planning is stopping; Codex is told to choose another agent; the Store-error test now fails the read after a wait has begun; the docs and comments state the shared-recovery, kept-pass and shutdown-deferral limits; test names say what they show. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 15 ++++++++--- runner/planning.ts | 29 ++++++++++++++------ test/planning-production.test.ts | 31 +++++++++++++++++++--- test/planning-worker.test.ts | 4 +-- 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index f0dc3fbf..846b1dfc 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -108,10 +108,17 @@ before. Shutdown cancels a running check and waits up to 20 s for it before closing the worker; that wait comes after the server's own 20 s grace for planning requests, so the two add up. A check that has not settled within a real request's budget and a minute fails, so the recheck applies to it. A reason that holds until -codeboost restarts gets no promise of a recheck. If startup fails after planning's -setup, the CLI exits, and the next start's planning recovery removes what the -check left. Tests: `test/planning-production.test.ts` ("the planning check -(#164)", with a check that passes, one that fails and one that does not finish), +codeboost restarts (an abandoned worker, cleanup that did not settle, a database +with other hard links, a missing token) gets no promise of a recheck, and Codex is +told to choose another agent. A recheck joins the worker's one startup recovery, +so a recovery that never ends fails each recheck until codeboost restarts. A +passed check is kept for the life of the process: a worker abandoned later, or a +token revoked later, shows as a failed request. A check deferred from the Store's +transaction can start during shutdown, before planning closes; close then waits +for it within its bound. If startup fails after planning's setup, the CLI exits, +and the next start's planning recovery removes what the check left. Tests: +`test/planning-production.test.ts` ("the planning check (#164)", with a check that +passes, one that fails and one that does not finish), `test/planning-worker.test.ts` (the check in planning's worker, including a wrong answer) and `test/agent-planning.test.ts` (against the vendor, real Docker: an invalid sign-in fails, and a live probe in the Live probes workflow passes with a diff --git a/runner/planning.ts b/runner/planning.ts index 331f7607..4317636f 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -33,8 +33,8 @@ export function isPlanningProbeAnswer(text: string): boolean { * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous * setup never blocks the server. The worker's TMPDIR is a planning root recorded in a ledger beside the review * database, and every Docker object it creates carries the database's planning owner. The first job of a process that - * can sign in (the planning check at startup, or the first check a request starts) removes what earlier sessions left, - * by that owner only, so Ask's and the runner's agents are never touched. + * can sign in (always a planning check: at startup, or one a request starts) removes what earlier sessions left, by + * that owner only, so Ask's and the runner's agents are never touched. * * The code is the review's repository at the current snapshot's head; E3 discards a reply whose snapshot or revision * changed meanwhile. The vendor is the one chosen in Settings for Ask; Codex is refused (#93). @@ -99,13 +99,16 @@ const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : ms >= 120_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1_000)} s`; // Reasons a recheck cannot help, so only a restart can: an abandoned worker (question-agent.ts), unconfirmed cleanup -// (planning-provider.ts), a database file with other hard links (question-leftovers.ts), and a missing token, since -// codeboost's environment is fixed while it runs. Matched only on codeboost's own text, before any vendor output. -const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links/; +// (planning-provider.ts, and the check's own cleanup that did not settle, after which planning stays off), a database +// file with other hard links (question-leftovers.ts), and a missing token, since codeboost's environment is fixed while +// it runs. Matched only on codeboost's own text, before any vendor output. +const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links|did not settle/; const ownText = (message: string) => message.split(' Claude said:')[0]!; /** - * How long one check may take before it fails, so a check that never settles (a stuck recovery, say) cannot keep - * planning "still checking": a real request's budget, as the probe has, and a minute for the worker to settle. + * How long one check may take before it fails: a real request's budget, as the probe has, and a minute for the worker + * to settle. Waiting requests are then refused with the failure, and the next one after the recheck window checks + * again. A recheck joins the worker's one startup recovery, so a recovery that never ends fails each recheck the same + * way until codeboost restarts. */ export const PLANNING_CHECK_TIMEOUT_MS = PLANNING_BUDGET_MS + 60_000; /** @@ -190,7 +193,11 @@ export class PlanningCheck { /** * Resolve once the check for the vendor chosen now has passed; reject with a `GuardRefusal` naming why when it * failed, or when no check passed within `waitMs`. `signal` stops only this wait, never the check, which other - * requests may share. + * requests may share: an aborted wait rejects with `signal.reason`, and a vendor that cannot be read (a Store error) + * with that error; the caller maps both. + * + * A passed check is kept for the life of the process: a worker that is abandoned later, cleanup that does not settle + * later, or a token revoked later shows as a failed request, not as "Planning is unavailable". */ async admit(signal: AbortSignal): Promise { const deadline = performance.now() + this.#waitMs; @@ -241,6 +248,12 @@ export class PlanningCheck { } #verdict(check: Check): void { if (check.passed) return; + // After close no check starts, so no recheck can be promised. + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); + // Codex is refused whatever happens (#93); only choosing another agent helps, and that starts its check at once. + if (check.failure && check.vendor === 'codex') + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choosing another ` + + `agent in Settings checks it at once. The check said: ${messageOf(check.failure.error)}`); // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart // gets no promise of a recheck, which could not help. if (check.failure && UNTIL_RESTART.test(ownText(messageOf(check.failure.error)))) diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 436cb53e..d619a834 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -543,6 +543,7 @@ describe('the planning check (#164)', () => { it.each([ ['a database file with other hard links', 'Planning is off: the review database /r/db has other hard links, so codeboost cannot tell whether another process is using it. Use a database file without hard links.'], + ['its own cleanup that did not settle', 'Planning agent timed out. Cleanup also did not settle: docker rm failed'], ])('promises no recheck for %s, which only a restart can change', async (_label, reason) => { const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); @@ -556,19 +557,41 @@ describe('the planning check (#164)', () => { expect((refusal as Error).message).toContain('it checks again on the next request after a minute'); }); - it('reports a Store it cannot read, while waiting, as a failed check, not a stopped wait', async () => { + it('reports a Store it cannot read, once a wait has begun, as an unavailable check, not a stopped wait', async () => { const config = production(), service = new ReviewService(config); closers.push(() => service.close()); service.store.setQuestionProvider('claude'); + let pass!: () => void; const deps = productionPlanning(config, { ...verified, issues: issues(async number => text(number)), - agent: () => closable(undefined, (_vendor, probeSignal) => new Promise((_, reject) => - probeSignal.addEventListener('abort', () => reject(probeSignal.reason), { once: true }))) })!(service); + agent: () => closable(undefined, (_vendor, probeSignal) => new Promise((resolve, reject) => { + pass = resolve; probeSignal.addEventListener('abort', () => reject(probeSignal.reason), { once: true }); + })) })!(service); + const waiting = Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); + // The read after the wait fails: the database went bad while the check ran. + await new Promise(resolve => setTimeout(resolve, 10)); vi.spyOn(service.store, 'questionProvider').mockImplementation(() => { throw new Error('database disk image is malformed'); }); - const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); + pass(); + const refusal = await waiting; expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost could not check the planning agent: database disk image is malformed`); + vi.mocked(service.store.questionProvider).mockRestore(); await deps.close!(); }); + it('refuses with "planning is stopping" after close, never with a recheck promise', async () => { + const check = new PlanningCheck((_vendor, probeSignal) => new Promise((_, reject) => + probeSignal.addEventListener('abort', () => reject(probeSignal.reason), { once: true })), () => 'claude'); + check.start(); + await check.close(); + expect(() => check.assertPassed()).toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); + await expect(check.admit(signal())).rejects.toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); + }); + + it('tells Codex to choose another agent, not to wait for a recheck', async () => { + const check = new PlanningCheck(async () => { throw new Error('Codex cannot write plans yet.'); }, () => 'codex'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choosing another agent in Settings checks it at once. The check said: Codex cannot write plans yet.`); + }); + it('waits on close for a timed-out check\'s probe to stop, within its bound', async () => { const order: string[] = []; const check = new PlanningCheck((_vendor, probeSignal) => new Promise((_, reject) => { diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index e51ba788..1b26fea1 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -157,7 +157,7 @@ it.each([ }); // #164: the planning check runs as a planning job in planning's own worker, and passes only with the schema's answer. -it('runs the planning check as a planning job in planning\'s worker, and passes with the schema\'s answer', async () => { +it('passes the planning check with the schema\'s answer from the worker', async () => { const service = review(); await expect(agent(service).probe('claude', new AbortController().signal)).resolves.toBeUndefined(); }); @@ -171,7 +171,7 @@ it('fails the planning check without a sign-in or with Codex, before the review\ expect(acquire).not.toHaveBeenCalled(); }); -it('runs the planning check on the review\'s code at the snapshot\'s head, read-only, with the check\'s prompt and schema', async () => { +it('runs the planning check on the review\'s code at the snapshot\'s head, with the check\'s prompt and schema', async () => { const service = review(), plan = vi.spyOn(AgentWorker.prototype, 'plan'); await agent(service).probe('claude', new AbortController().signal); const run = plan.mock.calls[0]![0], snapshot = service.store.getSnapshot(service.config.identity); From 7bfe421f0411e837591625fa65302bcf6388bdec Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 20:44:07 -0700 Subject: [PATCH 18/39] Restart wording only for codeboost's restart-only reasons; stop on abort first (#164) Review round 16: a run whose cleanup did not settle keeps the recheck promise (its storage is retried, and the next check reports a real restart need), so the wording no longer depends on whether Claude printed anything; a request already aborted starts no check; docs state when a passed check is kept, the check's own unsettled cleanup, the lock after an abandoned worker and the replayed refusal. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 21 ++++++++++++--------- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 16 ++++++++++------ test/planning-production.test.ts | 8 +++++++- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 846b1dfc..c4bf96be 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -108,15 +108,18 @@ before. Shutdown cancels a running check and waits up to 20 s for it before closing the worker; that wait comes after the server's own 20 s grace for planning requests, so the two add up. A check that has not settled within a real request's budget and a minute fails, so the recheck applies to it. A reason that holds until -codeboost restarts (an abandoned worker, cleanup that did not settle, a database -with other hard links, a missing token) gets no promise of a recheck, and Codex is -told to choose another agent. A recheck joins the worker's one startup recovery, -so a recovery that never ends fails each recheck until codeboost restarts. A -passed check is kept for the life of the process: a worker abandoned later, or a -token revoked later, shows as a failed request. A check deferred from the Store's -transaction can start during shutdown, before planning closes; close then waits -for it within its bound. If startup fails after planning's setup, the CLI exits, -and the next start's planning recovery removes what the check left. Tests: +codeboost restarts (an abandoned worker, cleanup codeboost could not confirm, a +database with other hard links, a missing token) gets no promise of a recheck, and +Codex is told to choose another agent. A recheck joins the worker's one startup +recovery, so a recovery that never ends fails each recheck until codeboost +restarts. A passed check is kept until a check for another vendor replaces it: a +worker abandoned later, cleanup that did not settle (the check's own run included, +when its answer still came back), or a token revoked later, shows as a failed +request. A refusal recorded when a request stopped waiting is replayed if the +browser resends the same action. A check deferred from the Store's transaction can +start during shutdown, before planning closes; close then waits for it within its +bound. If startup fails after planning's setup, the CLI exits, and the next +start's planning recovery removes what the check left. Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a check that passes, one that fails and one that does not finish), `test/planning-worker.test.ts` (the check in planning's worker, including a wrong diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 7be1b288..5c49026b 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index 4317636f..117e20d2 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -99,10 +99,11 @@ const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : ms >= 120_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1_000)} s`; // Reasons a recheck cannot help, so only a restart can: an abandoned worker (question-agent.ts), unconfirmed cleanup -// (planning-provider.ts, and the check's own cleanup that did not settle, after which planning stays off), a database -// file with other hard links (question-leftovers.ts), and a missing token, since codeboost's environment is fixed while -// it runs. Matched only on codeboost's own text, before any vendor output. -const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links|did not settle/; +// (planning-provider.ts' "off until codeboost restarts"), a database file with other hard links (question-leftovers.ts), +// and a missing token, since codeboost's environment is fixed while it runs. Each is codeboost's own message, given +// before any vendor output. A run whose cleanup did not settle is not among them: its storage is retried before the +// next run, and if planning did go off, the recheck reports that with these words. +const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links/; const ownText = (message: string) => message.split(' Claude said:')[0]!; /** * How long one check may take before it fails: a real request's budget, as the probe has, and a minute for the worker @@ -196,12 +197,15 @@ export class PlanningCheck { * requests may share: an aborted wait rejects with `signal.reason`, and a vendor that cannot be read (a Store error) * with that error; the caller maps both. * - * A passed check is kept for the life of the process: a worker that is abandoned later, cleanup that does not settle - * later, or a token revoked later shows as a failed request, not as "Planning is unavailable". + * A passed check is kept until a check for another vendor replaces it: a worker that is abandoned later, cleanup that + * does not settle later (the check's own run included, when its answer still came back), or a token revoked later + * shows as a failed request, not as "Planning is unavailable". */ async admit(signal: AbortSignal): Promise { const deadline = performance.now() + this.#waitMs; for (;;) { + // A request that stopped waiting starts, replaces or cancels no check. + signal.throwIfAborted(); const vendor = this.#vendor(); if (!vendor) return; const check = this.#check(vendor); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index d619a834..10b22016 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -543,13 +543,19 @@ describe('the planning check (#164)', () => { it.each([ ['a database file with other hard links', 'Planning is off: the review database /r/db has other hard links, so codeboost cannot tell whether another process is using it. Use a database file without hard links.'], - ['its own cleanup that did not settle', 'Planning agent timed out. Cleanup also did not settle: docker rm failed'], ])('promises no recheck for %s, which only a restart can change', async (_label, reason) => { const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message).toContain('only a restart of codeboost can change that'); }); + it('keeps the recheck promise when a run\'s cleanup did not settle, which the next run retries', async () => { + const reason = 'Claude could not write the plan. Claude said: no Cleanup also did not settle: docker volume rm failed'; + const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); + const refusal = await check.admit(signal()).catch((error: unknown) => error); + expect((refusal as Error).message).toContain('it checks again on the next request after a minute'); + }); + it('keeps the recheck promise when only the vendor\'s output mentions a restart', async () => { const reason = 'Claude could not write the plan. Check its sign-in and usage limits. Claude said: planning is off until codeboost restarts'; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); From db7f48cfbb6975b1aba0f25731dd76af7385f2d8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 20:53:29 -0700 Subject: [PATCH 19/39] Codex wording names the next request; exact lock and wait wording (#164) Review round 17. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 11 ++++++----- runner/question-leftovers.ts | 4 ++-- test/planning-production.test.ts | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 5c49026b..9ee0462a 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index 117e20d2..396e89e7 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -117,7 +117,7 @@ export const PLANNING_CHECK_TIMEOUT_MS = PLANNING_BUDGET_MS + 60_000; * startup, and `admit`, called before anything about a request is recorded, waits for it. The last check is kept: a * passed one admits every request for its vendor; a failed one refuses them for `recheckMs`, after which the next * request checks again. The first request after the vendor changes starts that vendor's check and cancels the one it - * replaces. A request waits for a check for at most `waitMs` in all, then is refused as still starting. With no vendor + * replaces. A request waits for a check for at most `waitMs` in all, then is refused as still checking. With no vendor * chosen nothing is checked, and the request is refused later, as before, with `NO_PLANNING_AGENT`. */ export class PlanningCheck { @@ -254,15 +254,16 @@ export class PlanningCheck { if (check.passed) return; // After close no check starts, so no recheck can be promised. if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); - // Codex is refused whatever happens (#93); only choosing another agent helps, and that starts its check at once. - if (check.failure && check.vendor === 'codex') - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choosing another ` - + `agent in Settings checks it at once. The check said: ${messageOf(check.failure.error)}`); // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart // gets no promise of a recheck, which could not help. if (check.failure && UNTIL_RESTART.test(ownText(messageOf(check.failure.error)))) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart ` + `of codeboost can change that. The check said: ${messageOf(check.failure.error)}`); + // Codex is refused whatever happens (#93): only choosing another agent helps, and the next request checks that + // one. A restart-only reason above keeps its own wording. + if (check.failure && check.vendor === 'codex') + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choose another ` + + `agent in Settings, and the next planning request checks it. The check said: ${messageOf(check.failure.error)}`); if (check.failure) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; ` + `it checks again on the next request after ${duration(this.#recheckMs)}. ` + `The check said: ${messageOf(check.failure.error)}`); diff --git a/runner/question-leftovers.ts b/runner/question-leftovers.ts index 6ed99608..b996fc02 100644 --- a/runner/question-leftovers.ts +++ b/runner/question-leftovers.ts @@ -191,8 +191,8 @@ function parse(text: string, naming: WorkerNaming): LedgerRecord { * Durable record of a feature's host copies (worker roots) that outlived their worker, and the review's lock for that * feature. Docker leftovers need no record: the feature labels them with the review's own owner token for it, and the * first job of each process that can sign in (for planning, usually its startup check, #164) removes them through lane - * D's scoped recovery (#51 item 4, #65). Ask and planning each - * have their own ledger, lock and owner, so neither one's recovery or refusals affect the other (#117). + * D's scoped recovery (#51 item 4, #65). Ask and planning each have their own ledger, lock and owner, so neither one's + * recovery or refusals affect the other (#117). */ export class LeftoverLedger { readonly path: string; diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 10b22016..3eba43fe 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -595,7 +595,7 @@ describe('the planning check (#164)', () => { it('tells Codex to choose another agent, not to wait for a recheck', async () => { const check = new PlanningCheck(async () => { throw new Error('Codex cannot write plans yet.'); }, () => 'codex'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choosing another agent in Settings checks it at once. The check said: Codex cannot write plans yet.`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choose another agent in Settings, and the next planning request checks it. The check said: Codex cannot write plans yet.`); }); it('waits on close for a timed-out check\'s probe to stop, within its bound', async () => { From 5792621f8675353a14f21d8dc9a3ba62ed5572cd Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:02:52 -0700 Subject: [PATCH 20/39] Exact check answer, repeated keys refused; test names and real cleanup text (#164) Review round 18: the check passes only on {"ok": true} written once; the cleanup-wording test uses cleanupFailed's real shape; test names say 'still checking' and 'recheck window'; the docs list the Store-error replay limit. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 23 +++++++++++----------- runner/planning.ts | 10 +++++----- test/planning-production.test.ts | 7 ++++--- test/planning-worker.test.ts | 3 ++- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index c4bf96be..335e5801 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -115,17 +115,18 @@ recovery, so a recovery that never ends fails each recheck until codeboost restarts. A passed check is kept until a check for another vendor replaces it: a worker abandoned later, cleanup that did not settle (the check's own run included, when its answer still came back), or a token revoked later, shows as a failed -request. A refusal recorded when a request stopped waiting is replayed if the -browser resends the same action. A check deferred from the Store's transaction can -start during shutdown, before planning closes; close then waits for it within its -bound. If startup fails after planning's setup, the CLI exits, and the next -start's planning recovery removes what the check left. Tests: -`test/planning-production.test.ts` ("the planning check (#164)", with a check that -passes, one that fails and one that does not finish), -`test/planning-worker.test.ts` (the check in planning's worker, including a wrong -answer) and `test/agent-planning.test.ts` (against the vendor, real Docker: an -invalid sign-in fails, and a live probe in the Live probes workflow passes with a -real one). +request. A refusal recorded when a request stopped waiting, or when the Store +could not be read during the wait, is replayed if the browser resends the same +action; keeping a Store error out of the record needs `web/server.ts`. A check +deferred from the Store's transaction can start during shutdown, before planning +closes; close then waits for it within its bound. If startup fails after +planning's setup, the CLI exits, and the next start's planning recovery removes +what the check left. Tests: `test/planning-production.test.ts` ("the planning +check (#164)", with a check that passes, one that fails and one that does not +finish), `test/planning-worker.test.ts` (the check in planning's worker, including +a wrong answer) and `test/agent-planning.test.ts` (against the vendor, real +Docker: an invalid sign-in fails, and a live probe in the Live probes workflow +passes with a real one). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/runner/planning.ts b/runner/planning.ts index 396e89e7..48e7e3ac 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -21,12 +21,12 @@ export const PLANNING_PROBE_PROMPT = 'This is codeboost\'s check that planning w + 'Answer with the JSON object {"ok": true} and nothing else.'; export const PLANNING_PROBE_SCHEMA = JSON.stringify({ type: 'object', properties: { ok: { const: true } }, required: ['ok'], additionalProperties: false }); -/** Whether a planning answer is exactly the check's: the object `{"ok": true}`, as JSON. */ +/** + * Whether a planning answer is exactly the check's: the object `{"ok": true}`, as JSON, with that one member written + * once (JSON.parse would let a repeated key's last value win). + */ export function isPlanningProbeAnswer(text: string): boolean { - let answer: unknown; - try { answer = JSON.parse(text); } catch { return false; } - return typeof answer === 'object' && answer !== null && !Array.isArray(answer) && Object.keys(answer).length === 1 - && (answer as { ok?: unknown }).ok === true; + return /^\s*\{\s*"ok"\s*:\s*true\s*\}\s*$/.test(text); } /** diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 3eba43fe..369dbd19 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -350,7 +350,7 @@ describe('the planning check (#164)', () => { expect(issueAccess).not.toHaveBeenCalled(); }); - it('checks again once the wait after a failure is over, and admits once a check passes', async () => { + it('checks again once the recheck window after a failure is over, and admits once a check passes', async () => { let fail = true; const { deps, probe, issueAccess } = checked(async () => { if (fail) throw new Error('Docker is not running.'); }, 'claude', 0); await expect(deps.describe(signal())).rejects.toThrow('Docker is not running.'); @@ -550,7 +550,8 @@ describe('the planning check (#164)', () => { }); it('keeps the recheck promise when a run\'s cleanup did not settle, which the next run retries', async () => { - const reason = 'Claude could not write the plan. Claude said: no Cleanup also did not settle: docker volume rm failed'; + // planning-provider.ts' cleanupFailed: the run's own failure, then the cleanup's. + const reason = 'Planning agent timed out. Cleanup also did not settle: docker volume rm failed'; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message).toContain('it checks again on the next request after a minute'); @@ -662,7 +663,7 @@ describe('the planning check (#164)', () => { expect(performance.now() - started).toBeLessThan(2_000); }); - it('refuses a request as still starting when the check outlasts its wait, and admits it once the check passed', async () => { + it('refuses a request as still checking when the check outlasts its wait, and admits it once the check passed', async () => { let pass!: () => void; const { deps, issueAccess } = checked(() => new Promise(resolve => { pass = resolve; }), 'claude', undefined, 50); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index 1b26fea1..ce7b510a 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -191,6 +191,7 @@ it('fails the planning check when the agent answers anything but the schema\'s a it('passes the planning check only with exactly the schema\'s answer', () => { expect(isPlanningProbeAnswer('{"ok":true}')).toBe(true); expect(isPlanningProbeAnswer(' { "ok": true }\n')).toBe(true); - for (const text of ['', 'ok', 'true', '{"ok":false}', '{"ok":"true"}', '{"ok":true,"extra":1}', '[{"ok":true}]', 'null', '{}']) + for (const text of ['', 'ok', 'true', '{"ok":false}', '{"ok":"true"}', '{"ok":true,"extra":1}', '[{"ok":true}]', 'null', '{}', + '{"ok":false,"ok":true}', '{"o\\u006b":true}']) expect(isPlanningProbeAnswer(text)).toBe(false); }); From d50bbbe59a6f1d229e6012b63f111af3091a6c54 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:10:42 -0700 Subject: [PATCH 21/39] Cite follow-up #198 for the review-view state and server-side limits (#164) Co-Authored-By: Claude Opus 5.5 --- docs/implementation/plan-library-alignment.md | 2 +- docs/implementation/planning-acceptance.md | 22 ++++++++++--------- web/planning.ts | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/implementation/plan-library-alignment.md b/docs/implementation/plan-library-alignment.md index 8b9191ec..14269bfd 100644 --- a/docs/implementation/plan-library-alignment.md +++ b/docs/implementation/plan-library-alignment.md @@ -24,7 +24,7 @@ Issue #6 tracked the v1 plan library contract and the runner obligations that ca | Output limits at the exact byte | **This change** (tests; the supervisor already enforced the limits) | Supervisor enforcement: `test/agent-supervisor.test.ts` (real Docker). Decoder behaviour only, not a limit: the Claude decoder handles an envelope of exactly the stdout ceiling, and the 1 MiB parser limit applies to the decoded answer at the exact byte: `test/agent-adapter.test.ts`. At #6 the decoder re-serialized the answer, so the limit applied to the re-encoded text. *Later:* #166 returns the exact text of `structured_output` in the CLI's envelope, so the limit applies to those bytes | | Prompt byte budget before spawn | Done before this change | `MAX_PROMPT_BYTES` in `core/planning-author.ts`; `test/planning-author.test.ts` ("accepts exactly the prompt byte limit and rejects the next byte without truncation") | | Model budget before spawn | Moved to #163 | — | -| Planning profile probed at startup | Moved to #164. *Later:* fixed in #164, checked at startup, on the first request after the vendor changes and on the first request a minute after a failure | `runner/planning.ts` (`PlanningAgent.probe`, `PlanningCheck`), `web/planning.ts`; `test/planning-production.test.ts` ("the planning check (#164)"), `test/planning-worker.test.ts`, `test/agent-planning.test.ts` (live Claude probe); `docs/implementation/planning-acceptance.md` | +| Planning profile probed at startup | Moved to #164. *Later:* fixed in #164 (the review-view state is #198), checked at startup, on the first request after the vendor changes and on the first request a minute after a failure | `runner/planning.ts` (`PlanningAgent.probe`, `PlanningCheck`), `web/planning.ts`; `test/planning-production.test.ts` ("the planning check (#164)"), `test/planning-worker.test.ts`, `test/agent-planning.test.ts` (live Claude probe); `docs/implementation/planning-acceptance.md` | | Docker daemon log limits | Moved to #165. *Later:* fixed in #165 with `--log-driver=none`, checked before launch | `agents/container/profile.ts`, `agents/container/run.ts`; `test/agent-container.test.ts` ("creates the agent container with no Docker daemon log", "refuses an agent container with %s at validation") (real Docker), `test/agent-daemon-log.test.ts`. Agent containers only; the runner's other containers (task-storage scripts, the keeper, the egress proxy) keep the daemon's default log | | Claude structured output under the v1 parsing contract | Moved to #166 (found during this change). *Later:* fixed in #166 | `agents/adapters/claude.ts`; `test/agent-adapter.test.ts` ("lets draft validation refuse %s in a structured answer") | diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 335e5801..c47f9aa4 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -117,16 +117,18 @@ worker abandoned later, cleanup that did not settle (the check's own run include when its answer still came back), or a token revoked later, shows as a failed request. A refusal recorded when a request stopped waiting, or when the Store could not be read during the wait, is replayed if the browser resends the same -action; keeping a Store error out of the record needs `web/server.ts`. A check -deferred from the Store's transaction can start during shutdown, before planning -closes; close then waits for it within its bound. If startup fails after -planning's setup, the CLI exits, and the next start's planning recovery removes -what the check left. Tests: `test/planning-production.test.ts` ("the planning -check (#164)", with a check that passes, one that fails and one that does not -finish), `test/planning-worker.test.ts` (the check in planning's worker, including -a wrong answer) and `test/agent-planning.test.ts` (against the vendor, real -Docker: an invalid sign-in fails, and a live probe in the Live probes workflow -passes with a real one). +action; keeping a Store error out of the record needs `web/server.ts`. Showing the +check's state in the review view before a request, and the other limits that need +`web/server.ts`, are #198. A check deferred from the Store's transaction can start +during shutdown, before planning closes; close then waits for it within its bound. +If startup fails after planning's setup, the CLI exits, and the next start's +planning recovery removes what the check left. Tests: +`test/planning-production.test.ts` ("the planning check (#164)", with a check that +passes, one that fails and one that does not finish), +`test/planning-worker.test.ts` (the check in planning's worker, including a wrong +answer) and `test/agent-planning.test.ts` (against the vendor, real Docker: an +invalid sign-in fails, and a live probe in the Live probes workflow passes with a +real one). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/web/planning.ts b/web/planning.ts index e3f19e02..bb6473ad 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -36,7 +36,7 @@ export function productionPlanning(config: ReviewConfig, options: { const provider = options.agent?.(service) ?? new PlanningAgent(service); // Started now, before the server listens. If startup fails after this, the CLI exits (web/cli.ts), and the next start's // planning recovery removes what the check left (its objects carry planning's owner). Another caller of startServer - // is left with the check running: startServer does not close planning when a later startup step fails (follow-up). + // is left with the check running: startServer does not close planning when a later startup step fails (#198). const check = new PlanningCheck((vendor, signal) => provider.probe(vendor, signal), () => service.store.questionProvider(), { recheckMs: options.recheckMs, waitMs: options.admitWaitMs }); check.start(); From de7c8f57d23c385b8cbc9df5975d961c27b520d5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:12:58 -0700 Subject: [PATCH 22/39] JSON whitespace only in the check answer; exact wording and stub note (#164) Review round 19 nits. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 44 +++++++++++----------- runner/planning.ts | 6 +-- test/fixtures/question-worker-stub.ts | 4 +- test/planning-production.test.ts | 2 +- test/planning-worker.test.ts | 2 +- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index c47f9aa4..061df7cc 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -103,28 +103,28 @@ unavailable:" and ends with the reason exactly as given (some end with a command to copy), and a check still running after the wait is refused with the same prefix; the server records the refusal and starts no agent. Requests within a minute of a failure are refused with it; the next request after that checks again. -With no vendor chosen nothing is checked, and the request is refused later, as -before. Shutdown cancels a running check and waits up to 20 s for it before -closing the worker; that wait comes after the server's own 20 s grace for planning -requests, so the two add up. A check that has not settled within a real request's -budget and a minute fails, so the recheck applies to it. A reason that holds until -codeboost restarts (an abandoned worker, cleanup codeboost could not confirm, a -database with other hard links, a missing token) gets no promise of a recheck, and -Codex is told to choose another agent. A recheck joins the worker's one startup -recovery, so a recovery that never ends fails each recheck until codeboost -restarts. A passed check is kept until a check for another vendor replaces it: a -worker abandoned later, cleanup that did not settle (the check's own run included, -when its answer still came back), or a token revoked later, shows as a failed -request. A refusal recorded when a request stopped waiting, or when the Store -could not be read during the wait, is replayed if the browser resends the same -action; keeping a Store error out of the record needs `web/server.ts`. Showing the -check's state in the review view before a request, and the other limits that need -`web/server.ts`, are #198. A check deferred from the Store's transaction can start -during shutdown, before planning closes; close then waits for it within its bound. -If startup fails after planning's setup, the CLI exits, and the next start's -planning recovery removes what the check left. Tests: -`test/planning-production.test.ts` ("the planning check (#164)", with a check that -passes, one that fails and one that does not finish), +With no vendor chosen nothing is checked, and the request fails later, as before, +with `NO_PLANNING_AGENT`. Shutdown cancels a running check and waits up to 20 s +for it before closing the worker; that wait comes after the server's own 20 s +grace for planning requests, so the two add up. A check that has not settled +within a real request's budget and a minute fails, so the recheck applies to it. A +reason that holds until codeboost restarts (an abandoned worker, cleanup codeboost +could not confirm, a database with other hard links, a missing token) gets no +promise of a recheck, and Codex is told to choose another agent. A recheck joins +the worker's one startup recovery, so a recovery that never ends fails each +recheck until codeboost restarts. A passed check is kept until a check for another +vendor replaces it: a worker abandoned later, cleanup that did not settle (the +check's own run included, when its answer still came back), or a token revoked +later, shows as a failed request. A refusal recorded when a request stopped +waiting, or when the Store could not be read during the wait, is replayed if the +browser resends the same action; keeping a Store error out of the record needs +`web/server.ts`. Showing the check's state in the review view before a request, +and the other limits that need `web/server.ts`, are #198. A check deferred from +the Store's transaction can start during shutdown, before planning closes; close +then waits for it within its bound. If startup fails after planning's setup, the +CLI exits, and the next start's planning recovery removes what the check left. +Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a +check that passes, one that fails and one that does not finish), `test/planning-worker.test.ts` (the check in planning's worker, including a wrong answer) and `test/agent-planning.test.ts` (against the vendor, real Docker: an invalid sign-in fails, and a live probe in the Live probes workflow passes with a diff --git a/runner/planning.ts b/runner/planning.ts index 48e7e3ac..780423cc 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -26,7 +26,7 @@ export const PLANNING_PROBE_SCHEMA = JSON.stringify({ type: 'object', properties * once (JSON.parse would let a repeated key's last value win). */ export function isPlanningProbeAnswer(text: string): boolean { - return /^\s*\{\s*"ok"\s*:\s*true\s*\}\s*$/.test(text); + return /^[ \t\n\r]*\{[ \t\n\r]*"ok"[ \t\n\r]*:[ \t\n\r]*true[ \t\n\r]*\}[ \t\n\r]*$/.test(text); } /** @@ -81,7 +81,7 @@ export class PlanningAgent implements AuthorProvider { close(): Promise { return this.#worker.close(); } } -/** Starts with this text when planning is refused because its check failed or has not finished. */ +/** Starts every refusal from planning admission: a failed or unfinished check, a stopped wait, and planning stopping. */ export const PLANNING_UNAVAILABLE = 'Planning is unavailable:'; /** How long a failed check refuses requests before the next request checks again. */ export const PLANNING_RECHECK_MS = 60_000; @@ -118,7 +118,7 @@ export const PLANNING_CHECK_TIMEOUT_MS = PLANNING_BUDGET_MS + 60_000; * passed one admits every request for its vendor; a failed one refuses them for `recheckMs`, after which the next * request checks again. The first request after the vendor changes starts that vendor's check and cancels the one it * replaces. A request waits for a check for at most `waitMs` in all, then is refused as still checking. With no vendor - * chosen nothing is checked, and the request is refused later, as before, with `NO_PLANNING_AGENT`. + * chosen nothing is checked, and the request fails later, as before, with `NO_PLANNING_AGENT`. */ export class PlanningCheck { readonly #probe: (vendor: Provider, signal: AbortSignal) => Promise; diff --git a/test/fixtures/question-worker-stub.ts b/test/fixtures/question-worker-stub.ts index c6c44287..2325f1e0 100644 --- a/test/fixtures/question-worker-stub.ts +++ b/test/fixtures/question-worker-stub.ts @@ -41,7 +41,9 @@ parentPort!.on('message', (message: WorkerRequest) => { // A planning job carries a ReadOnlyRun; the stub reads the same fields from either kind (#117). const job = message.type === 'ask' ? message.question : { ...message.run, noteId: message.run.taskId }; const { prompt, provider, noteId, attemptId } = job; - // The planning check (#164) passes with the schema's own answer. + // The planning check (#164) passes with the schema's own answer, given at once and compact, as the real worker returns + // Claude CLI 2.1.281's structured_output (live probe run 39541887939). The stub leaves out cancellation and the + // worker's feature check: it never leaves a check pending, and plan() refuses a non-planning worker first. if (message.type === 'plan' && message.run.taskId.startsWith('planning-probe-')) { // The agent answers the check only when asked for it: the prompt names the answer and the schema requires it. const asked = message.run.prompt.includes('{"ok": true}') && message.run.schemaText.includes('"ok":{"const":true}'); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 369dbd19..4a4e6c56 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -384,7 +384,7 @@ describe('the planning check (#164)', () => { it('checks nothing while no agent is chosen, then checks the vendor chosen before its first request', async () => { const { deps, service, probe, issueAccess } = checked(async () => undefined, null); expect(probe).not.toHaveBeenCalled(); - // No vendor: the request goes on, and the agent refuses it later with NO_PLANNING_AGENT, as before. + // No vendor: the request goes on, and fails later with NO_PLANNING_AGENT, as before. await deps.describe(signal()); expect(probe).not.toHaveBeenCalled(); service.store.setQuestionProvider('claude'); diff --git a/test/planning-worker.test.ts b/test/planning-worker.test.ts index ce7b510a..a473b014 100644 --- a/test/planning-worker.test.ts +++ b/test/planning-worker.test.ts @@ -192,6 +192,6 @@ it('passes the planning check only with exactly the schema\'s answer', () => { expect(isPlanningProbeAnswer('{"ok":true}')).toBe(true); expect(isPlanningProbeAnswer(' { "ok": true }\n')).toBe(true); for (const text of ['', 'ok', 'true', '{"ok":false}', '{"ok":"true"}', '{"ok":true,"extra":1}', '[{"ok":true}]', 'null', '{}', - '{"ok":false,"ok":true}', '{"o\\u006b":true}']) + '{"ok":false,"ok":true}', '{"o\\u006b":true}', '\uFEFF{"ok":true}', '{\u00a0"ok":true}']) expect(isPlanningProbeAnswer(text)).toBe(false); }); From ccf6bd1a1dd44073f170bf5c0f749e2cbb26a2ed Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:25:04 -0700 Subject: [PATCH 23/39] Say when a changed agent is being checked; exact recovery and shutdown docs (#164) Review round 20: assertPassed's refusal says the chosen agent changed and is being checked; docs say a first job whose recovery fails leaves recovery to the next, that shutdown cancels the check after the server's grace, and how a check before the runner's setup fails on a task with runner commits (kept as a recheck, since the runner's setup sets the repository later). Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 53 ++++++++++++---------- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 8 ++-- runner/question-leftovers.ts | 6 +-- test/planning-production.test.ts | 6 +-- 5 files changed, 40 insertions(+), 35 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 061df7cc..21d68b83 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -104,31 +104,34 @@ to copy), and a check still running after the wait is refused with the same prefix; the server records the refusal and starts no agent. Requests within a minute of a failure are refused with it; the next request after that checks again. With no vendor chosen nothing is checked, and the request fails later, as before, -with `NO_PLANNING_AGENT`. Shutdown cancels a running check and waits up to 20 s -for it before closing the worker; that wait comes after the server's own 20 s -grace for planning requests, so the two add up. A check that has not settled -within a real request's budget and a minute fails, so the recheck applies to it. A -reason that holds until codeboost restarts (an abandoned worker, cleanup codeboost -could not confirm, a database with other hard links, a missing token) gets no -promise of a recheck, and Codex is told to choose another agent. A recheck joins -the worker's one startup recovery, so a recovery that never ends fails each -recheck until codeboost restarts. A passed check is kept until a check for another -vendor replaces it: a worker abandoned later, cleanup that did not settle (the -check's own run included, when its answer still came back), or a token revoked -later, shows as a failed request. A refusal recorded when a request stopped -waiting, or when the Store could not be read during the wait, is replayed if the -browser resends the same action; keeping a Store error out of the record needs -`web/server.ts`. Showing the check's state in the review view before a request, -and the other limits that need `web/server.ts`, are #198. A check deferred from -the Store's transaction can start during shutdown, before planning closes; close -then waits for it within its bound. If startup fails after planning's setup, the -CLI exits, and the next start's planning recovery removes what the check left. -Tests: `test/planning-production.test.ts` ("the planning check (#164)", with a -check that passes, one that fails and one that does not finish), -`test/planning-worker.test.ts` (the check in planning's worker, including a wrong -answer) and `test/agent-planning.test.ts` (against the vendor, real Docker: an -invalid sign-in fails, and a live probe in the Live probes workflow passes with a -real one). +with `NO_PLANNING_AGENT`. After the server's 20 s grace for planning requests, +shutdown cancels a running check and waits up to 20 s more for it before closing +the worker. A check that runs before the runner's setup has set the runner-owned +repository fails, on a task with runner commits, with "which is not configured"; +the recheck a minute later passes once the runner's setup has finished, and +without a runner block it fails each time, as every planning request would. A +check that has not settled within a real request's budget and a minute fails, so +the recheck applies to it. A reason that holds until codeboost restarts (an +abandoned worker, cleanup codeboost could not confirm, a database with other hard +links, a missing token) gets no promise of a recheck, and Codex is told to choose +another agent. A recheck joins the worker's one startup recovery, so a recovery +that never ends fails each recheck until codeboost restarts. A passed check is +kept until a check for another vendor replaces it: a worker abandoned later, +cleanup that did not settle (the check's own run included, when its answer still +came back), or a token revoked later, shows as a failed request. A refusal +recorded when a request stopped waiting, or when the Store could not be read +during the wait, is replayed if the browser resends the same action; keeping a +Store error out of the record needs `web/server.ts`. Showing the check's state in +the review view before a request, and the other limits that need `web/server.ts`, +are #198. A check deferred from the Store's transaction can start during shutdown, +before planning closes; close then waits for it within its bound. If startup fails +after planning's setup, the CLI exits, and the next start's planning recovery +removes what the check left. Tests: `test/planning-production.test.ts` ("the +planning check (#164)", with a check that passes, one that fails and one that does +not finish), `test/planning-worker.test.ts` (the check in planning's worker, +including a wrong answer) and `test/agent-planning.test.ts` (against the vendor, +real Docker: an invalid sign-in fails, and a live probe in the Live probes +workflow passes with a real one). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 9ee0462a..e186432d 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts; until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts (a check whose recovery fails leaves it to the next one); until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index 780423cc..a92c64bc 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -33,8 +33,9 @@ export function isPlanningProbeAnswer(text: string): boolean { * The production planning provider (#117): every request runs in planning's own worker thread, so lane D's synchronous * setup never blocks the server. The worker's TMPDIR is a planning root recorded in a ledger beside the review * database, and every Docker object it creates carries the database's planning owner. The first job of a process that - * can sign in (always a planning check: at startup, or one a request starts) removes what earlier sessions left, by - * that owner only, so Ask's and the runner's agents are never touched. + * can sign in (in production, web/planning.ts, always a planning check: at startup, or one a request starts) removes + * what earlier sessions left, by that owner only, so Ask's and the runner's agents are never touched. A first job whose + * recovery fails leaves it to the next job. * * The code is the review's repository at the current snapshot's head; E3 discards a reply whose snapshot or revision * changed meanwhile. The vendor is the one chosen in Settings for Ask; Codex is refused (#93). @@ -248,7 +249,8 @@ export class PlanningCheck { // Never throws: an exception here would end the process. A Store that cannot be read starts nothing. try { const now = this.#vendor(); if (now) this.#check(now); } catch { /* stopping, or no Store: nothing to start */ } }); - throw new GuardRefusal(STILL_CHECKING); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost is ` + + 'checking it now. Retry in a minute.'); } #verdict(check: Check): void { if (check.passed) return; diff --git a/runner/question-leftovers.ts b/runner/question-leftovers.ts index b996fc02..f5f1ed3c 100644 --- a/runner/question-leftovers.ts +++ b/runner/question-leftovers.ts @@ -190,9 +190,9 @@ function parse(text: string, naming: WorkerNaming): LedgerRecord { /** * Durable record of a feature's host copies (worker roots) that outlived their worker, and the review's lock for that * feature. Docker leftovers need no record: the feature labels them with the review's own owner token for it, and the - * first job of each process that can sign in (for planning, usually its startup check, #164) removes them through lane - * D's scoped recovery (#51 item 4, #65). Ask and planning each have their own ledger, lock and owner, so neither one's - * recovery or refusals affect the other (#117). + * first job of each process that can sign in and recovers (for planning, usually its startup check, #164) removes them + * through lane D's scoped recovery (#51 item 4, #65). Ask and planning each have their own ledger, lock and owner, so + * neither one's recovery or refusals affect the other (#117). */ export class LeftoverLedger { readonly path: string; diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 4a4e6c56..4e19ed35 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -414,7 +414,7 @@ describe('the planning check (#164)', () => { const request = {} as Parameters[0]; // A rejection, as `AuthorProvider.invoke` promises: E3 then settles the request as failed with this reason // (test/planning-suggestions.test.ts covers a provider whose invoke fails). - await expect(made.deps.provider.invoke(request, signal())).rejects.toThrow('still checking'); + await expect(made.deps.provider.invoke(request, signal())).rejects.toThrow('is checking it now'); expect(made.agent.invoke).not.toHaveBeenCalled(); }); @@ -497,7 +497,7 @@ describe('the planning check (#164)', () => { const uncaught = vi.fn(); process.on('uncaughtException', uncaught); try { - expect(() => check.assertPassed()).toThrow('still checking'); + expect(() => check.assertPassed()).toThrow('is checking it now'); fail = true; await new Promise(resolve => setImmediate(resolve)); expect(uncaught).not.toHaveBeenCalled(); @@ -684,7 +684,7 @@ describe('the planning check (#164)', () => { const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); - expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent.`); + expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost is checking it now.`); // validate runs inside the Store's transaction, so the new vendor's check starts only on the next turn. expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude']); await new Promise(resolve => setImmediate(resolve)); From 8b223d7842e4a40c4c7750446c0962c6033849be Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:36:10 -0700 Subject: [PATCH 24/39] Restart wording says fix first; shutdown timing; drop a dead flag (#164) Review round 21: refusals for restart-only reasons say codeboost must be restarted after the cause is fixed; the still-checking refusal no longer claims every slow check is the first; the doc says shutdown cancels a check once planning requests settle or after the grace; the replaced flag, always implied by a changed current check, is gone; a test uses AgentWorker's real text. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 6 +++--- runner/planning.ts | 21 ++++++++++----------- test/planning-production.test.ts | 14 ++++++++------ 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 21d68b83..eea190e3 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -104,9 +104,9 @@ to copy), and a check still running after the wait is refused with the same prefix; the server records the refusal and starts no agent. Requests within a minute of a failure are refused with it; the next request after that checks again. With no vendor chosen nothing is checked, and the request fails later, as before, -with `NO_PLANNING_AGENT`. After the server's 20 s grace for planning requests, -shutdown cancels a running check and waits up to 20 s more for it before closing -the worker. A check that runs before the runner's setup has set the runner-owned +with `NO_PLANNING_AGENT`. Once planning requests have settled, or after the +server's 20 s grace for them, shutdown cancels a running check and waits up to +20 s more for it before closing the worker. A check that runs before the runner's setup has set the runner-owned repository fails, on a task with runner commits, with "which is not configured"; the recheck a minute later passes once the runner's setup has finished, and without a runner block it fails each time, as every planning request would. A diff --git a/runner/planning.ts b/runner/planning.ts index a92c64bc..d24703fe 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -93,15 +93,15 @@ export const PLANNING_CHECK_CLOSE_MS = 20_000; /** One run of the planning check, for one vendor. `passed` or `failure` is set once it settles. */ type Check = { vendor: Provider; done: Promise; passed: boolean; failure?: { error: unknown; at: number }; - abort: AbortController; replaced?: boolean }; + abort: AbortController }; const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error); -const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. The first check after ` - + 'codeboost starts can take several minutes while the agent image is built. Retry in a minute.'; +const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent. A check can take ` + + 'several minutes the first time, while the agent image is built. Retry in a minute.'; const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : ms >= 120_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1_000)} s`; -// Reasons a recheck cannot help, so only a restart can: an abandoned worker (question-agent.ts), unconfirmed cleanup -// (planning-provider.ts' "off until codeboost restarts"), a database file with other hard links (question-leftovers.ts), -// and a missing token, since codeboost's environment is fixed while it runs. Each is codeboost's own message, given +// Reasons a recheck cannot help, so codeboost must be restarted once the cause is fixed: an abandoned worker +// (question-agent.ts), unconfirmed cleanup (planning-provider.ts' "off until codeboost restarts"), a database file with +// other hard links (question-leftovers.ts), and a missing token, since codeboost's environment is fixed while it runs. Each is codeboost's own message, given // before any vendor output. A run whose cleanup did not settle is not among them: its storage is retried before the // next run, and if planning did go off, the recheck reports that with these words. const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links/; @@ -159,7 +159,6 @@ export class PlanningCheck { if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); // A check for another vendor no longer admits anything: stop it, so replaced checks do not pile up in the worker. if (this.#current && !this.#current.passed && !this.#current.failure) { - this.#current.replaced = true; this.#current.abort.abort(new Error('The planning agent chosen in Settings changed.')); } const abort = new AbortController(), stop = () => abort.abort(this.#abort.signal.reason); @@ -226,8 +225,8 @@ export class PlanningCheck { } if (outcome === 'timeout') throw new GuardRefusal(STILL_CHECKING); // Settings changed while this request waited, or its check was replaced (the vendor changed and changed back): - // check the vendor it will run with, within the same wait. - if (this.#vendor() !== vendor || check.replaced || check !== this.#current) continue; + // check the vendor it will run with, within the same wait. A replaced check is never `#current` again. + if (this.#vendor() !== vendor || check !== this.#current) continue; if (!check.passed && !check.failure) continue; // This check's own verdict: calling #check again could start a recheck once the window has passed. return this.#verdict(check); @@ -259,8 +258,8 @@ export class PlanningCheck { // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart // gets no promise of a recheck, which could not help. if (check.failure && UNTIL_RESTART.test(ownText(messageOf(check.failure.error)))) - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart ` - + `of codeboost can change that. The check said: ${messageOf(check.failure.error)}`); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and codeboost must ` + + `be restarted after it is fixed. The check said: ${messageOf(check.failure.error)}`); // Codex is refused whatever happens (#93): only choosing another agent helps, and the next request checks that // one. A restart-only reason above keeps its own wording. if (check.failure && check.vendor === 'codex') diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 4e19ed35..1b44b09e 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -14,6 +14,7 @@ import { PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { PLANNING_UNAVAILABLE, PlanningCheck } from '../runner/planning.ts'; +import { PLANNING_NAMING } from '../runner/question-leftovers.ts'; // Production planning wiring (#117): when it is on, what each request is told, and how the server awaits the issue. vi.setConfig({ testTimeout: 20_000 }); @@ -543,10 +544,10 @@ describe('the planning check (#164)', () => { it.each([ ['a database file with other hard links', 'Planning is off: the review database /r/db has other hard links, so codeboost cannot tell whether another process is using it. Use a database file without hard links.'], - ])('promises no recheck for %s, which only a restart can change', async (_label, reason) => { + ])('promises no recheck for %s, which needs a restart once fixed', async (_label, reason) => { const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toContain('only a restart of codeboost can change that'); + expect((refusal as Error).message).toContain('codeboost must be restarted after it is fixed'); }); it('keeps the recheck promise when a run\'s cleanup did not settle, which the next run retries', async () => { @@ -612,18 +613,19 @@ describe('the planning check (#164)', () => { expect(order).toEqual(['probe stopped', 'closed']); }); - it('promises no recheck when the sign-in is missing, which only a restart can change', async () => { + it('promises no recheck when the sign-in is missing, which needs a restart once fixed', async () => { const reason = 'Planning with Claude Code needs CLAUDE_CODE_OAUTH_TOKEN. Create one with `claude setup-token`.'; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that. The check said: ${reason}`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and codeboost must be restarted after it is fixed. The check said: ${reason}`); }); it('promises no recheck when the check says planning is off until a restart', async () => { - const reason = 'The agent container worker stopped. Its containers and storage may still exist, so planning is off until codeboost restarts.'; + // AgentWorker's own text after the planning worker exits (question-agent.ts). + const reason = `The agent container worker stopped (exit code 1). Its containers and storage may still exist, so ${PLANNING_NAMING.label} is off until codeboost restarts. The first ${PLANNING_NAMING.job} after the restart removes them.`; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that. The check said: ${reason}`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and codeboost must be restarted after it is fixed. The check said: ${reason}`); }); it('starts no check after close', async () => { From 1f6d9d640fcc10e396006a7024d3d2ae83c35e0c Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:44:14 -0700 Subject: [PATCH 25/39] Restart wording fits every restart-only reason; comment fixes (#164) Review round 22: a restart-only refusal says only a restart helps and to fix the cause first if the reason names one; the unreachable still-running branch is gone; the check timeout's doc says no request waits that long. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 17 ++++++++--------- test/planning-production.test.ts | 10 +++++----- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index d24703fe..b4151a54 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -99,17 +99,17 @@ const STILL_CHECKING = `${PLANNING_UNAVAILABLE} codeboost is still checking the + 'several minutes the first time, while the agent image is built. Retry in a minute.'; const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a moment' : ms >= 120_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1_000)} s`; -// Reasons a recheck cannot help, so codeboost must be restarted once the cause is fixed: an abandoned worker +// Reasons a recheck cannot help, so only a restart can, once any cause is fixed: an abandoned worker // (question-agent.ts), unconfirmed cleanup (planning-provider.ts' "off until codeboost restarts"), a database file with -// other hard links (question-leftovers.ts), and a missing token, since codeboost's environment is fixed while it runs. Each is codeboost's own message, given -// before any vendor output. A run whose cleanup did not settle is not among them: its storage is retried before the +// other hard links (question-leftovers.ts), and a missing token, since codeboost's environment is fixed while it runs. +// Each is codeboost's own message, given before any vendor output. A run whose cleanup did not settle is not among them: its storage is retried before the // next run, and if planning did go off, the recheck reports that with these words. const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links/; const ownText = (message: string) => message.split(' Claude said:')[0]!; /** * How long one check may take before it fails: a real request's budget, as the probe has, and a minute for the worker - * to settle. Waiting requests are then refused with the failure, and the next one after the recheck window checks - * again. A recheck joins the worker's one startup recovery, so a recovery that never ends fails each recheck the same + * to settle. No request waits that long (see PLANNING_ADMIT_WAIT_MS): requests after it are refused with the failure + * until the recheck window passes, and the next one after that checks again. A recheck joins the worker's one startup recovery, so a recovery that never ends fails each recheck the same * way until codeboost restarts. */ export const PLANNING_CHECK_TIMEOUT_MS = PLANNING_BUDGET_MS + 60_000; @@ -227,8 +227,7 @@ export class PlanningCheck { // Settings changed while this request waited, or its check was replaced (the vendor changed and changed back): // check the vendor it will run with, within the same wait. A replaced check is never `#current` again. if (this.#vendor() !== vendor || check !== this.#current) continue; - if (!check.passed && !check.failure) continue; - // This check's own verdict: calling #check again could start a recheck once the window has passed. + // Settled, so passed or failed. This check's own verdict: calling #check again could start a recheck once the window has passed. return this.#verdict(check); } } @@ -258,8 +257,8 @@ export class PlanningCheck { // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart // gets no promise of a recheck, which could not help. if (check.failure && UNTIL_RESTART.test(ownText(messageOf(check.failure.error)))) - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and codeboost must ` - + `be restarted after it is fixed. The check said: ${messageOf(check.failure.error)}`); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart ` + + `of codeboost can change that; fix the cause first if the reason names one. The check said: ${messageOf(check.failure.error)}`); // Codex is refused whatever happens (#93): only choosing another agent helps, and the next request checks that // one. A restart-only reason above keeps its own wording. if (check.failure && check.vendor === 'codex') diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 1b44b09e..42882986 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -544,10 +544,10 @@ describe('the planning check (#164)', () => { it.each([ ['a database file with other hard links', 'Planning is off: the review database /r/db has other hard links, so codeboost cannot tell whether another process is using it. Use a database file without hard links.'], - ])('promises no recheck for %s, which needs a restart once fixed', async (_label, reason) => { + ])('promises no recheck for %s, which only a restart can change', async (_label, reason) => { const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toContain('codeboost must be restarted after it is fixed'); + expect((refusal as Error).message).toContain('only a restart of codeboost can change that'); }); it('keeps the recheck promise when a run\'s cleanup did not settle, which the next run retries', async () => { @@ -613,11 +613,11 @@ describe('the planning check (#164)', () => { expect(order).toEqual(['probe stopped', 'closed']); }); - it('promises no recheck when the sign-in is missing, which needs a restart once fixed', async () => { + it('promises no recheck when the sign-in is missing, which only a restart can change', async () => { const reason = 'Planning with Claude Code needs CLAUDE_CODE_OAUTH_TOKEN. Create one with `claude setup-token`.'; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and codeboost must be restarted after it is fixed. The check said: ${reason}`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that; fix the cause first if the reason names one. The check said: ${reason}`); }); it('promises no recheck when the check says planning is off until a restart', async () => { @@ -625,7 +625,7 @@ describe('the planning check (#164)', () => { const reason = `The agent container worker stopped (exit code 1). Its containers and storage may still exist, so ${PLANNING_NAMING.label} is off until codeboost restarts. The first ${PLANNING_NAMING.job} after the restart removes them.`; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and codeboost must be restarted after it is fixed. The check said: ${reason}`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that; fix the cause first if the reason names one. The check said: ${reason}`); }); it('starts no check after close', async () => { From 71d4ca7621544fd0a8236cdc8e82886deb46b6cd Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 21:54:59 -0700 Subject: [PATCH 26/39] Restart-only reasons tested from their sources; a cleared agent ends a wait (#164) Review round 23: the restart-wording tests build each reason with the code that gives it (abandonedWorkerText is exported for that); a request whose wait ran out goes on when Settings cleared the agent meanwhile; a Store error is labelled a stopped wait only when it is the request's own abort; the null-to-Claude path is the one tested for a vendor chosen during the issue read; the abort signal's listener limit, which Node 26 never warns about, is no longer raised; the suggestion-cancel row names the check that fails before the worker. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 21 +++++---- runner/question-agent.ts | 5 ++- test/planning-production.test.ts | 60 ++++++++++++++----------- web/planning.ts | 2 +- 5 files changed, 54 insertions(+), 36 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index e186432d..8e97a7f6 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts (a check whose recovery fails leaves it to the next one); until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set, unless that check fails before it reaches the worker (on a task with runner commits, the runner's setup has not yet set the runner-owned repository; see `planning-acceptance.md`) (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts (a check whose recovery fails leaves it to the next one); until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index b4151a54..56f1294f 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -102,15 +102,16 @@ const duration = (ms: number) => ms === 60_000 ? 'a minute' : ms < 1_000 ? 'a mo // Reasons a recheck cannot help, so only a restart can, once any cause is fixed: an abandoned worker // (question-agent.ts), unconfirmed cleanup (planning-provider.ts' "off until codeboost restarts"), a database file with // other hard links (question-leftovers.ts), and a missing token, since codeboost's environment is fixed while it runs. -// Each is codeboost's own message, given before any vendor output. A run whose cleanup did not settle is not among them: its storage is retried before the -// next run, and if planning did go off, the recheck reports that with these words. +// Each is codeboost's own message, given before any vendor output. A run whose cleanup did not settle is not among +// them: its storage is retried before the next run, and if planning did go off, the recheck reports that with these +// words. const UNTIL_RESTART = /until codeboost restarts|needs CLAUDE_CODE_OAUTH_TOKEN|a database file without hard links/; const ownText = (message: string) => message.split(' Claude said:')[0]!; /** * How long one check may take before it fails: a real request's budget, as the probe has, and a minute for the worker * to settle. No request waits that long (see PLANNING_ADMIT_WAIT_MS): requests after it are refused with the failure - * until the recheck window passes, and the next one after that checks again. A recheck joins the worker's one startup recovery, so a recovery that never ends fails each recheck the same - * way until codeboost restarts. + * until the recheck window passes, and the next one after that checks again. A recheck joins the worker's one startup + * recovery, so a recovery that never ends fails each recheck the same way until codeboost restarts. */ export const PLANNING_CHECK_TIMEOUT_MS = PLANNING_BUDGET_MS + 60_000; /** @@ -135,8 +136,9 @@ export class PlanningCheck { readonly #events = new EventTarget(); constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, options: { recheckMs?: number; waitMs?: number; timeoutMs?: number } = {}) { - // One listener per waiting request and per running check, each removed when it ends: many at once are no leak. - setMaxListeners(0, this.#events, this.#abort.signal); + // One listener per waiting request, each removed when its wait ends: many at once are no leak. (Node 26 warns of + // none on an AbortSignal, which holds one per running check.) + setMaxListeners(0, this.#events); this.#probe = probe; this.#vendor = vendor; this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; this.#waitMs = options.waitMs ?? PLANNING_ADMIT_WAIT_MS; this.#timeoutMs = options.timeoutMs ?? PLANNING_CHECK_TIMEOUT_MS; @@ -223,11 +225,14 @@ export class PlanningCheck { finally { signal.removeEventListener('abort', release); this.#events.removeEventListener('replaced', moved); clearTimeout(timer); } - if (outcome === 'timeout') throw new GuardRefusal(STILL_CHECKING); + // A vendor changed meanwhile is checked (or, if none is chosen now, the request goes on) within what is left of + // the wait, which has passed: only a check that has already settled gives its verdict. + if (outcome === 'timeout' && this.#vendor() === vendor) throw new GuardRefusal(STILL_CHECKING); // Settings changed while this request waited, or its check was replaced (the vendor changed and changed back): // check the vendor it will run with, within the same wait. A replaced check is never `#current` again. if (this.#vendor() !== vendor || check !== this.#current) continue; - // Settled, so passed or failed. This check's own verdict: calling #check again could start a recheck once the window has passed. + // Settled, so passed or failed. This check's own verdict: calling #check again could start a recheck once the + // window has passed. return this.#verdict(check); } } diff --git a/runner/question-agent.ts b/runner/question-agent.ts index 94b65140..27c73a9c 100644 --- a/runner/question-agent.ts +++ b/runner/question-agent.ts @@ -5,6 +5,9 @@ import { credentialEnvironment, questionCredential, stopOf, workerEnvironment, t import { planningCredential } from './planning-provider.ts'; import type { RecoverReply, ReleaseReply, WorkerReply, WorkerRequest } from './question-worker.ts'; import { ASK_NAMING, createWorkerRoot, removeWorkerRoot, type LeftoverLedger, type WorkerNaming } from './question-leftovers.ts'; + +/** Why a feature is off after its worker was given up on: planning's check (#164) matches its wording. */ +export const abandonedWorkerText = (naming: WorkerNaming, why: string) => `The agent container worker ${why}. Its containers and storage may still exist, so ${naming.label} is off until codeboost restarts. The first ${naming.job} after the restart removes them.`; export type { Provider } from './question-container.ts'; // Leave the worker time to cancel the container and release storage before the review's own timeout fires. @@ -113,7 +116,7 @@ export class AgentWorker { async #abandonOnce(why: string) { const worker = this.worker; this.worker = undefined; - this.crashed ??= new Error(`The agent container worker ${why}. Its containers and storage may still exist, so ${this.naming.label} is off until codeboost restarts. The first ${this.naming.job} after the restart removes them.`); + this.crashed ??= new Error(abandonedWorkerText(this.naming, why)); for (const release of this.releases.values()) release(null); this.releases.clear(); this.#failRecoveries(this.crashed); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 42882986..68164390 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { linkSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -10,11 +10,12 @@ import type { PlanningAgent } from '../runner/planning.ts'; import { ReviewService, type ReviewConfig } from '../runner/review.ts'; import { productionPlanning } from '../web/planning.ts'; import { DatabaseSync } from 'node:sqlite'; -import { PLANNING_BUDGET_MS } from '../runner/planning-provider.ts'; +import { PLANNING_BUDGET_MS, PLANNING_LEFTOVERS, planningCredential } from '../runner/planning-provider.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { PLANNING_UNAVAILABLE, PlanningCheck } from '../runner/planning.ts'; -import { PLANNING_NAMING } from '../runner/question-leftovers.ts'; +import { LeftoverLedger, PLANNING_NAMING } from '../runner/question-leftovers.ts'; +import { abandonedWorkerText } from '../runner/question-agent.ts'; // Production planning wiring (#117): when it is on, what each request is told, and how the server awaits the issue. vi.setConfig({ testTimeout: 20_000 }); @@ -521,6 +522,17 @@ describe('the planning check (#164)', () => { } finally { process.off('warning', warned); } }); + it('lets a request go on when Settings clear the agent while it waits, even once the wait has passed', async () => { + let vendor: 'claude' | null = 'claude'; + const check = new PlanningCheck((_vendor, probeSignal) => new Promise(resolve => + probeSignal.addEventListener('abort', () => resolve(), { once: true })), () => vendor, { waitMs: 50 }); + const waiting = check.admit(signal()); + vendor = null; + // No check replaces Claude's, so only the wait's end sees the change. + await expect(waiting).resolves.toBeUndefined(); + await check.close(2_000); + }); + it('fails and cancels a check that does not settle in time, and checks again after the recheck window', async () => { let count = 0, late!: () => void; const signals: AbortSignal[] = []; @@ -542,14 +554,6 @@ describe('the planning check (#164)', () => { expect(count).toBe(2); }); - it.each([ - ['a database file with other hard links', 'Planning is off: the review database /r/db has other hard links, so codeboost cannot tell whether another process is using it. Use a database file without hard links.'], - ])('promises no recheck for %s, which only a restart can change', async (_label, reason) => { - const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); - const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toContain('only a restart of codeboost can change that'); - }); - it('keeps the recheck promise when a run\'s cleanup did not settle, which the next run retries', async () => { // planning-provider.ts' cleanupFailed: the run's own failure, then the cleanup's. const reason = 'Planning agent timed out. Cleanup also did not settle: docker volume rm failed'; @@ -613,16 +617,21 @@ describe('the planning check (#164)', () => { expect(order).toEqual(['probe stopped', 'closed']); }); - it('promises no recheck when the sign-in is missing, which only a restart can change', async () => { - const reason = 'Planning with Claude Code needs CLAUDE_CODE_OAUTH_TOKEN. Create one with `claude setup-token`.'; - const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); - const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that; fix the cause first if the reason names one. The check said: ${reason}`); - }); - - it('promises no recheck when the check says planning is off until a restart', async () => { - // AgentWorker's own text after the planning worker exits (question-agent.ts). - const reason = `The agent container worker stopped (exit code 1). Its containers and storage may still exist, so ${PLANNING_NAMING.label} is off until codeboost restarts. The first ${PLANNING_NAMING.job} after the restart removes them.`; + // Each reason as the code that gives it words it, so a reworded message that no longer matches fails here. + const thrown = (run: () => unknown) => { try { run(); } catch (error) { return (error as Error).message; } throw new Error('It did not throw.'); }; + const hardLinked = () => { + const root = mkdtempSync(join(tmpdir(), 'codeboost-linked-')); closers.push(() => rmSync(root, { recursive: true, force: true })); + writeFileSync(join(root, 'review.db'), ''); linkSync(join(root, 'review.db'), join(root, 'other.db')); + const ledger = LeftoverLedger.forDatabase(join(root, 'review.db'), PLANNING_NAMING); + return thrown(() => { ledger.acquire(); ledger.release(); }); + }; + it.each([ + ['the sign-in is missing', () => thrown(() => planningCredential('claude', {}))], + ['the worker was given up on', () => abandonedWorkerText(PLANNING_NAMING, 'stopped (exit code 1)')], + ['cleanup could not be confirmed', () => PLANNING_LEFTOVERS.untracked()], + ['the database file has other hard links', hardLinked], + ])('promises no recheck when %s, which only a restart can change', async (_label, reasonOf) => { + const reason = reasonOf(); const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that; fix the cause first if the reason names one. The check said: ${reason}`); @@ -678,19 +687,20 @@ describe('the planning check (#164)', () => { it('refuses at the start a vendor chosen while the issue was read, and checks it (#164)', async () => { let service!: ReviewService; + // Settings take Claude while the issue is read, after a request with none chosen was admitted unchecked. const issueText = vi.fn(async (number: number) => { - vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); + service.store.setQuestionProvider('claude'); return text(number); }); // (The provider, too, checks again when it is invoked: "refuses to invoke an agent whose check has not passed …".) - const made = checked(async () => undefined, 'claude', undefined, undefined, issueText); + const made = checked(async () => undefined, null, undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost is checking it now.`); // validate runs inside the Store's transaction, so the new vendor's check starts only on the next turn. - expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude']); + expect(made.probe).not.toHaveBeenCalled(); await new Promise(resolve => setImmediate(resolve)); - expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); + expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude']); // Once that check passed, the same request is admitted. await new Promise(resolve => setTimeout(resolve, 0)); expect(() => described.validate()).not.toThrow(); diff --git a/web/planning.ts b/web/planning.ts index bb6473ad..7a727cad 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -52,7 +52,7 @@ export function productionPlanning(config: ReviewConfig, options: { catch (error) { if (error instanceof GuardRefusal) throw error; const reason = error instanceof Error ? error.message : String(error); - throw new GuardRefusal(signal.aborted ? `${PLANNING_UNAVAILABLE} codeboost stopped waiting for the planning check: ${reason}` + throw new GuardRefusal(error === signal.reason ? `${PLANNING_UNAVAILABLE} codeboost stopped waiting for the planning check: ${reason}` : `${PLANNING_UNAVAILABLE} codeboost could not check the planning agent: ${reason}`); } return withIssueReadDeadline(signal, async (readSignal, timeoutMs) => { From 2b824fa58bdcb6eb1aed856365990ee6ec5e86c5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 22:05:11 -0700 Subject: [PATCH 27/39] Comment fixes; drop an abort check that cannot fire (#164) Review round 24: the request's signal is checked at the top of each loop with no await before the race, so the second check is gone; admit's doc covers no vendor chosen; the refusal prefix's doc names its refusals as examples; the recheck window's wording reads one way. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 56f1294f..a697d130 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -82,7 +82,10 @@ export class PlanningAgent implements AuthorProvider { close(): Promise { return this.#worker.close(); } } -/** Starts every refusal from planning admission: a failed or unfinished check, a stopped wait, and planning stopping. */ +/** + * Starts every refusal from planning admission, such as a failed or unfinished check, a vendor changed since the + * request was admitted, a stopped wait, a Store that cannot be read, and planning stopping. + */ export const PLANNING_UNAVAILABLE = 'Planning is unavailable:'; /** How long a failed check refuses requests before the next request checks again. */ export const PLANNING_RECHECK_MS = 60_000; @@ -148,7 +151,7 @@ export class PlanningCheck { const vendor = this.#vendor(); if (vendor) this.#check(vendor); } - // The current check if it covers `vendor`: for that vendor, and not failed `recheckMs` ago. + // The current check if it covers `vendor`: for that vendor, and passed, running, or failed less than `recheckMs` ago. #covering(vendor: Provider) { const current = this.#current; return current && current.vendor === vendor @@ -194,7 +197,7 @@ export class PlanningCheck { return check; } /** - * Resolve once the check for the vendor chosen now has passed; reject with a `GuardRefusal` naming why when it + * Resolve once the check for the vendor chosen now has passed, or at once when no vendor is chosen; reject with a `GuardRefusal` naming why when it * failed, or when no check passed within `waitMs`. `signal` stops only this wait, never the check, which other * requests may share: an aborted wait rejects with `signal.reason`, and a vendor that cannot be read (a Store error) * with that error; the caller maps both. @@ -219,7 +222,6 @@ export class PlanningCheck { moved = () => resolve('replaced'); this.#events.addEventListener('replaced', moved, { once: true }); timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - performance.now())); }); - if (signal.aborted) release(); let outcome: void | 'timeout' | 'replaced'; try { outcome = await Promise.race([check.done, stopped]); } finally { From 857e57dc8d11d092735b38d841c939f8a1254bd6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 22:12:27 -0700 Subject: [PATCH 28/39] Codex's stand-in check fails at once, as the real one does (#164) Review round 25: the tests that switch to Codex give it the probe answer PlanningAgent.probe gives (CODEX_PLANNING_REFUSED) and expect its refusal; a doc line is rewrapped. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 4 ++-- test/planning-production.test.ts | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index a697d130..5a6d80f1 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -197,8 +197,8 @@ export class PlanningCheck { return check; } /** - * Resolve once the check for the vendor chosen now has passed, or at once when no vendor is chosen; reject with a `GuardRefusal` naming why when it - * failed, or when no check passed within `waitMs`. `signal` stops only this wait, never the check, which other + * Resolve once the check for the vendor chosen now has passed, or at once when no vendor is chosen; reject with a + * `GuardRefusal` naming why when it failed, or when no check passed within `waitMs`. `signal` stops only this wait, never the check, which other * requests may share: an aborted wait rejects with `signal.reason`, and a vendor that cannot be read (a Store error) * with that error; the caller maps both. * diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 68164390..8b6b04dc 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -10,7 +10,7 @@ import type { PlanningAgent } from '../runner/planning.ts'; import { ReviewService, type ReviewConfig } from '../runner/review.ts'; import { productionPlanning } from '../web/planning.ts'; import { DatabaseSync } from 'node:sqlite'; -import { PLANNING_BUDGET_MS, PLANNING_LEFTOVERS, planningCredential } from '../runner/planning-provider.ts'; +import { CODEX_PLANNING_REFUSED, PLANNING_BUDGET_MS, PLANNING_LEFTOVERS, planningCredential } from '../runner/planning-provider.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { PLANNING_UNAVAILABLE, PlanningCheck } from '../runner/planning.ts'; @@ -384,7 +384,10 @@ describe('the planning check (#164)', () => { }); it('checks nothing while no agent is chosen, then checks the vendor chosen before its first request', async () => { - const { deps, service, probe, issueAccess } = checked(async () => undefined, null); + // As PlanningAgent.probe answers: Claude's check passes here, and Codex's always fails (planningCredential). + const { deps, service, probe, issueAccess } = checked(async vendor => { + if (vendor === 'codex') throw new Error(CODEX_PLANNING_REFUSED); + }, null); expect(probe).not.toHaveBeenCalled(); // No vendor: the request goes on, and fails later with NO_PLANNING_AGENT, as before. await deps.describe(signal()); @@ -393,9 +396,9 @@ describe('the planning check (#164)', () => { await deps.describe(signal()); // Settings refuse Codex now, but a database from before #75 may still hold it. vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); - await deps.describe(signal()); + await expect(deps.describe(signal())).rejects.toThrow(CODEX_PLANNING_REFUSED); expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); - expect(issueAccess).toHaveBeenCalledTimes(3); + expect(issueAccess).toHaveBeenCalledTimes(2); }); it('checks the vendor chosen while a request waited, before it admits the request', async () => { @@ -443,14 +446,16 @@ describe('the planning check (#164)', () => { it('cancels a running check that a check for another vendor replaced', async () => { let vendor: 'claude' | 'codex' = 'claude'; const signals: AbortSignal[] = []; - const check = new PlanningCheck((_vendor, probeSignal) => { signals.push(probeSignal); return new Promise(() => undefined); }, - () => vendor, { waitMs: 10 }); + // Claude's check runs until cancelled; Codex's fails at once, as PlanningAgent.probe's does. + const check = new PlanningCheck((probed, probeSignal) => { + signals.push(probeSignal); + return probed === 'codex' ? Promise.reject(new Error(CODEX_PLANNING_REFUSED)) : new Promise(() => undefined); + }, () => vendor, { waitMs: 10 }); check.start(); vendor = 'codex'; - await expect(check.admit(signal())).rejects.toThrow('still checking'); + await expect(check.admit(signal())).rejects.toThrow('choose another agent in Settings'); expect(signals.map(probeSignal => probeSignal.aborted)).toEqual([true, false]); await check.close(10); - expect(signals[1]!.aborted).toBe(true); }); it('keeps a multi-line reason whole, at the end of the refusal', async () => { From 5c67707a192a4e9da77f08ff2fd58219f2109f60 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 22:21:59 -0700 Subject: [PATCH 29/39] Reasons in the check's tests come from the code that gives them (#164) Review round 26: the vendor-output, unsettled-cleanup, Codex and multi-line reasons are built by planningOutput, PLANNING_FEATURE.cleanupFailed, CODEX_PLANNING_REFUSED and assertClear's wording; web/planning.ts says when the check starts with no vendor chosen; a comment names the recheck window. Co-Authored-By: Claude Opus 5.5 --- test/planning-production.test.ts | 23 +++++++++++++++-------- web/planning.ts | 6 +++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 8b6b04dc..a185e77c 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -10,7 +10,9 @@ import type { PlanningAgent } from '../runner/planning.ts'; import { ReviewService, type ReviewConfig } from '../runner/review.ts'; import { productionPlanning } from '../web/planning.ts'; import { DatabaseSync } from 'node:sqlite'; -import { CODEX_PLANNING_REFUSED, PLANNING_BUDGET_MS, PLANNING_LEFTOVERS, planningCredential } from '../runner/planning-provider.ts'; +import { CODEX_PLANNING_REFUSED, PLANNING_BUDGET_MS, PLANNING_FEATURE, PLANNING_LEFTOVERS, planningCredential, + planningOutput } from '../runner/planning-provider.ts'; +import type { InvocationInput } from '../agents/contract.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; import { PLANNING_UNAVAILABLE, PlanningCheck } from '../runner/planning.ts'; @@ -330,6 +332,7 @@ describe('the planning check (#164)', () => { return { deps, service, agent, issueAccess, close, probe: vi.mocked(agent.probe) }; } const signal = () => new AbortController().signal; + const thrown = (run: () => unknown) => { try { run(); } catch (error) { return (error as Error).message; } throw new Error('It did not throw.'); }; it('starts at setup with the vendor chosen in Settings, and that one check serves every later request', async () => { const { deps, probe, issueAccess } = checked(async () => undefined); @@ -346,7 +349,7 @@ describe('the planning check (#164)', () => { const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*The check said: Claude could not sign in\\.$`)); - // Inside the wait, the same failure refuses at once. + // Inside the recheck window, the same failure refuses at once. await expect(deps.describe(signal())).rejects.toThrow('Claude could not sign in.'); expect(probe).toHaveBeenCalledTimes(1); expect(issueAccess).not.toHaveBeenCalled(); @@ -459,7 +462,8 @@ describe('the planning check (#164)', () => { }); it('keeps a multi-line reason whole, at the end of the refusal', async () => { - const reason = 'Planning stays off until they are deleted. Delete them, then retry:\nrm -rf \'/tmp/codeboost-planning-x\''; + // LeftoverLedger.assertClear's text for host copies it could not delete. + const reason = `${PLANNING_NAMING.label} is off: host copies of ${PLANNING_NAMING.code} or credentials from an earlier session could not be deleted. Delete them, then retry:\nrm -rf '/tmp/codeboost-plan-x'`; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message.endsWith(`The check said: ${reason}`)).toBe(true); @@ -561,14 +565,18 @@ describe('the planning check (#164)', () => { it('keeps the recheck promise when a run\'s cleanup did not settle, which the next run retries', async () => { // planning-provider.ts' cleanupFailed: the run's own failure, then the cleanup's. - const reason = 'Planning agent timed out. Cleanup also did not settle: docker volume rm failed'; + const reason = PLANNING_FEATURE.cleanupFailed([new Error('docker volume rm failed')], { error: new Error('Planning agent timed out.') }).message; const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message).toContain('it checks again on the next request after a minute'); }); it('keeps the recheck promise when only the vendor\'s output mentions a restart', async () => { - const reason = 'Claude could not write the plan. Check its sign-in and usage limits. Claude said: planning is off until codeboost restarts'; + // planningOutput's text for a failed run whose output says so. + const context = { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }; + const reason = thrown(() => planningOutput({ attemptId: 'probe', context, exitCode: 1, signal: null, stderr: '', + stdout: 'planning is off until codeboost restarts' }, { attemptId: 'probe', context } as InvocationInput)); + expect(reason).toContain(' Claude said: planning is off until codeboost restarts'); const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); expect((refusal as Error).message).toContain('it checks again on the next request after a minute'); @@ -604,9 +612,9 @@ describe('the planning check (#164)', () => { }); it('tells Codex to choose another agent, not to wait for a recheck', async () => { - const check = new PlanningCheck(async () => { throw new Error('Codex cannot write plans yet.'); }, () => 'codex'); + const check = new PlanningCheck(async () => { throw new Error(CODEX_PLANNING_REFUSED); }, () => 'codex'); const refusal = await check.admit(signal()).catch((error: unknown) => error); - expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choose another agent in Settings, and the next planning request checks it. The check said: Codex cannot write plans yet.`); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed; choose another agent in Settings, and the next planning request checks it. The check said: ${CODEX_PLANNING_REFUSED}`); }); it('waits on close for a timed-out check\'s probe to stop, within its bound', async () => { @@ -623,7 +631,6 @@ describe('the planning check (#164)', () => { }); // Each reason as the code that gives it words it, so a reworded message that no longer matches fails here. - const thrown = (run: () => unknown) => { try { run(); } catch (error) { return (error as Error).message; } throw new Error('It did not throw.'); }; const hardLinked = () => { const root = mkdtempSync(join(tmpdir(), 'codeboost-linked-')); closers.push(() => rmSync(root, { recursive: true, force: true })); writeFileSync(join(root, 'review.db'), ''); linkSync(join(root, 'review.db'), join(root, 'other.db')); diff --git a/web/planning.ts b/web/planning.ts index 7a727cad..0e87c0bb 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -10,9 +10,9 @@ export { ISSUE_READ_TIMEOUT_MS } from '../github/issues.ts'; * Production planning (#117): on for a review with a github block, never in a demo. It needs no runner block, since * planning only reads code, as Ask does. The issue text is read from GitHub for each request (current collaborators' * comments, or every comment under explicit author-bound trust), bounded like an execute prompt; approved lessons stay - * empty until lessons exist (L1 to L4). When a vendor is chosen in Settings, the planning check (#164) starts with the - * server and must pass before a request reads the issue or records anything; a failed check refuses the request with - * its reason. + * empty until lessons exist (L1 to L4). The planning check (#164) starts with the server for the vendor chosen in + * Settings (or, with none chosen then, with the first request after one is), and must pass before a request reads the + * issue or records anything; a failed check refuses the request with its reason. */ export function productionPlanning(config: ReviewConfig, options: { /** The single-runner lock's check that the database path still names the locked file (runner/recovery.ts). */ From e0e46cb4f8592f2abecf4477734a36b6d5db7edc Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 22:29:44 -0700 Subject: [PATCH 30/39] Real sign-in and Codex reasons in the check's tests; doc and layout (#164) Review round 27: the sign-in refusals use planningOutput's own text and the vendor-change test CODEX_PLANNING_REFUSED; checked() takes only the vendors Settings accept; the suggestion-cancel row gives the runner-repository case as one example of a check failing before recovery; comments rewrapped; abandonedWorkerText moved below the module's re-export. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/planning.ts | 12 +++++------ runner/question-agent.ts | 2 +- test/planning-production.test.ts | 28 +++++++++++++++---------- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 8e97a7f6..22d624fb 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -361,7 +361,7 @@ F1 owns the production planning endpoints that G4 needs. They wrap E3's coordina | `POST /api/plan/import` | `Store.importRevision` | expected revision; body of at most 6 MiB + 16 KiB, so every source the 1 MiB parser accepts fits (see `docs/plan-format.md`, #146) | | `POST /api/plan/suggestions` | E3 coordinator start (`beginSuggestions`) | expected revision and snapshot | | `GET /api/plan/suggestions/:id` | `Store.getSuggestions`, with the request's `mode` | reads only the request row; refuses a draft's ID ("Unknown suggestion request.") | -| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set, unless that check fails before it reaches the worker (on a task with runner commits, the runner's setup has not yet set the runner-owned repository; see `planning-acceptance.md`) (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts (a check whose recovery fails leaves it to the next one); until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | +| `POST /api/plan/suggestions/:id/cancel` | `pending`: `handle.cancel(reason)` on the `SuggestionHandle` that `SuggestionCoordinator.start()` returned. E3's coordinator has no cancel-by-ID method, so F keeps a map from request ID to handle, adds each handle when it starts the request, and removes it only after the handle's outcome settles. If the request is `pending` but F holds no handle, F calls `Store.cancelSuggestions`. After a restart this no longer arises while planning is on (a non-demo review with a `github` block): startup fails every request an earlier process left `pending` (#124). Without planning, such a request stays `pending` until it is cancelled here. It is safe to do so because a crashed process's planning container has no route back to the Store: its output went to that process's worker, which ended with it. A live planning provider runs only through D (#117), in planning's own worker, in containers labelled with this database's planning owner (not the runner's or Ask's). Like Ask's, those containers are removed by D's `recoverLeftovers` for that owner in the next process's first planning job that can sign in: the planning check at startup when Claude is chosen and `CLAUDE_CODE_OAUTH_TOKEN` is set, unless that check fails before its recovery runs (for example, on a task with runner commits before the runner's setup has set the runner-owned repository, see `planning-acceptance.md`; or when planning's leftover record refuses it, or another process holds planning's lock) (#164, which also takes planning's lock from then until planning closes at shutdown, or until the process exits after an abandoned worker or a recovery stopped part way, and builds the agent image in planning's worker while the runner, if one is configured, builds its own), otherwise the first check a planning request starts (a check whose recovery fails leaves it to the next one); until then a stray container can keep running read-only (its deadline is enforced from the host, which is gone) until Claude exits or that recovery removes it, and its output is discarded. If a planning provider ever runs outside D, or could report into the Store from another process, this endpoint must refuse such a request instead, until it has been reconciled. `ready`: `Store.cancelSuggestions` (dismiss), because no invocation is running. | request is `pending` or `ready`, as `Store.cancelSuggestions` already allows | | `POST /api/plan/suggestions/:id/apply` | `Store.applySuggestion` | request `ready` and bound to the current revision and snapshot | | `POST /api/plan/drafts` | E3 coordinator start in draft mode (`beginSuggestions(…, 'draft')`, #124): a whole next revision of the current plan. The request lane D sees carries revision n+1, the revision the draft would become; the Store binds it to n | expected revision and snapshot; one planning invocation per plan at a time in this process, shared with suggestions (several drafts may be ready at once) | | `GET /api/plan/drafts/:id` | `Store.getDraft` | reads only a draft request's row | diff --git a/runner/planning.ts b/runner/planning.ts index 5a6d80f1..569a9b05 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -198,9 +198,9 @@ export class PlanningCheck { } /** * Resolve once the check for the vendor chosen now has passed, or at once when no vendor is chosen; reject with a - * `GuardRefusal` naming why when it failed, or when no check passed within `waitMs`. `signal` stops only this wait, never the check, which other - * requests may share: an aborted wait rejects with `signal.reason`, and a vendor that cannot be read (a Store error) - * with that error; the caller maps both. + * `GuardRefusal` naming why when it failed, or when no check passed within `waitMs`. `signal` stops only this wait, + * never the check, which other requests may share: an aborted wait rejects with `signal.reason`, and a vendor that + * cannot be read (a Store error) with that error; the caller maps both. * * A passed check is kept until a check for another vendor replaces it: a worker that is abandoned later, cleanup that * does not settle later (the check's own run included, when its answer still came back), or a token revoked later @@ -239,9 +239,9 @@ export class PlanningCheck { } } /** - * Throw a `GuardRefusal` unless the check for the vendor chosen now has passed (or no vendor is chosen). Requests call - * it again inside the action that starts them, so a vendor chosen after `admit` never runs unchecked. It starts no - * check there (that runs inside the Store's transaction): one it needs is started on the next turn. + * Throw a `GuardRefusal` unless the check for the vendor chosen now has passed (or no vendor is chosen). Requests + * call it again inside the action that starts them, so a vendor chosen after `admit` never runs unchecked. It starts + * no check there (that runs inside the Store's transaction): one it needs is started on the next turn. */ assertPassed(): void { const vendor = this.#vendor(); diff --git a/runner/question-agent.ts b/runner/question-agent.ts index 27c73a9c..b97b930b 100644 --- a/runner/question-agent.ts +++ b/runner/question-agent.ts @@ -5,10 +5,10 @@ import { credentialEnvironment, questionCredential, stopOf, workerEnvironment, t import { planningCredential } from './planning-provider.ts'; import type { RecoverReply, ReleaseReply, WorkerReply, WorkerRequest } from './question-worker.ts'; import { ASK_NAMING, createWorkerRoot, removeWorkerRoot, type LeftoverLedger, type WorkerNaming } from './question-leftovers.ts'; +export type { Provider } from './question-container.ts'; /** Why a feature is off after its worker was given up on: planning's check (#164) matches its wording. */ export const abandonedWorkerText = (naming: WorkerNaming, why: string) => `The agent container worker ${why}. Its containers and storage may still exist, so ${naming.label} is off until codeboost restarts. The first ${naming.job} after the restart removes them.`; -export type { Provider } from './question-container.ts'; // Leave the worker time to cancel the container and release storage before the review's own timeout fires. const SETTLE_MARGIN_MS = 5_000; diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index a185e77c..bfb2adb6 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -321,7 +321,7 @@ it('ends a planning request that does not settle after shutdown aborts it by clo // #164: the planning check, through productionPlanning's setup with a stand-in agent whose `probe` is the check. describe('the planning check (#164)', () => { /** The production setup on a review whose Settings chose `vendor`, with a stand-in agent and issue reads. */ - function checked(probe: (vendor: string, signal: AbortSignal) => Promise, vendor: 'claude' | 'codex' | null = 'claude', + function checked(probe: (vendor: string, signal: AbortSignal) => Promise, vendor: 'claude' | null = 'claude', recheckMs?: number, admitWaitMs?: number, issueText = vi.fn(async (number: number) => text(number))) { const config = production(), service = new ReviewService(config); closers.push(() => service.close()); if (vendor) service.store.setQuestionProvider(vendor); @@ -333,6 +333,12 @@ describe('the planning check (#164)', () => { } const signal = () => new AbortController().signal; const thrown = (run: () => unknown) => { try { run(); } catch (error) { return (error as Error).message; } throw new Error('It did not throw.'); }; + // A real sign-in failure, as planningOutput words it for a failed run. Tests that only show a reason passed through + // whole use any text ('Docker is not running.', 'No worker could start.'). + const runContext = { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }; + const failedRun = (stdout: string) => thrown(() => planningOutput({ attemptId: 'probe', context: runContext, exitCode: 1, + signal: null, stderr: '', stdout }, { attemptId: 'probe', context: runContext } as InvocationInput)); + const signInFailure = failedRun('Invalid API key · Please run /login'); it('starts at setup with the vendor chosen in Settings, and that one check serves every later request', async () => { const { deps, probe, issueAccess } = checked(async () => undefined); @@ -345,12 +351,13 @@ describe('the planning check (#164)', () => { }); it('refuses a request with the check\'s reason, before it reads the issue, and does not check again within the recheck window', async () => { - const { deps, probe, issueAccess } = checked(async () => { throw new Error('Claude could not sign in.'); }); + const { deps, probe, issueAccess } = checked(async () => { throw new Error(signInFailure); }); const refusal = await Promise.resolve(deps.describe(signal())).catch((error: unknown) => error); expect(refusal).toBeInstanceOf(GuardRefusal); - expect((refusal as Error).message).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*The check said: Claude could not sign in\\.$`)); + expect((refusal as Error).message.startsWith(`${PLANNING_UNAVAILABLE} `)).toBe(true); + expect((refusal as Error).message.endsWith(`The check said: ${signInFailure}`)).toBe(true); // Inside the recheck window, the same failure refuses at once. - await expect(deps.describe(signal())).rejects.toThrow('Claude could not sign in.'); + await expect(deps.describe(signal())).rejects.toThrow(signInFailure); expect(probe).toHaveBeenCalledTimes(1); expect(issueAccess).not.toHaveBeenCalled(); }); @@ -407,11 +414,11 @@ describe('the planning check (#164)', () => { it('checks the vendor chosen while a request waited, before it admits the request', async () => { let pass!: () => void; const { deps, service, probe } = checked(vendor => vendor === 'claude' ? new Promise(resolve => { pass = resolve; }) - : Promise.reject(new Error('Codex cannot write plans yet.'))); + : Promise.reject(new Error(CODEX_PLANNING_REFUSED))); const waiting = Promise.resolve(deps.describe(signal())); vi.spyOn(service.store, 'questionProvider').mockReturnValue('codex'); pass(); - await expect(waiting).rejects.toThrow('Codex cannot write plans yet.'); + await expect(waiting).rejects.toThrow(CODEX_PLANNING_REFUSED); expect(probe.mock.calls.map(call => call[0])).toEqual(['claude', 'codex']); }); @@ -573,9 +580,7 @@ describe('the planning check (#164)', () => { it('keeps the recheck promise when only the vendor\'s output mentions a restart', async () => { // planningOutput's text for a failed run whose output says so. - const context = { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }; - const reason = thrown(() => planningOutput({ attemptId: 'probe', context, exitCode: 1, signal: null, stderr: '', - stdout: 'planning is off until codeboost restarts' }, { attemptId: 'probe', context } as InvocationInput)); + const reason = failedRun('planning is off until codeboost restarts'); expect(reason).toContain(' Claude said: planning is off until codeboost restarts'); const check = new PlanningCheck(async () => { throw new Error(reason); }, () => 'claude'); const refusal = await check.admit(signal()).catch((error: unknown) => error); @@ -719,7 +724,7 @@ describe('the planning check (#164)', () => { }); it('refuses a start through the server with 409, recording no request and starting no agent', async () => { - const config = production(), probe = vi.fn(async () => { throw new Error('Claude could not sign in.'); }); + const config = production(), probe = vi.fn(async () => { throw new Error(signInFailure); }); const service = new ReviewService(config); service.store.setQuestionProvider('claude'); service.close(); const agent = closable(undefined, probe), issueAccess = vi.fn(); const setup = productionPlanning(config, { ...verified, issues: { issueAccess, issueText: vi.fn() }, agent: () => agent })!; @@ -734,7 +739,8 @@ describe('the planning check (#164)', () => { const started = await api('POST', '/api/plan/suggestions', { expectedRevision: view.plan.revision, snapshotId: view.snapshot.id, feedback: '', actionId: randomUUID() }); expect(started.status).toBe(409); - expect(started.body.error).toMatch(new RegExp(`^${PLANNING_UNAVAILABLE} .*Claude could not sign in\\.`)); + expect(started.body.error.startsWith(`${PLANNING_UNAVAILABLE} `)).toBe(true); + expect(started.body.error).toContain(signInFailure); const db = new DatabaseSync(config.database); try { expect((db.prepare('SELECT COUNT(*) AS n FROM requests').get() as { n: number }).n).toBe(0); } finally { db.close(); } expect(agent.invoke).not.toHaveBeenCalled(); From 4cc95fb60b6d33782fd1c63ed2806049e4fe7506 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 22:39:42 -0700 Subject: [PATCH 31/39] Test the wrapped invoke forwards after a pass; wording (#164) Review round 28: the invoke test first shows a passed check forwards the call as it is; the refusal for a changed vendor says codeboost checks it next, which holds even if the deferred check cannot start; 'records a planning request' in the docs; the browser spec's stand-in says no vendor is chosen there; one import from runner/planning.ts. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 2 +- runner/planning.ts | 4 ++-- test/browser/plans-production.spec.ts | 3 ++- test/planning-production.test.ts | 15 +++++++++------ web/planning.ts | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index eea190e3..755ec910 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -92,7 +92,7 @@ schema-constrained answer and the read-only profile (`PlanningAgent.probe`). `PlanningCheck` starts it when the server starts, before it listens, and again on the first request after the vendor changes, cancelling the check it replaces. `productionPlanning`'s `describe` waits for it, at most 30 s in all, before it -reads the issue or records anything, and the request checks again just before it +reads the issue or records a planning request, and the request checks again just before it starts, so a vendor chosen during the issue read never runs unchecked. That last check runs inside the Store's transaction, so it starts no check itself: it refuses, and the check it needs starts on the next turn. The provider checks once diff --git a/runner/planning.ts b/runner/planning.ts index 569a9b05..5d393a95 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -254,8 +254,8 @@ export class PlanningCheck { // Never throws: an exception here would end the process. A Store that cannot be read starts nothing. try { const now = this.#vendor(); if (now) this.#check(now); } catch { /* stopping, or no Store: nothing to start */ } }); - throw new GuardRefusal(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost is ` - + 'checking it now. Retry in a minute.'); + throw new GuardRefusal(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost checks ` + + 'it next. Retry in a minute.'); } #verdict(check: Check): void { if (check.passed) return; diff --git a/test/browser/plans-production.spec.ts b/test/browser/plans-production.spec.ts index 193e586f..d8502957 100644 --- a/test/browser/plans-production.spec.ts +++ b/test/browser/plans-production.spec.ts @@ -100,7 +100,8 @@ function dBackedAgent(service: ReviewService, observations: { return provider.invoke(request, signal); }, async close() { /* createPlanningProvider has no process-level owner */ }, - // The planning check (#164) passes here; planning-production and agent-planning test it. + // No vendor is chosen in this spec, so the planning check (#164) checks nothing and never calls this; + // planning-production and agent-planning test the check. async probe() { /* passes */ }, }; return agent as PlanningAgent; diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index bfb2adb6..6d21c093 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -6,7 +6,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts'; import { ISSUE_READ_ACTIVE_MS, type IssueAccess, type IssueText } from '../github/issues.ts'; import { createDemo } from '../scripts/demo.ts'; -import type { PlanningAgent } from '../runner/planning.ts'; import { ReviewService, type ReviewConfig } from '../runner/review.ts'; import { productionPlanning } from '../web/planning.ts'; import { DatabaseSync } from 'node:sqlite'; @@ -15,7 +14,7 @@ import { CODEX_PLANNING_REFUSED, PLANNING_BUDGET_MS, PLANNING_FEATURE, PLANNING_ import type { InvocationInput } from '../agents/contract.ts'; import { PLANNING_SHUTDOWN_GRACE_MS, startServer, type PlanningDeps } from '../web/server.ts'; import { GuardRefusal } from '../runner/lifecycle.ts'; -import { PLANNING_UNAVAILABLE, PlanningCheck } from '../runner/planning.ts'; +import { PLANNING_UNAVAILABLE, PlanningCheck, type PlanningAgent } from '../runner/planning.ts'; import { LeftoverLedger, PLANNING_NAMING } from '../runner/question-leftovers.ts'; import { abandonedWorkerText } from '../runner/question-agent.ts'; @@ -425,11 +424,15 @@ describe('the planning check (#164)', () => { it('refuses to invoke an agent whose check has not passed for the vendor chosen at the call', async () => { const made = checked(async () => undefined); await made.deps.describe(signal()); + const request = {} as Parameters[0], forwarded = signal(); + // Once the check passed, the call goes to the agent as it is. + await made.deps.provider.invoke(request, forwarded); + expect(made.agent.invoke).toHaveBeenCalledWith(request, forwarded); + vi.mocked(made.agent.invoke).mockClear(); vi.spyOn(made.service.store, 'questionProvider').mockReturnValue('codex'); - const request = {} as Parameters[0]; // A rejection, as `AuthorProvider.invoke` promises: E3 then settles the request as failed with this reason // (test/planning-suggestions.test.ts covers a provider whose invoke fails). - await expect(made.deps.provider.invoke(request, signal())).rejects.toThrow('is checking it now'); + await expect(made.deps.provider.invoke(request, signal())).rejects.toThrow('and codeboost checks it next'); expect(made.agent.invoke).not.toHaveBeenCalled(); }); @@ -515,7 +518,7 @@ describe('the planning check (#164)', () => { const uncaught = vi.fn(); process.on('uncaughtException', uncaught); try { - expect(() => check.assertPassed()).toThrow('is checking it now'); + expect(() => check.assertPassed()).toThrow('and codeboost checks it next'); fail = true; await new Promise(resolve => setImmediate(resolve)); expect(uncaught).not.toHaveBeenCalled(); @@ -713,7 +716,7 @@ describe('the planning check (#164)', () => { const made = checked(async () => undefined, null, undefined, undefined, issueText); service = made.service; const described = await made.deps.describe(signal()); - expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost is checking it now.`); + expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} the planning agent chosen in Settings changed, and codeboost checks it next.`); // validate runs inside the Store's transaction, so the new vendor's check starts only on the next turn. expect(made.probe).not.toHaveBeenCalled(); await new Promise(resolve => setImmediate(resolve)); diff --git a/web/planning.ts b/web/planning.ts index 0e87c0bb..f44f0cc0 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -12,7 +12,7 @@ export { ISSUE_READ_TIMEOUT_MS } from '../github/issues.ts'; * comments, or every comment under explicit author-bound trust), bounded like an execute prompt; approved lessons stay * empty until lessons exist (L1 to L4). The planning check (#164) starts with the server for the vendor chosen in * Settings (or, with none chosen then, with the first request after one is), and must pass before a request reads the - * issue or records anything; a failed check refuses the request with its reason. + * issue or records a planning request; a failed check refuses the request with its reason. */ export function productionPlanning(config: ReviewConfig, options: { /** The single-runner lock's check that the database path still names the locked file (runner/recovery.ts). */ From 885ccb797c2d4b5cf5a233e4f60d78a8ebe78185 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 22:51:10 -0700 Subject: [PATCH 32/39] Pin the check's default wait, timeout and close bound (#164) Review round 29: one fake-clock test through productionPlanning, with nothing overridden, shows a request waits 30 s, a check fails after the planning budget and a minute, and close waits 20 s; it fails if any of them changes. The refusal comment notes the Store's 4,000-character record. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 3 ++- test/planning-production.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/runner/planning.ts b/runner/planning.ts index 5d393a95..42158cfd 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -261,7 +261,8 @@ export class PlanningCheck { if (check.passed) return; // After close no check starts, so no recheck can be promised. if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); - // The reason comes last and as it is: some end with a command a person may copy. One that holds until a restart + // The reason comes last and as it is: some end with a command a person may copy. (The Store records a refusal cut + // to 4,000 characters, so a resend of the same action may replay it shortened.) One that holds until a restart // gets no promise of a recheck, which could not help. if (check.failure && UNTIL_RESTART.test(ownText(messageOf(check.failure.error)))) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart ` diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 6d21c093..dbb62896 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -541,6 +541,38 @@ describe('the planning check (#164)', () => { } finally { process.off('warning', warned); } }); + it('waits 30 s for a check, fails one after the planning budget and a minute, and closes within 20 s', async () => { + // productionPlanning's own defaults, as the docs give them: nothing here overrides them. The clock is faked too, so each wait is exact. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + // The probe never settles and ignores its abort, as a job stuck in the worker would. + const { deps } = checked(() => new Promise(() => undefined)); + let outcome: unknown; + void Promise.resolve(deps.describe(signal())).catch((error: unknown) => { outcome = error; }); + await vi.advanceTimersByTimeAsync(30_000 - 1); + expect(outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(String(outcome)).toContain('still checking'); + // The check itself fails once the planning budget and a minute have passed since it started. + // A request waiting from 15 s before then gets that failure, not the end of its own wait. + await vi.advanceTimersByTimeAsync(11 * 60_000 - 30_000 - 15_000); + outcome = undefined; + void Promise.resolve(deps.describe(signal())).catch((error: unknown) => { outcome = error; }); + await vi.advanceTimersByTimeAsync(15_000 - 1); + expect(outcome).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(String(outcome)).toContain('did not finish within 11 minutes'); + // A check still running at close is waited for 20 s. + void Promise.resolve(deps.describe(signal())).catch(() => undefined); + let closed = false; + void deps.close!().then(() => { closed = true; }); + await vi.advanceTimersByTimeAsync(20_000 - 1); + expect(closed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(closed).toBe(true); + } finally { vi.useRealTimers(); } + }); + it('lets a request go on when Settings clear the agent while it waits, even once the wait has passed', async () => { let vendor: 'claude' | null = 'claude'; const check = new PlanningCheck((_vendor, probeSignal) => new Promise(resolve => From c5711fc096b35a9d0165b57aa71d3a53ab4c69fc Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:00:29 -0700 Subject: [PATCH 33/39] Admit nothing after close, even under a passed check (#164) Review round 30: #check and assertPassed test for close before the covering check, so a request, its start and the wrapped invoke are refused as stopping once planning closed; a test shows it. The defaults test's close step names what close waits for. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 5 +++-- test/planning-production.test.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 42158cfd..16d4285a 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -159,9 +159,10 @@ export class PlanningCheck { } // The check that covers `vendor`, started now if none does. #check(vendor: Provider) { + // First: after close nothing is admitted, not even under a check that passed. + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); const covering = this.#covering(vendor); if (covering) return covering; - if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); // A check for another vendor no longer admits anything: stop it, so replaced checks do not pile up in the worker. if (this.#current && !this.#current.passed && !this.#current.failure) { this.#current.abort.abort(new Error('The planning agent chosen in Settings changed.')); @@ -246,9 +247,9 @@ export class PlanningCheck { assertPassed(): void { const vendor = this.#vendor(); if (!vendor) return; + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); const covering = this.#covering(vendor); if (covering) return this.#verdict(covering); - if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); // Checks the vendor chosen when this runs, which Settings may have changed since the refusal. setImmediate(() => { // Never throws: an exception here would end the process. A Store that cannot be read starts nothing. diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index dbb62896..28857465 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -541,7 +541,7 @@ describe('the planning check (#164)', () => { } finally { process.off('warning', warned); } }); - it('waits 30 s for a check, fails one after the planning budget and a minute, and closes within 20 s', async () => { + it('waits 30 s for a check, fails one after the planning budget and a minute, and waits 20 s at close', async () => { // productionPlanning's own defaults, as the docs give them: nothing here overrides them. The clock is faked too, so each wait is exact. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); try { @@ -562,8 +562,7 @@ describe('the planning check (#164)', () => { expect(outcome).toBeUndefined(); await vi.advanceTimersByTimeAsync(1); expect(String(outcome)).toContain('did not finish within 11 minutes'); - // A check still running at close is waited for 20 s. - void Promise.resolve(deps.describe(signal())).catch(() => undefined); + // Close waits 20 s for the timed-out check's probe, which never stops. let closed = false; void deps.close!().then(() => { closed = true; }); await vi.advanceTimersByTimeAsync(20_000 - 1); @@ -701,6 +700,16 @@ describe('the planning check (#164)', () => { expect(probe).toHaveBeenCalledTimes(1); }); + it('admits nothing after close, even under a check that passed', async () => { + const made = checked(async () => undefined); + await made.deps.describe(signal()); + await made.deps.close!(); + const stopping = `${PLANNING_UNAVAILABLE} planning is stopping.`; + await expect(Promise.resolve(made.deps.describe(signal()))).rejects.toThrow(stopping); + await expect(made.deps.provider.invoke({} as Parameters[0], signal())).rejects.toThrow(stopping); + expect(made.agent.invoke).not.toHaveBeenCalled(); + }); + it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; // Claude's check passes at 3 s, after the vendor changed; Codex's never ends. From 683c44d9c7ee80fbe2ff53609810bb4381122116 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:09:08 -0700 Subject: [PATCH 34/39] Admit nothing after close with no agent chosen either (#164) Review round 31: admit and assertPassed test for close before they read the vendor; the after-close test covers no agent chosen too. The PR body records the live probe run on the head with #163's budget. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 4 +++- test/planning-production.test.ts | 23 +++++++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 16d4285a..41292998 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -212,6 +212,8 @@ export class PlanningCheck { for (;;) { // A request that stopped waiting starts, replaces or cancels no check. signal.throwIfAborted(); + // After close nothing is admitted, with or without a vendor. + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); const vendor = this.#vendor(); if (!vendor) return; const check = this.#check(vendor); @@ -245,9 +247,9 @@ export class PlanningCheck { * no check there (that runs inside the Store's transaction): one it needs is started on the next turn. */ assertPassed(): void { + if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); const vendor = this.#vendor(); if (!vendor) return; - if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); const covering = this.#covering(vendor); if (covering) return this.#verdict(covering); // Checks the vendor chosen when this runs, which Settings may have changed since the refusal. diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 28857465..21a16c0b 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -333,7 +333,8 @@ describe('the planning check (#164)', () => { const signal = () => new AbortController().signal; const thrown = (run: () => unknown) => { try { run(); } catch (error) { return (error as Error).message; } throw new Error('It did not throw.'); }; // A real sign-in failure, as planningOutput words it for a failed run. Tests that only show a reason passed through - // whole use any text ('Docker is not running.', 'No worker could start.'). + // whole use any text ('Docker is not running.', 'No worker could start.'). Codex's refusal is CODEX_PLANNING_REFUSED, + // which planningCredential throws as it is. const runContext = { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }; const failedRun = (stdout: string) => thrown(() => planningOutput({ attemptId: 'probe', context: runContext, exitCode: 1, signal: null, stderr: '', stdout }, { attemptId: 'probe', context: runContext } as InvocationInput)); @@ -700,15 +701,17 @@ describe('the planning check (#164)', () => { expect(probe).toHaveBeenCalledTimes(1); }); - it('admits nothing after close, even under a check that passed', async () => { - const made = checked(async () => undefined); - await made.deps.describe(signal()); - await made.deps.close!(); - const stopping = `${PLANNING_UNAVAILABLE} planning is stopping.`; - await expect(Promise.resolve(made.deps.describe(signal()))).rejects.toThrow(stopping); - await expect(made.deps.provider.invoke({} as Parameters[0], signal())).rejects.toThrow(stopping); - expect(made.agent.invoke).not.toHaveBeenCalled(); - }); + it.each([['under a check that passed', 'claude'], ['with no agent chosen', null]] as const)( + 'admits nothing after close, %s', async (_label, vendor) => { + const made = checked(async () => undefined, vendor); + await made.deps.describe(signal()); + await made.deps.close!(); + const stopping = `${PLANNING_UNAVAILABLE} planning is stopping.`; + await expect(Promise.resolve(made.deps.describe(signal()))).rejects.toThrow(stopping); + await expect(made.deps.provider.invoke({} as Parameters[0], signal())).rejects.toThrow(stopping); + expect(made.agent.invoke).not.toHaveBeenCalled(); + expect(made.issueAccess).toHaveBeenCalledTimes(1); + }); it('bounds a request\'s whole wait, across a vendor change, by one deadline', async () => { let vendor: 'claude' | 'codex' = 'claude'; From 162f43c8495b90d02a7459cce843de1cfcba6c15 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:20:14 -0700 Subject: [PATCH 35/39] A request waiting at close is refused as stopping; close tests (#164) Review round 32: #verdict tests for close before a pass, so a check that passes after close admits nothing; tests cover a check deferred before close, start after close, and a request waiting at close whose check then passes. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 4 ++-- test/planning-production.test.ts | 27 +++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index 41292998..e1304464 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -261,9 +261,9 @@ export class PlanningCheck { + 'it next. Retry in a minute.'); } #verdict(check: Check): void { - if (check.passed) return; - // After close no check starts, so no recheck can be promised. + // After close nothing is admitted, even by a check that passed meanwhile, and no recheck can be promised. if (this.#abort.signal.aborted) throw new GuardRefusal(`${PLANNING_UNAVAILABLE} planning is stopping.`); + if (check.passed) return; // The reason comes last and as it is: some end with a command a person may copy. (The Store records a refusal cut // to 4,000 characters, so a resend of the same action may replay it shortened.) One that holds until a restart // gets no promise of a recheck, which could not help. diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 21a16c0b..ee3bce2a 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -689,16 +689,35 @@ describe('the planning check (#164)', () => { expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} codeboost's check of the planning agent failed, and only a restart of codeboost can change that; fix the cause first if the reason names one. The check said: ${reason}`); }); - it('starts no check after close', async () => { + it('starts no check after close, not even one deferred before it', async () => { let vendor: 'claude' | 'codex' = 'claude'; - const probe = vi.fn(async () => undefined), check = new PlanningCheck(probe, () => vendor); + const probe = vi.fn(async (_probed: string) => undefined), check = new PlanningCheck(probe, () => vendor); check.start(); - await check.close(); + await new Promise(resolve => setImmediate(resolve)); + // A changed vendor defers its check to the next turn; close comes first. vendor = 'codex'; + expect(() => check.assertPassed()).toThrow('and codeboost checks it next'); + await check.close(); + await new Promise(resolve => setImmediate(resolve)); + expect(() => check.start()).toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); await expect(check.admit(signal())).rejects.toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); expect(() => check.assertPassed()).toThrow(`${PLANNING_UNAVAILABLE} planning is stopping.`); await new Promise(resolve => setImmediate(resolve)); - expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls.map(call => call[0])).toEqual(['claude']); + }); + + it('tells a request waiting at close that planning is stopping, even if the check then passes', async () => { + let pass!: () => void; + // The probe answers after close cancelled it, as an answer already on its way would. + const check = new PlanningCheck(() => new Promise(resolve => { pass = resolve; }), () => 'claude'); + const waiting = check.admit(signal()).catch((error: unknown) => error); + await new Promise(resolve => setImmediate(resolve)); + const closed = check.close(2_000); + pass(); + await closed; + const refusal = await waiting; + expect(refusal).toBeInstanceOf(GuardRefusal); + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} planning is stopping.`); }); it.each([['under a check that passed', 'claude'], ['with no agent chosen', null]] as const)( From 42af3bfeaf3771c99a0c73e62f857ccc65cd8a0e Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:28:51 -0700 Subject: [PATCH 36/39] Close wakes a waiting request at once; docs and a still-running test (#164) Review round 33: a request waiting at close is refused as stopping at once, even when its check's probe never stops; start's doc says it throws after close; web/planning.ts says the check gates a request when a vendor is chosen; a test covers validate meeting a check still running; a comment notes what a worker that never ends a job leaves for close. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 18 +++++++++++++----- test/planning-production.test.ts | 29 +++++++++++++++++++++++++++++ web/planning.ts | 4 ++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/runner/planning.ts b/runner/planning.ts index e1304464..9796e1d7 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -146,7 +146,9 @@ export class PlanningCheck { this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; this.#waitMs = options.waitMs ?? PLANNING_ADMIT_WAIT_MS; this.#timeoutMs = options.timeoutMs ?? PLANNING_CHECK_TIMEOUT_MS; } - /** Begin the check for the chosen vendor, if any, without waiting for it. */ + /** + * Begin the check for the chosen vendor, if any, without waiting for it. After close it throws "planning is stopping". + */ start(): void { const vendor = this.#vendor(); if (vendor) this.#check(vendor); @@ -188,6 +190,7 @@ export class PlanningCheck { }).finally(() => { clearTimeout(timer); this.#abort.signal.removeEventListener('abort', stop); }); check.done = done; // Close waits for the probe itself too: a check that timed out has settled, but its job may still be in the worker. + // (A worker that never ends a job adds one such probe each recheck window; close's bound still holds.) const settled = probe.then(() => undefined, () => undefined); for (const running of [done, settled]) { this.#running.add(running); @@ -219,17 +222,22 @@ export class PlanningCheck { const check = this.#check(vendor); // A settled check gives its verdict at once. if (check.passed || check.failure) return this.#verdict(check); - let release!: () => void, moved!: () => void, timer: ReturnType | undefined; - const stopped = new Promise<'timeout' | 'replaced'>((resolve, reject) => { + let release!: () => void, moved!: () => void, closing!: () => void, timer: ReturnType | undefined; + const stopped = new Promise<'timeout' | 'replaced' | 'closing'>((resolve, reject) => { release = () => reject(signal.reason); signal.addEventListener('abort', release, { once: true }); moved = () => resolve('replaced'); this.#events.addEventListener('replaced', moved, { once: true }); + // Close wakes the wait at once, even when its check's probe does not stop: the loop then refuses it as + // stopping. + closing = () => resolve('closing'); this.#abort.signal.addEventListener('abort', closing, { once: true }); timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - performance.now())); }); - let outcome: void | 'timeout' | 'replaced'; + let outcome: void | 'timeout' | 'replaced' | 'closing'; try { outcome = await Promise.race([check.done, stopped]); } finally { - signal.removeEventListener('abort', release); this.#events.removeEventListener('replaced', moved); clearTimeout(timer); + signal.removeEventListener('abort', release); this.#events.removeEventListener('replaced', moved); + this.#abort.signal.removeEventListener('abort', closing); clearTimeout(timer); } + if (outcome === 'closing') continue; // A vendor changed meanwhile is checked (or, if none is chosen now, the request goes on) within what is left of // the wait, which has passed: only a check that has already settled gives its verdict. if (outcome === 'timeout' && this.#vendor() === vendor) throw new GuardRefusal(STILL_CHECKING); diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index ee3bce2a..1cdf4989 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -706,6 +706,17 @@ describe('the planning check (#164)', () => { expect(probe.mock.calls.map(call => call[0])).toEqual(['claude']); }); + it('wakes a request waiting at close at once, though its check\'s probe never stops', async () => { + const check = new PlanningCheck(() => new Promise(() => undefined), () => 'claude'); + const started = performance.now(); + const waiting = check.admit(signal()).catch((error: unknown) => error); + await new Promise(resolve => setImmediate(resolve)); + void check.close(10); + const refusal = await waiting; + expect((refusal as Error).message).toBe(`${PLANNING_UNAVAILABLE} planning is stopping.`); + expect(performance.now() - started).toBeLessThan(5_000); + }); + it('tells a request waiting at close that planning is stopping, even if the check then passes', async () => { let pass!: () => void; // The probe answers after close cancelled it, as an answer already on its way would. @@ -789,6 +800,24 @@ describe('the planning check (#164)', () => { expect(() => described.validate()).not.toThrow(); }); + it('refuses at the start while the check for a vendor chosen during the issue read still runs', async () => { + let service!: ReviewService, pass!: () => void; + const issueText = vi.fn(async (number: number) => { + service.store.setQuestionProvider('claude'); + return text(number); + }); + const made = checked(() => new Promise(resolve => { pass = resolve; }), null, undefined, undefined, issueText); + service = made.service; + const described = await made.deps.describe(signal()); + expect(() => described.validate()).toThrow('and codeboost checks it next'); + await new Promise(resolve => setImmediate(resolve)); + expect(made.probe.mock.calls.map(call => call[0])).toEqual(['claude']); + expect(() => described.validate()).toThrow(`${PLANNING_UNAVAILABLE} codeboost is still checking the planning agent.`); + pass(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(() => described.validate()).not.toThrow(); + }); + it('refuses a start through the server with 409, recording no request and starting no agent', async () => { const config = production(), probe = vi.fn(async () => { throw new Error(signInFailure); }); const service = new ReviewService(config); service.store.setQuestionProvider('claude'); service.close(); diff --git a/web/planning.ts b/web/planning.ts index f44f0cc0..6edeb929 100644 --- a/web/planning.ts +++ b/web/planning.ts @@ -11,8 +11,8 @@ export { ISSUE_READ_TIMEOUT_MS } from '../github/issues.ts'; * planning only reads code, as Ask does. The issue text is read from GitHub for each request (current collaborators' * comments, or every comment under explicit author-bound trust), bounded like an execute prompt; approved lessons stay * empty until lessons exist (L1 to L4). The planning check (#164) starts with the server for the vendor chosen in - * Settings (or, with none chosen then, with the first request after one is), and must pass before a request reads the - * issue or records a planning request; a failed check refuses the request with its reason. + * Settings (or, with none chosen then, with the first request after one is), and, when a vendor is chosen, must pass + * before a request reads the issue or records a planning request; a failed check refuses the request with its reason. */ export function productionPlanning(config: ReviewConfig, options: { /** The single-runner lock's check that the database path still names the locked file (runner/recovery.ts). */ From 6ab8f8b93cbe9bd51b371203b4260863877de3a3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:29:02 -0700 Subject: [PATCH 37/39] Rewrap start's doc (#164) Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runner/planning.ts b/runner/planning.ts index 9796e1d7..cd09f64c 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -147,7 +147,8 @@ export class PlanningCheck { this.#timeoutMs = options.timeoutMs ?? PLANNING_CHECK_TIMEOUT_MS; } /** - * Begin the check for the chosen vendor, if any, without waiting for it. After close it throws "planning is stopping". + * Begin the check for the chosen vendor, if any, without waiting for it. After close it throws "planning is + * stopping". */ start(): void { const vendor = this.#vendor(); From 0472de639cb4165ed41eca298417d247058c15d8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:36:20 -0700 Subject: [PATCH 38/39] Comment: the AbortSignal also holds one listener per waiting request (#164) Review round 34. Co-Authored-By: Claude Opus 5.5 --- runner/planning.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner/planning.ts b/runner/planning.ts index cd09f64c..26b177d2 100644 --- a/runner/planning.ts +++ b/runner/planning.ts @@ -140,7 +140,7 @@ export class PlanningCheck { constructor(probe: (vendor: Provider, signal: AbortSignal) => Promise, vendor: () => Provider | null, options: { recheckMs?: number; waitMs?: number; timeoutMs?: number } = {}) { // One listener per waiting request, each removed when its wait ends: many at once are no leak. (Node 26 warns of - // none on an AbortSignal, which holds one per running check.) + // none on an AbortSignal, which holds one per running check and one per waiting request.) setMaxListeners(0, this.#events); this.#probe = probe; this.#vendor = vendor; this.#recheckMs = options.recheckMs ?? PLANNING_RECHECK_MS; this.#waitMs = options.waitMs ?? PLANNING_ADMIT_WAIT_MS; From 688858702a2b346109ba3e1ac9e2da2444b7078e Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 20 Oct 2026 23:45:53 -0700 Subject: [PATCH 39/39] Rewrap a doc paragraph; drop a repeated issue tag from a test title (#164) Review round 35. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/planning-acceptance.md | 86 +++++++++++----------- test/planning-production.test.ts | 2 +- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/docs/implementation/planning-acceptance.md b/docs/implementation/planning-acceptance.md index 755ec910..97763d06 100644 --- a/docs/implementation/planning-acceptance.md +++ b/docs/implementation/planning-acceptance.md @@ -89,49 +89,51 @@ egress, a mounted schema), on the review's code at the snapshot's head, with a real request's budget, since a process's first job may build the agent image. It asks for the schema's own answer, `{"ok": true}`, so it covers sign-in, the schema-constrained answer and the read-only profile (`PlanningAgent.probe`). -`PlanningCheck` starts it when the server starts, before it listens, and again on -the first request after the vendor changes, cancelling the check it replaces. +`PlanningCheck` starts it when the server starts, before it listens, and again +on the first request after the vendor changes, cancelling the check it replaces. `productionPlanning`'s `describe` waits for it, at most 30 s in all, before it -reads the issue or records a planning request, and the request checks again just before it -starts, so a vendor chosen during the issue read never runs unchecked. That last -check runs inside the Store's transaction, so it starts no check itself: it -refuses, and the check it needs starts on the next turn. The provider checks once -more when it is invoked, as defence in depth (E3 invokes it in a microtask right -after the recorded action); a refusal there settles the request as failed. A -failed check refuses the request with a `GuardRefusal` that starts "Planning is -unavailable:" and ends with the reason exactly as given (some end with a command -to copy), and a check still running after the wait is refused with the same -prefix; the server records the refusal and starts no agent. Requests within a -minute of a failure are refused with it; the next request after that checks again. -With no vendor chosen nothing is checked, and the request fails later, as before, -with `NO_PLANNING_AGENT`. Once planning requests have settled, or after the -server's 20 s grace for them, shutdown cancels a running check and waits up to -20 s more for it before closing the worker. A check that runs before the runner's setup has set the runner-owned -repository fails, on a task with runner commits, with "which is not configured"; -the recheck a minute later passes once the runner's setup has finished, and -without a runner block it fails each time, as every planning request would. A -check that has not settled within a real request's budget and a minute fails, so -the recheck applies to it. A reason that holds until codeboost restarts (an -abandoned worker, cleanup codeboost could not confirm, a database with other hard -links, a missing token) gets no promise of a recheck, and Codex is told to choose -another agent. A recheck joins the worker's one startup recovery, so a recovery -that never ends fails each recheck until codeboost restarts. A passed check is -kept until a check for another vendor replaces it: a worker abandoned later, -cleanup that did not settle (the check's own run included, when its answer still -came back), or a token revoked later, shows as a failed request. A refusal -recorded when a request stopped waiting, or when the Store could not be read -during the wait, is replayed if the browser resends the same action; keeping a -Store error out of the record needs `web/server.ts`. Showing the check's state in -the review view before a request, and the other limits that need `web/server.ts`, -are #198. A check deferred from the Store's transaction can start during shutdown, -before planning closes; close then waits for it within its bound. If startup fails -after planning's setup, the CLI exits, and the next start's planning recovery -removes what the check left. Tests: `test/planning-production.test.ts` ("the -planning check (#164)", with a check that passes, one that fails and one that does -not finish), `test/planning-worker.test.ts` (the check in planning's worker, -including a wrong answer) and `test/agent-planning.test.ts` (against the vendor, -real Docker: an invalid sign-in fails, and a live probe in the Live probes -workflow passes with a real one). +reads the issue or records a planning request, and the request checks again just +before it starts, so a vendor chosen during the issue read never runs unchecked. +That last check runs inside the Store's transaction, so it starts no check +itself: it refuses, and the check it needs starts on the next turn. The provider +checks once more when it is invoked, as defence in depth (E3 invokes it in a +microtask right after the recorded action); a refusal there settles the request +as failed. A failed check refuses the request with a `GuardRefusal` that starts +"Planning is unavailable:" and ends with the reason exactly as given (some end +with a command to copy), and a check still running after the wait is refused +with the same prefix; the server records the refusal and starts no agent. +Requests within a minute of a failure are refused with it; the next request +after that checks again. With no vendor chosen nothing is checked, and the +request fails later, as before, with `NO_PLANNING_AGENT`. Once planning requests +have settled, or after the server's 20 s grace for them, shutdown cancels a +running check and waits up to 20 s more for it before closing the worker. A +check that runs before the runner's setup has set the runner-owned repository +fails, on a task with runner commits, with "which is not configured"; the +recheck a minute later passes once the runner's setup has finished, and without +a runner block it fails each time, as every planning request would. A check that +has not settled within a real request's budget and a minute fails, so the +recheck applies to it. A reason that holds until codeboost restarts (an +abandoned worker, cleanup codeboost could not confirm, a database with other +hard links, a missing token) gets no promise of a recheck, and Codex is told to +choose another agent. A recheck joins the worker's one startup recovery, so a +recovery that never ends fails each recheck until codeboost restarts. A passed +check is kept until a check for another vendor replaces it: a worker abandoned +later, cleanup that did not settle (the check's own run included, when its +answer still came back), or a token revoked later, shows as a failed request. A +refusal recorded when a request stopped waiting, or when the Store could not be +read during the wait, is replayed if the browser resends the same action; +keeping a Store error out of the record needs `web/server.ts`. Showing the +check's state in the review view before a request, and the other limits that +need `web/server.ts`, are #198. A check deferred from the Store's transaction +can start during shutdown, before planning closes; close then waits for it +within its bound. If startup fails after planning's setup, the CLI exits, and +the next start's planning recovery removes what the check left. Tests: +`test/planning-production.test.ts` ("the planning check (#164)", with a check +that passes, one that fails and one that does not finish), +`test/planning-worker.test.ts` (the check in planning's worker, including a +wrong answer) and `test/agent-planning.test.ts` (against the vendor, real +Docker: an invalid sign-in fails, and a live probe in the Live probes workflow +passes with a real one). The Store guarantees revision/snapshot binding and pending-only settlement across processes, and E3 requires the caller's snapshot identity before admission. G4 now diff --git a/test/planning-production.test.ts b/test/planning-production.test.ts index 1cdf4989..b165cdb5 100644 --- a/test/planning-production.test.ts +++ b/test/planning-production.test.ts @@ -779,7 +779,7 @@ describe('the planning check (#164)', () => { await expect(deps.describe(signal())).resolves.toMatchObject({ issue: { title: 'Retries ignore the cap' } }); }); - it('refuses at the start a vendor chosen while the issue was read, and checks it (#164)', async () => { + it('refuses at the start a vendor chosen while the issue was read, and checks it', async () => { let service!: ReviewService; // Settings take Claude while the issue is read, after a request with none chosen was admitted unchecked. const issueText = vi.fn(async (number: number) => {