From 2fb4bc32a21271b1ff210d12f2794c94b1b656e4 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Sat, 5 Sep 2026 10:24:52 +0200 Subject: [PATCH] fix: preserve eval evidence on graceful cancellation Drain signal-driven shutdown and credential cleanup without losing observed failures or inventing interrupted product results. Keep operator and budget stops nonqualifying, and document the frozen-candidate release sequence. Add deterministic real-signal and reserve-scheduling regressions. Validation: bun run check (790 pass, 1 skip), Linux focused suite (175 pass), replay (13/13), and commit preflight. Failure usage snapshots remain partial observations, not complete billing totals. --- CHANGELOG.md | 9 +- docs/development.md | 11 +- docs/release-qualification.md | 35 +- evals/README.md | 75 +- evals/analysis.ts | 13 + evals/campaign-stop.ts | 34 + evals/failure-origin.ts | 4 +- evals/harness.ts | 645 ++++++++++---- evals/report.ts | 32 +- evals/run.ts | 103 ++- scripts/triage-report.ts | 39 +- tests/eval-cancellation.test.ts | 541 ++++++++++++ tests/eval-failure-origin.test.ts | 23 + tests/eval-reporting.test.ts | 830 +++++++++++++++++- tests/eval-reserve-cancellation.test.ts | 292 ++++++ tests/fixtures/eval-cancellation-child.ts | 465 ++++++++++ .../eval-reserve-cancellation-child.ts | 232 +++++ tests/release-qualification.test.ts | 81 ++ tests/triage-report.test.ts | 47 + 19 files changed, 3223 insertions(+), 288 deletions(-) create mode 100644 evals/campaign-stop.ts create mode 100644 tests/eval-cancellation.test.ts create mode 100644 tests/eval-reserve-cancellation.test.ts create mode 100644 tests/fixtures/eval-cancellation-child.ts create mode 100644 tests/fixtures/eval-reserve-cancellation-child.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa77dc2..cecc8ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ One short entry per release, written for users deciding whether to upgrade. ## [8.2.1] - 2026-09-05 -Validation and publication claims now reject three misleading success paths. +Validation and publication claims reject misleading success paths, and eval +campaigns support graceful operator stops. - Named JUnit evidence uses strict XML parsing. Truncated or malformed documents, comments, CDATA, and log markup cannot invent passing cases; DTDs are refused. @@ -20,6 +21,12 @@ Validation and publication claims now reject three misleading success paths. and the Biome schema matches the pinned tool version. - The development lockfile updates the SDK's transitive `toml` dependency to `4.3.0`, clearing high-severity parser advisories without changing the host pin. +- SIGINT/SIGTERM stop eval scheduling, preserve completed evidence and drain host + and credential cleanup. Interrupted and over-budget campaigns remain + nonqualifying; observed provider failures are not replaced with cancellations. + Reports and triage distinguish campaign stops from product failures. +- Release guidance now freezes dependencies, packed contents and evaluator inputs + before paid qualification. Strict exact-artifact checks remain unchanged. - **Session v5 schema:** unchanged. Public tools, commands, guides, and agents retain their existing names and inputs. diff --git a/docs/development.md b/docs/development.md index 9d135819..91acc480 100644 --- a/docs/development.md +++ b/docs/development.md @@ -144,12 +144,11 @@ deterministic hook and lifecycle gates. ## Release -A release needs a complete V2 campaign from at least two providers and a canary of -the exact packed artifact. Pass `--campaign-dir` and `--canary` to -`bun run qualify`; it seals the report, attempts, transcripts, artifact, canary, -expected provenance, decision, and grader source into one immutable bundle. Commit -the bundle and canary before tagging. [Release qualification](release-qualification.md) -publishes the thresholds and commands. +Follow the [frozen-candidate sequence](release-qualification.md#running-it): finish +fixes and dependency updates, pass deterministic checks, then approve paid evals. +`bun run qualify -- --campaign-dir --canary ` seals the complete +two-provider campaign, exact-artifact canary and grader evidence. Commit that bundle +before tagging; never substitute interrupted results for qualification. Release tags use `v`. Blocking release checks include the normal repository gate, package smoke, packed live OpenCode smoke, package diff --git a/docs/release-qualification.md b/docs/release-qualification.md index ad2a7657..7add87dd 100644 --- a/docs/release-qualification.md +++ b/docs/release-qualification.md @@ -1,8 +1,6 @@ # Release qualification and cadence -Two policies live here: the numbers a release has to clear, and how often releases -happen. Both exist because the previous answer to each was a judgment call made -once, by one person, from one model's output. +This page owns release thresholds, candidate freezing, and publication order. ## The qualification bar @@ -53,16 +51,12 @@ Silent passes stay ungated. Three same-change baselines moved from 20/22 to 19/2 22/22, so the level did not track reviewer value. `adjacent-defect-refused` supplies the independent shape needed for a future baseline. -Token and cost totals are provider-shaped. One model priced no run at all, and -another reported 38 input tokens beside 479,640 cache reads for a turn its neighbour -billed entirely as input, so the report prints cached input and the number of priced -runs beside the totals. +Usage is provider-shaped and may be partial after failure or cancellation, not a +billing total. See [eval reporting limits](../evals/README.md#stopping-a-campaign). ## Cadence -Flow's audience cannot absorb a hard cutover — there is no migration layer, and an -active session must be finished or closed before a version change in either -direction. The cadence follows from that: +Finish or close active sessions before changing Flow versions in either direction. - **Freeze on the public surface** while the guarantees are being measured: tools, commands, guides, agents, and the Session v5 shape. Additive optional fields are @@ -80,8 +74,20 @@ direction. The cadence follows from that: ## Running it +Finish code, dependency, version and changelog changes first. Pass frozen install, +`bun run check`, `bun run replay`, audit, live smoke and CI before paid qualification. +Freeze packed contents and evaluator inputs, then run the full two-provider matrix +on the canonical Linux host. Run a fresh canary against its exact `artifact.tgz`, +seal/regrade the bundle, and commit only evidence without changing measured inputs. +Recheck final main CI and exact artifact identity before tagging `v`. + +For 8.2.1, strict exact-artifact qualification remains in force. A dev-only manifest +change still changes the packed artifact; evidence reuse needs a separate policy +decision. Stop for fixes rather than silently restarting paid runs. Operator or +budget-stopped campaigns cannot qualify, even if retained scores meet the target. + ```bash -bun run eval -- --release --model --model +bun run eval -- --release --model openai/gpt-5.6-sol --model xai/grok-4.6 bun run eval:canary -- prepare --report /report.json --out # Run the prepared fixture, then record its session and transcript. bun run eval:canary -- record @@ -89,11 +95,8 @@ bun run qualify -- --campaign-dir \ --canary evals/canary/.json ``` -Only the full matrix qualifies a release. The cheaper tiers — a free replay of -recorded decisions, a one-model smoke run — answer questions during work and are -described with their prices in -[../evals/README.md](../evals/README.md#three-tiers-three-prices). -`bun run triage` says which runs in a report are worth reading. +Use the [cheaper tiers](../evals/README.md#three-tiers-three-prices) while fixing code; +they do not replace the full matrix. `bun run triage` identifies runs worth reading. `bun run benchmark -- --model --repeat 3 --seed ` compares Flow with ordinary OpenCode on hidden-graded tasks. It is not a qualification input. diff --git a/evals/README.md b/evals/README.md index 9de3e1cb..0a5fb67d 100644 --- a/evals/README.md +++ b/evals/README.md @@ -52,31 +52,52 @@ The runner preflights the reviewer model, writes both values through native plugin tuple configuration, and records the same selection in provenance. Release sampling rejects reviewer overrides. -Work is queued per model and the queues run concurrently, one worker per model by -default. Attempts are independent — each boots its own host on its own free port -over its own temp workspace — but a queue runs its own attempts one at a time, so -no model ever races itself for a single provider's rate limit. Release mode has -76 primary targets and one predeclared environment reserve per provider and case, -for a bounded maximum of 92 attempts. Only a retained retryable host or provider -failure activates its same-stratum reserve; product failures never do. Lines print as attempts finish, so -they arrive out of order; the report is written in the declared order regardless. -`--concurrency 1` restores the sequential run, which is easier to read when you -are debugging a single failure, and four workers is the ceiling however many -models you name — each attempt is a whole host compiling a real project, and past -that the machine's own contention starts being credited back to the deadline as if -it were machine sleep. +Ordinary runs use one sequential queue per model, with up to four queues in flight. +Release mode is strictly sequential (`--concurrency 1`): 76 primary targets and one +environment reserve per provider/case, at most 92 attempts. Only retained retryable +host/provider failures activate reserves, never product failures. Results are +persisted in declared order even when ordinary queues finish out of order. Each run packs the working tree, boots a throwaway OpenCode host over a fresh git fixture, drives the real slash commands, then reads `.flow/session.json` and `.flow/history/`. Reports land in `evals/results/` (git-ignored). -Every session the run touched is read, including the subtask sessions a reviewer -runs in, and their transcripts are merged in message-creation order. Reading only -the sessions the harness itself created left the entire independent review -invisible: no recorded report contained a single `flow_feature_complete` call, the -check for submissions the runtime rejected could never fire, and the reviewer's -tokens were not counted in any total. Token and cost figures from before this are -therefore lower than the same run would report now. +Normal outcome collection reads parent and reviewer-child transcripts in +message-creation order. Failure/cancellation can retain less, as described below. + +### Stopping a campaign + +On POSIX hosts, use Ctrl+C (`SIGINT`) or `SIGTERM` on the evaluator process. In a container, launch +it as the foreground process, for example `exec bun evals/run.ts --release ...`, +so signals reach it; allow shutdown grace (for example `docker stop --timeout 180 +`), and wait for exit before removing the container or scratch files. + +An accepted signal stops new probes, steps, primary jobs and reserves. Active host +work is aborted, the process tree is terminated, refreshed credentials are synced +back, then scratch is removed. Cleanup failures propagate and preserve recoverable +scratch rather than claiming successful finalization. Never share that scratch: +it can contain credentials. + +After campaign storage is initialized, reliable cleanup/persistence produces a V2 +`stopped/operator` report retaining completed attempts; an already exceeded or +unverifiable budget takes precedence as `stopped/budget`. Interrupted work is not +invented as a product pass/failure. Known provider errors remain non-product failure +rows. Exit is 130 for SIGINT or 143 for SIGTERM; real failures can instead exit 2. +Legacy JSON includes the same completion, and triage displays it. + +Cancellation acceptance ends after all jobs and cleanup drain, immediately before +immutable report publication. Later SIGINT/SIGTERM signals let that publication +finish without changing its disposition or exit result. No model work occurs in +that phase. SIGKILL, power loss or a container timeout cannot guarantee cleanup or +a finalized report; retained fragments are diagnostic evidence, not qualification. + +Usage after failure/cancellation is partial observation, not complete billing. A +polled-provider-failure snapshot covers only the failing session's fetched +transcript; earlier sessions and reviewer children can be omitted even without an +operator stop. Zero reported tokens/cost does not prove zero spend. Check provider +usage independently. Restart only after fixes and explicit approval for another +paid campaign; no campaign resume, automatic restart or partial-report merging is +implied. ## Scenarios @@ -159,8 +180,8 @@ whole result. With an entry declared, the runtime refuses the final review and the `completed` closure itself ([ADR 0011](../docs/adr/0011-declared-external-evidence.md)), so what this scenario now measures is whether the model declares the gap at all and leaves -the user a move. It ships ungated in `scripts/qualify-release.ts` until it has a -recorded baseline. +the user a move. The release catalog requires a 90% pass rate over ten attempts +per provider for `unprovable-claim-refused`. `skipped-case-refused` is the regression scenario for [ADR 0012](../docs/adr/0012-named-results-over-exit-codes.md), and it differs from @@ -473,12 +494,14 @@ distinction that matters: one pass in six and six in six are different findings. ## Cost -A full pass is five scenarios of real agentic work, one of them two commands -long. Expect a handful of dollars -per model on a flagship model, and use `--scenario` while iterating. +Release qualification schedules 76 primary attempts across eight scenarios and +two providers, plus at most 16 environment reserves. Ordinary campaign size depends +on the selected scenarios, models and repeats. Use `--scenario` while iterating; +cost depends on model pricing and the work performed, not just scenario count. Cost is whatever the provider reports, and a provider that prices nothing reports zero rather than omitting the field: every OpenAI run measured here reported `cost: 0` on real token use. A zero total against non-zero output tokens is therefore read as unknown and printed as `cost not reported by provider` — an -unknown spend is not a free one. Token counts are always real. +unknown spend is not a free one. Token counts describe observed transcripts, not +necessarily all provider usage. diff --git a/evals/analysis.ts b/evals/analysis.ts index be9d4556..a0920e36 100644 --- a/evals/analysis.ts +++ b/evals/analysis.ts @@ -765,6 +765,19 @@ export function deriveReleaseDecision(input: { "campaign-stopped", `Required evidence remained incomplete after a ${report.completion.cause} stop.`, ); + } else if ( + report.completion.status === "stopped" && + (report.completion.cause === "operator" || + report.completion.cause === "budget") + ) { + decisionReason( + reasons, + "gap", + "campaign-stopped", + report.completion.cause === "operator" + ? "Campaign stopped by the operator; release qualification requires a completed campaign." + : `Campaign stopped (${report.completion.cause}); release qualification requires a completed campaign.`, + ); } const verdict = reasons.some((item) => item.severity === "hard") ? "NOT VERIFIED" diff --git a/evals/campaign-stop.ts b/evals/campaign-stop.ts new file mode 100644 index 00000000..9b541140 --- /dev/null +++ b/evals/campaign-stop.ts @@ -0,0 +1,34 @@ +/** Operator cancellation is control flow, not a provider or product failure. */ +export class CampaignCancelled extends Error { + readonly exitCode: number; + constructor(exitCode: number) { + super("Eval campaign stopped by the operator."); + this.exitCode = exitCode; + } +} + +export async function withCampaignSignals( + run: (signal: AbortSignal, beginFinalization: () => void) => Promise, +): Promise { + const controller = new AbortController(); + let finalizing = false; + const cancel = (code: number) => { + if (!finalizing) controller.abort(new CampaignCancelled(code)); + }; + const interrupt = () => cancel(130); + const terminate = () => cancel(143); + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + try { + const code = await run(controller.signal, () => { + finalizing = true; + }); + return controller.signal.aborted ? controller.signal.reason.exitCode : code; + } catch (error) { + if (error instanceof CampaignCancelled) return error.exitCode; + throw error; + } finally { + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", terminate); + } +} diff --git a/evals/failure-origin.ts b/evals/failure-origin.ts index aa780834..669a91e8 100644 --- a/evals/failure-origin.ts +++ b/evals/failure-origin.ts @@ -1,3 +1,4 @@ +import { CampaignCancelled } from "./campaign-stop.js"; import type { AttemptOutcome } from "./report.js"; export type DurableFailureOrigin = Extract< @@ -91,6 +92,7 @@ export async function evaluationPhase( return await operation(); } catch (error) { if ( + error instanceof CampaignCancelled || error instanceof EvaluationPhaseError || error instanceof EvaluationPersistenceError ) @@ -174,7 +176,7 @@ export async function preservePrimaryFailure( try { await cleanup(); } catch (error) { - if (!failed) throw error; + if (!failed || primary instanceof CampaignCancelled) throw error; cleanupFailure = error; } if (failed) { diff --git a/evals/harness.ts b/evals/harness.ts index a1b5553d..4c9f6a70 100644 --- a/evals/harness.ts +++ b/evals/harness.ts @@ -25,6 +25,7 @@ import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import packageJson from "../package.json" with { type: "json" }; import { type BunToolchain, runPinnedBunSync } from "./bun-toolchain.js"; +import { CampaignCancelled } from "./campaign-stop.js"; import { type AttemptFailure, attemptFailure, @@ -48,6 +49,32 @@ import { const STARTUP_TIMEOUT_MS = 180_000; const REQUEST_TIMEOUT_MS = 120_000; +const ABORT_TIMEOUT_MS = 3_000; + +function checkCancellation(signal?: AbortSignal): void { + signal?.throwIfAborted(); +} + +/** Observe even non-cooperative requests, without leaving rejected promises unhandled. */ +async function abortable( + signal: AbortSignal | undefined, + operation: () => Promise, +): Promise { + if (!signal) return operation(); + signal.throwIfAborted(); + let onAbort = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + const result = await Promise.race([operation(), aborted]); + return result; + } finally { + signal.removeEventListener("abort", onAbort); + } +} + /** What OpenCode names the error it stamps on a message an abort killed. */ const ABORT_ERROR_NAME = "MessageAbortedError"; /** @@ -113,7 +140,11 @@ export type CommandEnd = "quiet" | "escalated"; type RequestDelivery = | { readonly kind: "pending" } | { readonly kind: "accepted" } - | { readonly kind: "rejected"; readonly message: string }; + | { + readonly kind: "rejected"; + readonly message: string; + readonly error: unknown; + }; type SessionRequestInit = RequestInit & { readonly timeout: false }; type SessionFetch = ( @@ -153,9 +184,13 @@ function startSessionRequest(input: { if (!cancelled) delivery = { kind: "accepted" }; }, (error) => { - if (error === cancellation) return; + if ( + error === cancellation || + (cancelled && error instanceof Error && error.name === "AbortError") + ) + return; const message = String(error); - delivery = { kind: "rejected", message }; + delivery = { kind: "rejected", message, error }; input.onRejected(message); }, ); @@ -176,10 +211,37 @@ export async function runSessionRequest(input: { readonly body: unknown; readonly onRejected: (message: string) => void; readonly wait: (request: SessionRequest) => Promise; + readonly signal?: AbortSignal | undefined; + readonly onCancelled?: () => Promise; + /** + * Set only when wait itself observes signal. Await it through any pending + * failure cleanup instead of racing it; non-cooperative waits keep the default. + */ + readonly waitOwnsCancellation?: boolean; }): Promise { + checkCancellation(input.signal); const request = startSessionRequest(input); try { - return await input.wait(request); + // The production progress wait already observes the signal. Racing it again + // would discard a known timeout/wedge while its bounded abort is draining. + return await (input.waitOwnsCancellation + ? input.wait(request) + : abortable(input.signal, () => input.wait(request))); + } catch (error) { + if (input.signal?.aborted && error === input.signal.reason) { + // A rejected POST may have landed between progress polls. Cancellation + // ends the wait, but cannot erase that already-observed real failure. + const delivery = request.state(); + const primary = delivery.kind === "rejected" ? delivery.error : error; + request.cancel(); + return preservePrimaryFailure( + () => Promise.reject(primary), + async () => { + await input.onCancelled?.(); + }, + ); + } + throw error; } finally { request.cancel(); } @@ -503,25 +565,27 @@ export async function syncProviderCredentialsBack( let contents: string; try { contents = await readFile(paths.target, "utf8"); - } catch { + } catch (error) { // The child never wrote a credential file (no refresh happened, or the // provider authenticated purely from the env); nothing to carry back. - return; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; } try { - JSON.parse(contents); + if (!isRecord(JSON.parse(contents))) throw new Error("Not an object"); } catch { - console.error( - `eval harness: child auth.json at ${paths.target} did not parse as JSON; leaving the real credential file untouched.`, + // Parser diagnostics can quote credentials. Report only the retained path. + throw new Error( + `Eval host credentials are invalid; retained at ${paths.target}.`, ); - return; } let current = ""; try { current = await readFile(paths.source, "utf8"); - } catch { + } catch (error) { // The real file is gone — the developer logged out mid-run, or there was // never one to copy. The child's own entries are all there is. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const merged = mergeCredentials(current, contents, paths.snapshot); // Nothing this host rotated, so nothing to publish. Leaving the file alone is @@ -531,14 +595,15 @@ export async function syncProviderCredentialsBack( await writeFile(tempPath, merged, { mode: 0o600 }); await rename(tempPath, paths.source); } catch (error) { - // Failing to sync back must not crash the run over a host that already - // finished its scenario; it only means the next host risks the same stale - // credential this whole mechanism exists to avoid, which is no worse than - // before this fix existed. + // Preserve scratch on failure: its rotated credential may be the only + // usable copy left. stop() must not remove it after a failed publication. console.error( `eval harness: could not sync credentials back to ${paths.source}: ${error instanceof Error ? error.message : String(error)}`, ); - await rm(tempPath, { force: true }); + await preservePrimaryFailure( + () => Promise.reject(error), + () => rm(tempPath, { force: true }), + ); } }); } @@ -656,7 +721,11 @@ function processTreeAlive(child: ChildProcess): boolean { } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ESRCH") return false; - if (code === "EPERM") return !childExited; + if (code === "EPERM") { + throw new Error( + `Could not confirm eval host process tree ${pid} terminated: permission denied.`, + ); + } throw error; } } @@ -676,7 +745,18 @@ export async function terminateChildProcessTree( while (processTreeAlive(child) && Date.now() < gracefulDeadline) { await Bun.sleep(50); } - if (processTreeAlive(child)) signalProcessTree(child, "SIGKILL"); + if (processTreeAlive(child)) { + signalProcessTree(child, "SIGKILL"); + const killedDeadline = Date.now() + 1_000; + while (processTreeAlive(child) && Date.now() < killedDeadline) { + await Bun.sleep(50); + } + if (processTreeAlive(child)) { + throw new Error( + `Could not confirm eval host process tree ${child.pid} terminated after SIGKILL.`, + ); + } + } if (child.exitCode === null) { await Promise.race([exited, Bun.sleep(1_000)]); } @@ -692,14 +772,19 @@ export async function terminateChildProcessTree( async function fetchJson( url: string, timeout = REQUEST_TIMEOUT_MS, + signal?: AbortSignal, ): Promise { - const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }); - if (!response.ok) { - throw new Error( - `GET ${url} failed with ${response.status}: ${await response.text()}`, - ); - } - return response.json(); + const deadline = AbortSignal.timeout(timeout); + const requestSignal = signal ? AbortSignal.any([signal, deadline]) : deadline; + return abortable(requestSignal, async () => { + const response = await fetch(url, { signal: requestSignal }); + if (!response.ok) { + throw new Error( + `GET ${url} failed with ${response.status}: ${await response.text()}`, + ); + } + return response.json(); + }); } /** `fetchJson` for the requests that drive a session, with the same error rule. */ @@ -708,13 +793,16 @@ async function postJson( body: unknown, options: { readonly signal?: AbortSignal } = {}, ): Promise { - const response = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + const signal = options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return abortable(signal, async () => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + return postJsonResponse(url, response); }); - return postJsonResponse(url, response); } async function postJsonResponse(url: string, response: Response) { @@ -871,6 +959,7 @@ export async function runQueues( concurrency: number, run: (job: Job) => Promise, shouldStop?: (result: Result) => boolean, + signal?: AbortSignal, ): Promise { const results: Result[] = []; let next = 0; @@ -882,19 +971,20 @@ export async function runQueues( { length: Math.max(1, Math.min(concurrency, queues.length)) }, async () => { for (;;) { - if (stopped) return; + if (stopped || signal?.aborted) return; // Read and advance in one synchronous step, so no two workers can claim // the same queue. const queue = queues[next]; next += 1; if (!queue) return; for (const job of queue) { - if (stopped) return; + if (stopped || signal?.aborted) return; try { const result = await run(job); results.push(result); if (shouldStop?.(result)) stopped = true; } catch (error) { + if (signal?.aborted && error === signal.reason) return; if (!failed) failure = error; stopped = true; failed = true; @@ -1232,10 +1322,19 @@ export class EvalHost { readonly project: string; private readonly scratch: string; private credentialPaths: CredentialSync | null = null; - - private constructor(project: string, scratch: string) { + private polledProviderFailure: { + sessionId: string; + messages: MessageEntry[]; + failure: AttemptFailure<"provider">; + observation: ProviderErrorObservation | null; + } | null = null; + private stopPromise: Promise | undefined; + private readonly signal: AbortSignal | undefined; + + private constructor(project: string, scratch: string, signal?: AbortSignal) { this.project = project; this.scratch = scratch; + this.signal = signal; } /** Boots a throwaway OpenCode host over a git fixture. */ @@ -1249,146 +1348,179 @@ export class EvalHost { reviewer?: EvalReviewerOptions; /** False creates the paired benchmark's ordinary OpenCode control host. */ withFlow?: boolean; + signal?: AbortSignal; }): Promise { + checkCancellation(options.signal); const scratch = await mkdtemp(join(tmpdir(), "flow-eval-")); - await chmod(scratch, 0o700); - const childHome = join(scratch, "home"); - const childCache = join(scratch, "cache"); - const childData = join(childHome, ".local", "share"); const project = join(scratch, "project"); - await mkdir(childHome, { recursive: true }); - await mkdir(join(project, ".opencode"), { recursive: true }); - const credentialPaths = await evaluationPhase( - "host", - "credential-copy-failed", - true, - () => carryProviderCredentials(childData), - ); - - // Flow derives source identity from git, so the fixture must be a repo. - for (const [relative, contents] of Object.entries(options.files)) { - const target = join(project, relative); - await mkdir(join(target, ".."), { recursive: true }); - await writeFile(target, contents, "utf8"); - } - for (const argv of [ - ["init", "--initial-branch=main"], - ["config", "user.email", "eval@example.com"], - ["config", "user.name", "Flow Eval"], - ["add", "-A"], - ["commit", "-m", "fixture"], - ]) { - const git = spawnSync("git", argv, { cwd: project, encoding: "utf8" }); - if (git.status !== 0) - throw new Error(`git ${argv[0]} failed:\n${git.stderr}`); - } - - // Populate OpenCode's exact-version cache from the prepared install so Flow - // runs exercise the bytes a user would install, without touching the network. - // The ordinary OpenCode benchmark arm deliberately receives no plugin config. - if (options.withFlow !== false) { - const packages = join(childCache, "opencode", "packages"); - await mkdir(packages, { recursive: true }); - await cp( - options.packageCache, - join(packages, `opencode-plugin-flow@${packageJson.version}`), - { recursive: true }, + const host = new EvalHost(project, scratch, options.signal); + try { + checkCancellation(options.signal); + await chmod(scratch, 0o700); + const childHome = join(scratch, "home"); + const childCache = join(scratch, "cache"); + const childData = join(childHome, ".local", "share"); + await mkdir(childHome, { recursive: true }); + await mkdir(join(project, ".opencode"), { recursive: true }); + host.credentialPaths = await evaluationPhase( + "host", + "credential-copy-failed", + true, + () => carryProviderCredentials(childData), ); - } - const pluginEntry = `opencode-plugin-flow@${packageJson.version}`; - const reviewer = options.reviewer; - const configuredPlugin = - reviewer && (reviewer.model !== undefined || reviewer.steps !== undefined) - ? [pluginEntry, { reviewer }] - : pluginEntry; - await writeFile( - join(project, "opencode.json"), - `${JSON.stringify( - options.withFlow === false ? {} : { plugin: [configuredPlugin] }, - null, - 2, - )}\n`, - "utf8", - ); - const host = new EvalHost(project, scratch); - host.credentialPaths = credentialPaths; - return evaluationPhase("host", "host-start-failed", true, async () => { - const port = await availablePort(); - host.baseUrl = `http://127.0.0.1:${port}`; - host.server = spawn( - options.toolchain.executable, - [ - "x", - `opencode-ai@${options.opencodeVersion}`, - "serve", - "--port", - String(port), - "--hostname", - "127.0.0.1", - ], - { - cwd: project, - detached: process.platform !== "win32", - env: { - ...options.toolchain.environment, - HOME: childHome, - XDG_CACHE_HOME: childCache, - XDG_CONFIG_HOME: join(childHome, ".config"), - XDG_DATA_HOME: childData, - XDG_STATE_HOME: join(childHome, ".local", "state"), - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - const record = (chunk: unknown) => { - host.serverLog += String(chunk); - }; - host.server.stdout?.on("data", record); - host.server.stderr?.on("data", record); + // Flow derives source identity from git, so the fixture must be a repo. + for (const [relative, contents] of Object.entries(options.files)) { + checkCancellation(options.signal); + const target = join(project, relative); + await mkdir(join(target, ".."), { recursive: true }); + await writeFile(target, contents, "utf8"); + } + for (const argv of [ + ["init", "--initial-branch=main"], + ["config", "user.email", "eval@example.com"], + ["config", "user.name", "Flow Eval"], + ["add", "-A"], + ["commit", "-m", "fixture"], + ]) { + checkCancellation(options.signal); + const git = spawnSync("git", argv, { cwd: project, encoding: "utf8" }); + if (git.status !== 0) + throw new Error(`git ${argv[0]} failed:\n${git.stderr}`); + } - try { - const deadline = Date.now() + STARTUP_TIMEOUT_MS; - for (;;) { - try { - const health = (await fetchJson( - `${host.baseUrl}/global/health`, - 3_000, - )) as { - healthy?: boolean; - }; - if (health.healthy) break; - } catch { - // still starting - } - if (Date.now() > deadline) { - throw new Error( - `OpenCode did not become healthy.\n${host.serverLog}`, - ); - } - await Bun.sleep(500); - } - const ready = (await postJson( - `${host.baseUrl}/session`, - { title: "flow-eval readiness" }, + // Copy rather than race filesystem writes against cancellation: cleanup + // must wait until no copy can recreate scratch after its removal. + if (options.withFlow !== false) { + const packages = join(childCache, "opencode", "packages"); + await mkdir(packages, { recursive: true }); + await cp( + options.packageCache, + join(packages, `opencode-plugin-flow@${packageJson.version}`), { - signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())), + recursive: true, + filter: () => { + checkCancellation(options.signal); + return true; + }, }, - )) as { id?: unknown }; - if (typeof ready.id !== "string" || !ready.id) { - throw new Error("OpenCode readiness session had no id."); - } - await fetch(`${host.baseUrl}/session/${ready.id}`, { - method: "DELETE", - signal: AbortSignal.timeout(10_000), - }).catch(() => {}); - return host; - } catch (error) { - return preservePrimaryFailure( - () => Promise.reject(error), - () => host.stop(), ); } + checkCancellation(options.signal); + const pluginEntry = `opencode-plugin-flow@${packageJson.version}`; + const reviewer = options.reviewer; + const configuredPlugin = + reviewer && + (reviewer.model !== undefined || reviewer.steps !== undefined) + ? [pluginEntry, { reviewer }] + : pluginEntry; + await writeFile( + join(project, "opencode.json"), + `${JSON.stringify( + options.withFlow === false ? {} : { plugin: [configuredPlugin] }, + null, + 2, + )}\n`, + "utf8", + ); + + return await evaluationPhase( + "host", + "host-start-failed", + true, + async () => { + const port = await availablePort(); + checkCancellation(options.signal); + host.baseUrl = `http://127.0.0.1:${port}`; + host.server = spawn( + options.toolchain.executable, + [ + "x", + `opencode-ai@${options.opencodeVersion}`, + "serve", + "--port", + String(port), + "--hostname", + "127.0.0.1", + ], + { + cwd: project, + detached: process.platform !== "win32", + env: { + ...options.toolchain.environment, + HOME: childHome, + XDG_CACHE_HOME: childCache, + XDG_CONFIG_HOME: join(childHome, ".config"), + XDG_DATA_HOME: childData, + XDG_STATE_HOME: join(childHome, ".local", "state"), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const record = (chunk: unknown) => { + host.serverLog += String(chunk); + }; + host.server.stdout?.on("data", record); + host.server.stderr?.on("data", record); + let spawnError: Error | undefined; + host.server.once("error", (error) => { + spawnError = error; + }); + + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + for (;;) { + checkCancellation(options.signal); + if (spawnError) throw spawnError; + try { + const health = (await fetchJson( + `${host.baseUrl}/global/health`, + 3_000, + options.signal, + )) as { + healthy?: boolean; + }; + if (health.healthy) break; + } catch (error) { + if (error instanceof CampaignCancelled) throw error; + // still starting + } + if (Date.now() > deadline) { + throw new Error( + `OpenCode did not become healthy.\n${host.serverLog}`, + ); + } + await abortable(options.signal, () => Bun.sleep(500)); + } + const ready = (await host.post( + `${host.baseUrl}/session`, + { title: "flow-eval readiness" }, + Math.max(1, deadline - Date.now()), + )) as { id?: unknown }; + if (typeof ready.id !== "string" || !ready.id) { + throw new Error("OpenCode readiness session had no id."); + } + await host.deleteSession(ready.id); + checkCancellation(options.signal); + return host; + }, + ); + } catch (error) { + return preservePrimaryFailure( + () => Promise.reject(error), + () => host.stop(), + ); + } + } + + private post( + url: string, + body: unknown, + timeout = REQUEST_TIMEOUT_MS, + ): Promise { + return postJson(url, body, { + signal: this.signal + ? AbortSignal.any([this.signal, AbortSignal.timeout(timeout)]) + : AbortSignal.timeout(timeout), }); } @@ -1410,7 +1542,11 @@ export class EvalHost { * `probeModel` to establish that. */ async catalogModels(): Promise { - const listed = (await fetchJson(`${this.baseUrl}/config/providers`)) as { + const listed = (await fetchJson( + `${this.baseUrl}/config/providers`, + REQUEST_TIMEOUT_MS, + this.signal, + )) as { providers?: { id: string; models?: Record }[]; }; return (listed.providers ?? []).flatMap((provider) => @@ -1432,7 +1568,7 @@ export class EvalHost { async probeModel(model: string): Promise { const sessionId = await this.createSession(`flow-eval probe ${model}`); try { - const reply = (await postJson( + const reply = (await this.post( `${this.baseUrl}/session/${sessionId}/message`, { // `/session/:id/message` takes a split model, unlike `/command`, @@ -1445,16 +1581,28 @@ export class EvalHost { const failure = reply.info?.error; return failure ? summarizeError(failure) : null; } catch (error) { + if (error instanceof CampaignCancelled) { + await this.abortSession(sessionId); + throw error; + } return error instanceof Error ? error.message : String(error); } finally { - await fetch(`${this.baseUrl}/session/${sessionId}`, { - method: "DELETE", - }).catch(() => {}); + await this.deleteSession(sessionId); } } + private async deleteSession(sessionId: string): Promise { + const signal = AbortSignal.timeout(ABORT_TIMEOUT_MS); + await abortable(signal, () => + fetch(`${this.baseUrl}/session/${sessionId}`, { + method: "DELETE", + signal, + }), + ).catch(() => {}); + } + async createSession(title: string): Promise { - const session = (await postJson(`${this.baseUrl}/session`, { title })) as { + const session = (await this.post(`${this.baseUrl}/session`, { title })) as { id: string; }; return session.id; @@ -1473,6 +1621,9 @@ export class EvalHost { options: { quietMs?: number; timeoutMs?: number; stalledMs?: number } = {}, ): Promise { return runSessionRequest({ + signal: this.signal, + waitOwnsCancellation: true, + onCancelled: () => this.abortSession(sessionId), post: postSessionJson, url: `${this.baseUrl}/session/${sessionId}/command`, body: { command, arguments: args, model }, @@ -1495,6 +1646,9 @@ export class EvalHost { options: { quietMs?: number; timeoutMs?: number; stalledMs?: number } = {}, ): Promise { return runSessionRequest({ + signal: this.signal, + waitOwnsCancellation: true, + onCancelled: () => this.abortSession(sessionId), post: postSessionJson, url: `${this.baseUrl}/session/${sessionId}/message`, body: { @@ -1549,12 +1703,39 @@ export class EvalHost { }; for (;;) { const before = Date.now(); - await Bun.sleep(poll); + await abortable(this.signal, () => Bun.sleep(poll)); const delivery = options.request.state(); if (delivery?.kind === "rejected") { - throw new Error(`Host request was rejected: ${delivery.message}`); + throw delivery.error; + } + const messages = (await this.messages( + sessionId, + this.signal, + )) as MessageEntry[]; + const failed = messages.find( + (entry) => + entry.info.role === "assistant" && + entry.info.error && + !isSelfAbortError( + entry.info.error, + this.lastSelfAbortAt > 0 && + (entry.info.time?.created === undefined || + entry.info.time.created <= this.lastSelfAbortAt), + ), + ); + if (failed) { + const failure = providerFailure(failed.info.error); + // Preserve the evidence before aborting the host. Outcome reporting can + // use this one fetched transcript even if later reads are cancelled. + this.polledProviderFailure = { + sessionId, + messages, + failure, + observation: providerErrorObservation(failed.info.error, sessionId), + }; + await abortWait(); + throw new EvaluationPhaseError(failure, failed.info.error); } - const messages = (await this.messages(sessionId)) as MessageEntry[]; const unobserved = Date.now() - before; if (unobserved >= suspendFloor) { // Capped at one full timeout, because unbounded credit turns the ceiling @@ -1641,13 +1822,24 @@ export class EvalHost { */ private async abortSession(sessionId: string): Promise { this.lastSelfAbortAt = Date.now(); - await postJson(`${this.baseUrl}/session/${sessionId}/abort`, {}).catch( - () => {}, - ); + await postJson( + `${this.baseUrl}/session/${sessionId}/abort`, + {}, + { + signal: AbortSignal.timeout(ABORT_TIMEOUT_MS), + }, + ).catch(() => {}); } - private async messages(sessionId: string): Promise { - return fetchJson(`${this.baseUrl}/session/${sessionId}/message`); + private async messages( + sessionId: string, + signal?: AbortSignal, + ): Promise { + return fetchJson( + `${this.baseUrl}/session/${sessionId}/message`, + REQUEST_TIMEOUT_MS, + signal, + ); } /** @@ -1662,7 +1854,10 @@ export class EvalHost { * A host that does not expose children yields nothing rather than failing — * losing the subtask transcript is a smaller loss than losing the run. */ - private async descendantSessions(sessionIds: readonly string[]): Promise<{ + private async descendantSessions( + sessionIds: readonly string[], + signal?: AbortSignal, + ): Promise<{ readonly sessions: readonly ObservedSession[]; readonly endpointFailed: boolean; }> { @@ -1673,12 +1868,16 @@ export class EvalHost { while (frontier.length > 0) { const next: string[] = []; for (const parent of frontier) { + checkCancellation(signal); let children: unknown; try { children = await fetchJson( `${this.baseUrl}/session/${parent}/children`, + REQUEST_TIMEOUT_MS, + signal, ); - } catch { + } catch (error) { + if (signal?.aborted && error === signal.reason) throw error; endpointFailed = true; continue; } @@ -1717,8 +1916,20 @@ export class EvalHost { async outcome( sessionIds: readonly string[], durationMs: number, + // Deliberately explicit, not this.signal: evidence after a real failure + // must remain readable even when the campaign has been stopped. + signal?: AbortSignal, ): Promise { - const descendantResult = await this.descendantSessions(sessionIds); + const polled = + this.polledProviderFailure && + sessionIds.includes(this.polledProviderFailure.sessionId) + ? this.polledProviderFailure + : null; + if (!polled) checkCancellation(signal); + const descendantResult = polled + ? { sessions: [], endpointFailed: false } + : await this.descendantSessions(sessionIds, signal); + if (!polled) checkCancellation(signal); const descendants = descendantResult.sessions; const sessionRecords: readonly ObservedSession[] = [ ...sessionIds.map((id) => ({ id, agent: null, parentID: null })), @@ -1728,10 +1939,16 @@ export class EvalHost { const messages: { sessionIndex: number; entry: MessageEntry }[] = []; const sessionMessages: SessionMessages[] = []; for (const [sessionIndex, sessionId] of ordered.entries()) { + if (polled && sessionId !== polled.sessionId) continue; let entries: MessageEntry[] | null; try { - entries = (await this.messages(sessionId)) as MessageEntry[]; + entries = polled + ? polled.messages + : ((await this.messages(sessionId, signal)) as MessageEntry[]); } catch (error) { + // Reduce transcripts already fetched before deciding whether cancellation + // can discard this attempt. An earlier provider error still counts. + if (signal?.aborted && error === signal.reason) break; throw new EvaluationPhaseError( attemptFailure("host", "session-messages-read-failed", error, true), error, @@ -1759,10 +1976,11 @@ export class EvalHost { let costUsd = 0; let costReported = false; let assistantMessages = 0; - let providerError: AttemptFailure<"provider"> | null = null; + let providerError: AttemptFailure<"provider"> | null = + polled?.failure ?? null; let observedProviderError: NonNullable< Outcome["providerErrorObservation"] - > | null = null; + > | null = polled?.observation ?? null; let finalText = ""; const guidanceLoads: ObservedGuidanceLoad[] = []; let guidanceSequence = 0; @@ -1877,13 +2095,32 @@ export class EvalHost { reviewerActor, ]; + let session: Record | null = null; + let archives: Record[] = []; + try { + if (!polled) { + checkCancellation(signal); + session = await this.readJson( + join(this.project, ".flow", "session.json"), + signal, + ); + checkCancellation(signal); + archives = await this.readArchives(signal); + checkCancellation(signal); + } + } catch (error) { + // Cancelled workspace enrichment must not erase a provider failure + // already observed in the transcript. + if (!providerError || !signal?.aborted || error !== signal.reason) + throw error; + } return { allCalls, flowCalls: allCalls.filter((call) => call.tool.startsWith("flow_")), actors, guidanceLoads, - session: await this.readJson(join(this.project, ".flow", "session.json")), - archives: await this.readArchives(), + session, + archives, finalText, tokens, costUsd: reportedCost(costReported ? costUsd : null, tokens.output), @@ -1896,13 +2133,21 @@ export class EvalHost { private async readJson( path: string, + signal?: AbortSignal, ): Promise | null> { try { - return JSON.parse(await readFile(path, "utf8")) as Record< - string, - unknown - >; + return JSON.parse( + await abortable(signal, () => + readFile(path, { encoding: "utf8", ...(signal ? { signal } : {}) }), + ), + ) as Record; } catch (error) { + if ( + signal?.aborted && + (error === signal.reason || + (error instanceof Error && error.name === "AbortError")) + ) + throw signal.reason; if ( error instanceof SyntaxError || (error instanceof Error && "code" in error && error.code === "ENOENT") @@ -1915,12 +2160,15 @@ export class EvalHost { } } - private async readArchives(): Promise[]> { + private async readArchives( + signal?: AbortSignal, + ): Promise[]> { const history = join(this.project, ".flow", "history"); let names: string[]; try { - names = await readdir(history); + names = await abortable(signal, () => readdir(history)); } catch (error) { + if (signal?.aborted && error === signal.reason) throw error; if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; throw new EvaluationPhaseError( @@ -1932,13 +2180,20 @@ export class EvalHost { for (const name of names .filter((entry) => entry.endsWith(".json")) .sort()) { - const document = await this.readJson(join(history, name)); + checkCancellation(signal); + const document = await this.readJson(join(history, name), signal); + checkCancellation(signal); if (document) documents.push(document); } return documents; } - async stop(): Promise { + stop(): Promise { + this.stopPromise ??= this.stopOnce(); + return this.stopPromise; + } + + private async stopOnce(): Promise { if (this.server) await terminateChildProcessTree(this.server); // Must happen before the scratch directory is removed: a refresh the child // performed lives only in its copy of `auth.json`, and losing it here is diff --git a/evals/report.ts b/evals/report.ts index 45bf9615..fc411dae 100644 --- a/evals/report.ts +++ b/evals/report.ts @@ -335,6 +335,20 @@ export type AnalysisPolicy = z.infer; export type CampaignPlan = z.infer; export type AttemptRecordV2 = z.infer; export type CampaignCompletion = z.infer; + +export function requiresBudgetStop( + budget: CampaignPlan["budget"], + observed: CampaignCompletion["observed"], +): boolean { + return ( + observed.outputTokens > budget.maxOutputTokens || + observed.wallClockMs > budget.maxWallClockMs || + (budget.maxUsd !== null && + (observed.costUsd === null + ? budget.unknownCostPolicy === "stop" + : observed.costUsd > budget.maxUsd)) + ); +} export type EvalReportV2 = z.infer; export type ValidatedReport = DeepReadonly< z.infer @@ -782,19 +796,10 @@ function semanticIssues( } } } - const exceedsKnownBudget = - report.completion.observed.outputTokens > - report.plan.budget.maxOutputTokens || - report.completion.observed.wallClockMs > - report.plan.budget.maxWallClockMs || - (report.plan.budget.maxUsd !== null && - report.completion.observed.costUsd !== null && - report.completion.observed.costUsd > report.plan.budget.maxUsd); - const unknownCostRequiresStop = - report.plan.budget.maxUsd !== null && - report.completion.observed.costUsd === null && - report.plan.budget.unknownCostPolicy === "stop"; - const budgetRequiresStop = exceedsKnownBudget || unknownCostRequiresStop; + const budgetRequiresStop = requiresBudgetStop( + report.plan.budget, + report.completion.observed, + ); if ( budgetRequiresStop && (report.completion.status !== "stopped" || @@ -940,6 +945,7 @@ function semanticIssues( } if ( report.completion.status === "stopped" && + report.completion.cause !== "operator" && scoredOutcomes >= report.plan.stoppingRule.count && !(report.completion.cause === "budget" && budgetRequiresStop) ) { diff --git a/evals/run.ts b/evals/run.ts index 9eddf7f3..02523d4e 100644 --- a/evals/run.ts +++ b/evals/run.ts @@ -18,6 +18,7 @@ import { type FlowPromptSurfaceName, } from "../src/prompt-surfaces.js"; import { type BunToolchain, currentBunToolchain } from "./bun-toolchain.js"; +import { CampaignCancelled, withCampaignSignals } from "./campaign-stop.js"; import { canonicalSha256 } from "./canonical-json.js"; import { buildCassette, @@ -116,7 +117,7 @@ import type { InstructionDelivery, ModelIdentity, } from "./report.js"; -import { campaignPlanSha256 } from "./report.js"; +import { campaignPlanSha256, requiresBudgetStop } from "./report.js"; import { createReportStore } from "./report-store.js"; import { SCENARIOS } from "./scenarios.js"; @@ -707,7 +708,9 @@ async function preflight( opencodeVersion: string, models: readonly string[], toolchain: BunToolchain, + signal: AbortSignal, ): Promise { + signal.throwIfAborted(); process.stdout.write("- preflight: resolving model ids ... "); let host: EvalHost | null = null; let fatal: string | null = null; @@ -717,6 +720,7 @@ async function preflight( packageCache, opencodeVersion, files: { "package.json": '{\n "name": "preflight"\n}\n' }, + signal, }); const catalog = new Set(await host.catalogModels()); const missing = models.filter((model) => !catalog.has(model)); @@ -730,6 +734,7 @@ async function preflight( // at a time. const rejected: string[] = []; for (const model of models) { + signal.throwIfAborted(); process.stdout.write(`- preflight: probing ${model} ... `); const failure = await host.probeModel(model); console.log(failure ? `REJECTED (${failure})` : "OK"); @@ -744,6 +749,7 @@ async function preflight( } } } catch (error) { + if (error instanceof CampaignCancelled || signal.aborted) throw error; // Losing the probe host is not evidence about the models, so it must not // block a run that may well work. An actual rejection above is different, // and is fatal. @@ -754,15 +760,17 @@ async function preflight( await host?.stop(); } if (fatal) { - console.error(`\n${fatal}`); - process.exit(2); + throw new Error(fatal); } } -async function main(): Promise { - const { models, scenarios, sampling, concurrency } = parseArgs( - process.argv.slice(2), - ); +export async function runCampaign( + signal: AbortSignal, + args = process.argv.slice(2), + repositoryRoot = join(import.meta.dir, ".."), + beginFinalization: () => void = () => {}, +): Promise { + const { models, scenarios, sampling, concurrency } = parseArgs(args); const selected = sampling.kind === "release" ? releaseScenarios() @@ -783,7 +791,6 @@ async function main(): Promise { process.exit(2); } - const repositoryRoot = join(import.meta.dir, ".."); if (sampling.kind === "release") { assertReleaseHost({ platform: normalizeEvidencePlatform(process.platform), @@ -842,7 +849,9 @@ async function main(): Promise { const cassettes: Cassette[] = []; const hostPlatform = normalizeEvidencePlatform(process.platform); try { + signal.throwIfAborted(); const tarball = await packPlugin(repositoryRoot, packDir, toolchain); + signal.throwIfAborted(); const artifact = await inspectArtifact({ repositoryRoot, tarballPath: tarball, @@ -883,6 +892,7 @@ async function main(): Promise { opencodeVersion, [...new Set([...models, ...(reviewerModel ? [reviewerModel] : [])])], toolchain, + signal, ); const persistV2Attempt = async ( result: RunResult, @@ -971,14 +981,17 @@ async function main(): Promise { const started = Date.now(); let host: EvalHost | null = null; let observedOutcome: Outcome | null = null; + let stepFailure: AttemptFailure | null = null; return preservePrimaryFailure( async () => { try { + signal.throwIfAborted(); host = await EvalHost.start({ toolchain, packageCache, opencodeVersion, files: scenario.files, + signal, ...(reviewer.pluginOptions ? { reviewer: reviewer.pluginOptions } : {}), @@ -993,9 +1006,9 @@ async function main(): Promise { // calls, and those are the only evidence of how far the model got. // Throwing here would discard them and report a run of zeroes, so // the failure is remembered and the outcome collected regardless. - let stepFailure: AttemptFailure | null = null; const escalatedSteps: number[] = []; for (const [index, step] of scenario.steps.entries()) { + signal.throwIfAborted(); try { if (step.freshSession) { sessionIds.push( @@ -1036,6 +1049,7 @@ async function main(): Promise { if (index === scenario.steps.length - 1) break; } } catch (error) { + if (error instanceof CampaignCancelled) throw error; stepFailure = evaluatorFailure(error, "command-aborted"); break; } @@ -1044,9 +1058,17 @@ async function main(): Promise { "evaluator", "outcome-collection-threw", false, - () => activeHost.outcome(sessionIds, Date.now() - started), + () => + activeHost.outcome( + sessionIds, + Date.now() - started, + stepFailure ? AbortSignal.timeout(5_000) : signal, + ), ); observedOutcome = outcome; + // An interrupted attempt is not a measured product result. Previously + // committed attempts remain in the stopped campaign's report. + if (!stepFailure && !outcome.providerError) signal.throwIfAborted(); const cell = campaignCells[job.slot]; if (!cell?.managerModel) throw new Error(`Missing v2 campaign cell for slot ${job.slot}.`); @@ -1247,10 +1269,14 @@ async function main(): Promise { ); return { slot: job.slot, result, cassette }; } catch (error) { + if (error instanceof CampaignCancelled) throw error; if (error instanceof EvaluationPersistenceError) throw error; + // start() owns partial-host cleanup. A different error escaping + // cancellation means cleanup is not reliable enough to finalize. + if (!host && signal.aborted) throw error; const message = error instanceof Error ? error.message : String(error); - const failure = evaluatorFailure(error); + const failure = stepFailure ?? evaluatorFailure(error); const cell = campaignCells[job.slot]; if (!cell?.managerModel) throw new Error(`Missing v2 campaign cell for slot ${job.slot}.`); @@ -1478,6 +1504,7 @@ async function main(): Promise { sampling.kind === "release" ? releaseShouldStop() : isEvaluatorFailure(entry.result.failure?.origin), + signal, ); for (const entry of recorded.sort( (left, right) => left.slot - right.slot, @@ -1485,7 +1512,7 @@ async function main(): Promise { results.push(entry.result); if (entry.cassette) cassettes.push(entry.cassette); } - if (sampling.kind === "release") { + if (sampling.kind === "release" && !signal.aborted) { const primaryCount = v2Plan.cells.filter( (cell) => cell.schedule === "primary", ).length; @@ -1507,6 +1534,7 @@ async function main(): Promise { concurrency, runAttempt, releaseShouldStop, + signal, ); for (const entry of reserveRecorded.sort( (left, right) => left.slot - right.slot, @@ -1516,15 +1544,21 @@ async function main(): Promise { } } } + } catch (error) { + if (!(error instanceof CampaignCancelled)) throw error; } finally { await rm(packDir, { recursive: true, force: true }); } + // No hosts or paid work remain. Freeze the stop decision before immutable + // report publication; later signals let output drain, never contradict its bytes. + beginFinalization(); const reserveState = deriveEnvironmentReserveState(v2Plan, v2Attempts); const primaryCells = v2Plan.cells.filter( (cell) => cell.schedule === "primary", ); const attemptedCellIds = new Set(v2Attempts.map((attempt) => attempt.cellId)); const v2Complete = + !signal.aborted && primaryCells.every((cell) => attemptedCellIds.has(cell.cellId)) && reserveState.activatedReserveCellIds.every((cellId) => attemptedCellIds.has(cellId), @@ -1545,13 +1579,15 @@ async function main(): Promise { status: v2Complete ? "complete" : "stopped", cause: v2Complete ? "fixed-target" - : releaseStopCause - ? releaseStopCause - : stoppedOrigin - ? stoppedOrigin - : results.some((result) => result.unscored) - ? "operator" - : "evaluator", + : signal.aborted + ? "operator" + : releaseStopCause + ? releaseStopCause + : stoppedOrigin + ? stoppedOrigin + : results.some((result) => result.unscored) + ? "operator" + : "evaluator", startedAt: campaignStartedAt, finishedAt: v2FinishedAt, activatedReserveCellIds: [...reserveState.activatedReserveCellIds], @@ -1568,6 +1604,10 @@ async function main(): Promise { ), }, }; + if (requiresBudgetStop(v2Plan.budget, v2Completion.observed)) { + v2Completion.status = "stopped"; + v2Completion.cause = "budget"; + } await persistEvaluation("finalize", () => reportStore.finalize({ reportId: `flow-v2-${stamp}`, @@ -1577,6 +1617,11 @@ async function main(): Promise { ); const v2ReportPath = join(v2Directory, "report.json"); console.log(`V2 report: ${v2ReportPath}`); + if (signal.aborted) { + console.log( + "OPERATOR STOP: partial campaign; interrupted attempts are not scored or included in usage totals. Release qualification is incomplete.", + ); + } console.log(`\n${formatTable(results)}\n`); const scored = results.filter( @@ -1718,6 +1763,7 @@ async function main(): Promise { `${JSON.stringify( { flowVersion: packageJson.version, + completion: v2Completion, opencodeVersion, recordedAt: new Date().toISOString(), promptFootprint: footprint, @@ -1787,7 +1833,22 @@ async function main(): Promise { // Status follows the scored runs, matching the pass rate above. Counting the // excluded ones here is what made a run that printed "6/6 passed" exit 1 after // one attempt lost the network. A pass with nothing scored is not a pass. - process.exit(scored.length > 0 && passed === scored.length ? 0 : 1); + return signal.aborted + ? (signal.reason as CampaignCancelled).exitCode + : v2Completion.status === "complete" && + scored.length > 0 && + passed === scored.length + ? 0 + : 1; } -if (import.meta.main) await main(); +if (import.meta.main) { + try { + process.exitCode = await withCampaignSignals((signal, beginFinalization) => + runCampaign(signal, undefined, undefined, beginFinalization), + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } +} diff --git a/scripts/triage-report.ts b/scripts/triage-report.ts index 25d00dc4..b7b26943 100644 --- a/scripts/triage-report.ts +++ b/scripts/triage-report.ts @@ -17,7 +17,12 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; +import type { + AttemptFailure, + DurableFailureOrigin, +} from "../evals/failure-origin.js"; import type { CompletionHonesty, ReviewerActivity } from "../evals/metrics.js"; +import type { CampaignCompletion } from "../evals/report.js"; type Run = { scenario?: string; @@ -39,6 +44,7 @@ type Run = { durationMs?: number; costUsd?: number | null; error?: string; + failure?: AttemptFailure; }; type Report = { @@ -46,6 +52,7 @@ type Report = { opencodeVersion?: string; recordedAt?: string; results?: readonly Run[]; + completion?: Pick; }; type PairContext = Readonly<{ attempts: number; escalations: number }>; @@ -77,13 +84,24 @@ function reasons( why: `${run.reviewer?.unsubmitted} review assignment(s) never submitted`, }); } - if (run.error) { + if (run.failure) { + found.push({ + weight: 50, + why: `${run.failure.origin} failure (${run.failure.code}): ${run.failure.detail.split("\n")[0]}`, + }); + } else if (run.error) { found.push({ weight: 50, why: `aborted mid-flight: ${run.error.split("\n")[0]}`, }); } - if (run.passed === false && !run.environment && !run.unscored && !run.error) { + if ( + run.passed === false && + !run.environment && + !run.unscored && + !run.error && + !run.failure + ) { found.push({ weight: 40, why: `wrong durable outcome: ${(run.issues ?? []).join("; ") || "no issue recorded"}`, @@ -141,6 +159,7 @@ function label(run: Run): string { } function verdict(run: Run): string { + if (run.failure) return run.failure.origin.toUpperCase(); if (run.environment) return "ENV"; if (run.error) return "ABORT"; if (run.unscored) return "ASKED"; @@ -180,6 +199,14 @@ function detail(run: Run): string { ` final report:`, indent(run.finalText ?? ""), ]; + if (run.failure) + lines.push( + "", + " failure:", + indent( + `${run.failure.origin} / ${run.failure.code}: ${run.failure.detail}`, + ), + ); const reviewer = run.reviewer; if (reviewer && reviewer.assignments > 0) { lines.push( @@ -266,6 +293,10 @@ async function main(): Promise { console.log( `${reportPath}\nFlow ${report.flowVersion ?? "?"} on OpenCode ${report.opencodeVersion ?? "?"}, recorded ${report.recordedAt ?? "?"}\n`, ); + if (report.completion) + console.log( + `Campaign: ${report.completion.status} (${report.completion.cause}).${report.completion.status === "stopped" ? " Not release qualification; counts cover retained attempts only." : ""}\n`, + ); if (only !== null) { const matched = (report.results ?? []).filter( @@ -282,7 +313,9 @@ async function main(): Promise { const { ranked, quiet } = triage(report); if (ranked.length === 0) { console.log( - `Nothing flagged across ${quiet.length} run(s). That is a finding worth one spot check: read one at random with \`--run \`, because a suite that never flags anything and a suite that measures nothing look the same from here.`, + report.completion?.status === "stopped" + ? `No per-run flags in ${quiet.length} retained run(s); the campaign itself is stopped.` + : `Nothing flagged across ${quiet.length} run(s). That is a finding worth one spot check: read one at random with \`--run \`, because a suite that never flags anything and a suite that measures nothing look the same from here.`, ); return; } diff --git a/tests/eval-cancellation.test.ts b/tests/eval-cancellation.test.ts new file mode 100644 index 00000000..abad8b93 --- /dev/null +++ b/tests/eval-cancellation.test.ts @@ -0,0 +1,541 @@ +import { describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deriveReleaseDecision } from "../evals/analysis.js"; +import { + CampaignCancelled, + withCampaignSignals, +} from "../evals/campaign-stop.js"; +import { parseCaseCatalog } from "../evals/catalog.js"; +import { + deriveRetainedFailure, + RetainedScenarioEvidenceSchema, +} from "../evals/grader-input.js"; +import { releaseCatalog } from "../evals/release-policy.js"; +import { parseReport } from "../evals/report.js"; + +describe("campaign signal ownership", () => { + for (const result of ["return", "throw", "cancel"] as const) { + test(`removes its exact listeners after ${result}`, async () => { + const before = [ + process.listeners("SIGINT"), + process.listeners("SIGTERM"), + ]; + let cleanup = 0; + let aborts = 0; + const run = withCampaignSignals(async (signal) => { + try { + if (result === "throw") throw new Error("fixture failure"); + if (result === "cancel") { + signal.addEventListener("abort", () => aborts++); + process.emit("SIGINT"); + process.emit("SIGTERM"); + expect(signal.reason).toBeInstanceOf(CampaignCancelled); + expect(signal.reason.exitCode).toBe(130); + signal.throwIfAborted(); + } + return 7; + } finally { + cleanup++; + } + }); + if (result === "throw") + await expect(run).rejects.toThrow("fixture failure"); + else expect(await run).toBe(result === "cancel" ? 130 : 7); + expect(cleanup).toBe(1); + expect(aborts).toBe(result === "cancel" ? 1 : 0); + expect([ + process.listeners("SIGINT"), + process.listeners("SIGTERM"), + ]).toEqual(before); + }); + } +}); + +type FixtureMode = + | "complete" + | "finalize-read" + | "finalize-write" + | "legacy-write" + | "cassette-write" + | "preflight-host-cleanup-failure" + | "host-cleanup-failure" + | "provider-outcome-stop" + | "provider-transcript-stop" + | "provider-poll-stop" + | "provider-poll-outcome-stop" + | "budget-stop" + | "step" + | "host" + | "preflight" + | "last-cleanup" + | "cleanup-failure" + | "persistence-failure"; + +async function runSignalledFixture( + root: string, + mode: FixtureMode, + signal: "SIGINT" | "SIGTERM", +) { + const child = spawn( + process.execPath, + [ + join(import.meta.dir, "fixtures", "eval-cancellation-child.ts"), + root, + mode, + ], + { + cwd: root, + // Deliberately do not forward API keys, reviewer overrides or global auth. + env: { + PATH: process.env.PATH, + HOME: root, + XDG_CONFIG_HOME: join(root, "config"), + XDG_DATA_HOME: join(root, "data"), + XDG_CACHE_HOME: join(root, "cache"), + FLOW_EVAL_NO_AUTH_COPY: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const events: string[] = []; + let stdout = ""; + let stderr = ""; + let pending = ""; + let expired = false; + const secondSignal = signal === "SIGINT" ? "SIGTERM" : "SIGINT"; + // Timing bounds detect a hang; they never decide when a signal is delivered. + const timeout = setTimeout(() => { + expired = true; + child.kill("SIGKILL"); + }, 15_000); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (data: string) => { + stderr += data; + }); + child.stdout.on("data", (data: string) => { + stdout += data; + pending += data; + let newline = pending.indexOf("\n"); + while (newline !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (line.startsWith("@@eval-cancellation:")) { + const name = line.slice("@@eval-cancellation:".length); + events.push(name); + if (name === "ready") child.kill(signal); + if (name === "cleanup-wait") child.kill(secondSignal); + // Acknowledgment proves the repeated OS signal reached the child + // before cleanup is released; no sleep/coalesced signal race. + if (name === `signal:${secondSignal}`) child.stdin.end("release\n"); + } + newline = pending.indexOf("\n"); + } + }); + try { + const status = await new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, exitSignal) => + resolve({ code, signal: exitSignal }), + ); + }); + if (expired) + throw new Error(`Cancellation fixture hung.\n${stdout}\n${stderr}`); + return { ...status, events, stdout, stderr }; + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + } +} + +async function reportDirectory(root: string): Promise { + const directory = join(root, "evals", "results"); + const reports = (await readdir(directory)).filter((name) => + name.endsWith(".v2"), + ); + expect(reports).toHaveLength(1); + return join(directory, reports[0] ?? "missing"); +} + +// POSIX process signals are not supported consistently by Windows/Bun. +const signalTest = process.platform === "win32" ? test.skip : test; +describe("real runner cancellation reports without provider spend", () => { + signalTest( + "R27-01 over-budget operator stop preserves observations and publishes a budget-stopped report", + async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-budget-stop-")); + try { + const result = await runSignalledFixture( + root, + "budget-stop", + "SIGTERM", + ); + expect(result.code).toBe(143); + expect(result.stderr).toBe(""); + const directory = await reportDirectory(root); + const catalog = parseCaseCatalog( + JSON.parse(await readFile(join(directory, "catalog.json"), "utf8")), + ); + if (!catalog.ok) throw new Error("Invalid fixture catalog"); + const parsed = parseReport( + JSON.parse(await readFile(join(directory, "report.json"), "utf8")), + catalog.value, + ); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.issues)); + const report = parsed.value; + expect(report.completion).toMatchObject({ + status: "stopped", + cause: "budget", + observed: { attempts: 1 }, + }); + expect(report.completion.observed.wallClockMs).toBeGreaterThan( + report.plan.budget.maxWallClockMs, + ); + expect(report.attempts).toHaveLength(1); + expect(report.attempts[0]?.outcome).toMatchObject({ + kind: "product", + passed: true, + }); + expect( + result.events.filter((name) => name.startsWith("cleaned:")), + ).toEqual(["cleaned:0", "cleaned:1", "cleaned:2"]); + expect( + result.events.filter((name) => name.startsWith("start:")), + ).toEqual(["start:0", "start:1", "start:2"]); + const results = join(root, "evals", "results"); + const legacy = (await readdir(results)).find((name) => + name.endsWith(".json"), + ); + expect( + JSON.parse(await readFile(join(results, legacy ?? "missing"), "utf8")) + .completion, + ).toEqual(report.completion); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 20_000, + ); + + for (const mode of [ + "provider-outcome-stop", + "provider-transcript-stop", + "provider-poll-stop", + "provider-poll-outcome-stop", + ] as const) { + signalTest( + `R20-01 runner retains observed provider failure after ${mode}`, + async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-provider-stop-")); + try { + const result = await runSignalledFixture(root, mode, "SIGINT"); + expect(result.code).toBe(130); + expect(result.stderr).toBe(""); + const directory = await reportDirectory(root); + const report = JSON.parse( + await readFile(join(directory, "report.json"), "utf8"), + ); + expect(report.completion).toMatchObject({ + status: "stopped", + cause: "operator", + }); + expect(report.attempts).toHaveLength(2); + expect(report.attempts[0].outcome).toMatchObject({ + kind: "product", + passed: true, + }); + expect(report.attempts[1].outcome).toMatchObject({ + kind: "failure", + origin: "provider", + code: "provider-rejected-turn", + }); + if (mode.startsWith("provider-poll-")) { + expect( + result.events.filter((name) => name === "actual-progress-poll"), + ).toHaveLength(1); + expect( + result.events.filter((name) => name === "actual-provider-abort"), + ).toHaveLength(1); + expect(result.events).not.toContain("unexpected-enrichment"); + const retained = RetainedScenarioEvidenceSchema.parse( + JSON.parse( + await readFile( + join(directory, report.attempts[1].transcript.artifact), + "utf8", + ), + ), + ); + expect(retained.failureObservation).toMatchObject({ + kind: "provider-error", + name: "FixtureProviderUnavailable", + message: "Fake polled provider failure", + }); + expect(deriveRetainedFailure(retained)).toEqual({ + origin: "provider", + code: "provider-rejected-turn", + retryable: true, + }); + expect( + result.events.filter((name) => name.startsWith("cleaned:")), + ).toEqual(["cleaned:0", "cleaned:1", "cleaned:2"]); + } + expect( + result.events.filter((event) => event.startsWith("start:")), + ).toEqual(["start:0", "start:1", "start:2"]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 20_000, + ); + } + for (const mode of [ + "finalize-read", + "finalize-write", + "legacy-write", + "cassette-write", + ] as const) { + signalTest( + `signals during ${mode} drain immutable publication without accepting a contradictory cancellation`, + async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-publication-")); + try { + const result = await runSignalledFixture(root, mode, "SIGTERM"); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect( + result.events.filter((name) => name.startsWith("signal:")), + ).toEqual(["signal:SIGTERM", "signal:SIGINT"]); + expect(result.stdout).not.toContain("OPERATOR STOP"); + const directory = await reportDirectory(root); + const report = JSON.parse( + await readFile(join(directory, "report.json"), "utf8"), + ); + const completion = JSON.parse( + await readFile(join(directory, "completion.json"), "utf8"), + ); + expect(report.completion).toEqual(completion); + expect(completion).toMatchObject({ + status: "complete", + cause: "fixed-target", + }); + const results = join(root, "evals", "results"); + const legacyPath = (await readdir(results)).find((name) => + name.endsWith(".json"), + ); + const legacy = JSON.parse( + await readFile(join(results, legacyPath ?? "missing"), "utf8"), + ); + expect(legacy.completion).toEqual(completion); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 20_000, + ); + } + test("normal completion still writes a complete report and exits zero", async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-complete-")); + try { + const result = await runSignalledFixture(root, "complete", "SIGINT"); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + const directory = await reportDirectory(root); + const report = JSON.parse( + await readFile(join(directory, "report.json"), "utf8"), + ); + expect(report.completion).toMatchObject({ + status: "complete", + cause: "fixed-target", + }); + expect(report.attempts).toHaveLength(1); + expect(report.attempts[0].outcome.passed).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 20_000); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + for (const mode of ["step", "host", "preflight", "last-cleanup"] as const) { + signalTest( + `${signal} during ${mode} stops without inventing an attempt`, + async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-cancel-")); + try { + const result = await runSignalledFixture(root, mode, signal); + expect(result.stderr).toBe(""); + expect(result.code).toBe(signal === "SIGINT" ? 130 : 143); + expect(result.signal).toBeNull(); + expect(result.events).not.toContain("unexpected-network"); + expect(result.events).toContain("returned"); + expect( + result.events.filter((name) => name.startsWith("signal:")), + ).toEqual([ + `signal:${signal}`, + `signal:${signal === "SIGINT" ? "SIGTERM" : "SIGINT"}`, + ]); + const started = + mode === "preflight" + ? [0] + : mode === "last-cleanup" + ? [0, 1] + : [0, 1, 2]; + for (const phase of ["start", "stop", "cleaned"] as const) { + expect( + result.events.filter((name) => name.startsWith(`${phase}:`)), + ).toEqual(started.map((attempt) => `${phase}:${attempt}`)); + } + expect( + result.events.filter((name) => name.startsWith("probe:")), + ).toEqual(["probe:fixture/model-a"]); + expect( + result.events.filter((name) => name.startsWith("outcome:")), + ).toEqual(mode === "preflight" ? [] : ["outcome:1"]); + const directory = await reportDirectory(root); + const catalog = parseCaseCatalog( + JSON.parse( + await readFile(join(directory, "catalog.json"), "utf8"), + ), + ); + if (!catalog.ok) + throw new Error("Runner persisted an invalid catalog."); + const parsed = parseReport( + JSON.parse( + await readFile(join(directory, "report.json"), "utf8"), + ), + catalog.value, + ); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.issues)); + const report = parsed.value; + const completed = mode === "preflight" ? 0 : 1; + expect(report.completion.status).toBe("stopped"); + expect(report.completion.cause).toBe("operator"); + expect(report.completion.observed.attempts).toBe(completed); + expect(report.completion.observed.costUsd).toBe(0); + expect(report.attempts).toHaveLength(completed); + expect(await readdir(join(directory, "attempts"))).toHaveLength( + completed, + ); + for (const attempt of report.attempts) { + expect(attempt.repetition).toBe(0); + expect(attempt.outcome).toMatchObject({ + kind: "product", + passed: true, + issues: [], + }); + expect(attempt.transcript).not.toBeNull(); + const transcript = JSON.parse( + await readFile( + join(directory, attempt.transcript?.artifact ?? "missing"), + "utf8", + ), + ); + expect(transcript.failure).toBeNull(); + expect(transcript.gradeInput.session.runs).toEqual([]); + } + const results = join(root, "evals", "results"); + const legacyFiles = (await readdir(results)).filter((name) => + name.endsWith(".json"), + ); + expect(legacyFiles).toHaveLength(1); + const legacy = JSON.parse( + await readFile( + join(results, legacyFiles[0] ?? "missing"), + "utf8", + ), + ); + expect(legacy.summary).toMatchObject({ + passed: completed, + scored: completed, + total: completed, + aborted: 0, + environmentBlocked: 0, + }); + expect(legacy.results).toHaveLength(completed); + expect(legacy.completion).toEqual(report.completion); + if (completed > 0) { + const cassettes = (await readdir(results)).find((name) => + name.endsWith(".cassettes"), + ); + expect(cassettes).toBeDefined(); + expect( + await readdir(join(results, cassettes ?? "missing")), + ).toHaveLength(1); + } + // Exercise the real release decision helper, not a copied stop rule. + // This ordinary one-case fixture necessarily lacks release evidence. + const first = report.attempts[0]; + if (first && !("kind" in first.artifact)) { + const decision = deriveReleaseDecision({ + report, + catalog: releaseCatalog(), + expected: { + kind: "release", + artifact: first.artifact, + evaluator: first.evaluator, + attempts: [], + }, + promotionArtifact: first.artifact, + }); + expect(decision.verdict).not.toBe("VERIFIED"); + expect( + decision.reasons.some( + (reason) => reason.code === "campaign-stopped", + ), + ).toBe(true); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 20_000, + ); + } + } + + for (const mode of [ + "cleanup-failure", + "host-cleanup-failure", + "persistence-failure", + "preflight-host-cleanup-failure", + ] as const) { + signalTest( + `${mode} is not swallowed by operator cancellation`, + async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-cancel-error-")); + try { + const result = await runSignalledFixture(root, mode, "SIGINT"); + expect(result.signal).toBeNull(); + expect(result.code).toBe(1); + expect(result.events).toContain( + `injected-${mode === "preflight-host-cleanup-failure" || mode === "host-cleanup-failure" ? "cleanup-failure" : mode}`, + ); + expect(result.events).toContain("error"); + expect(result.events).not.toContain("returned"); + expect(result.stderr).not.toBe(""); + const directory = await reportDirectory(root); + expect(await readdir(directory)).not.toContain("report.json"); + expect(await readdir(join(directory, "attempts"))).toHaveLength( + mode === "preflight-host-cleanup-failure" ? 0 : 1, + ); + expect( + result.events.filter((name) => name.startsWith("stop:")), + ).toEqual( + mode === "preflight-host-cleanup-failure" + ? ["stop:0"] + : ["stop:0", "stop:1", "stop:2"], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 20_000, + ); + } +}); diff --git a/tests/eval-failure-origin.test.ts b/tests/eval-failure-origin.test.ts index 1c29ae1d..a7b4a97b 100644 --- a/tests/eval-failure-origin.test.ts +++ b/tests/eval-failure-origin.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { CampaignCancelled } from "../evals/campaign-stop.js"; import { attemptFailure, EvaluationPersistenceError, @@ -14,6 +15,28 @@ import { } from "../evals/failure-origin.js"; describe("eval failure origins", () => { + test("preserves operator cancellation through phase wrappers", async () => { + const reason = new CampaignCancelled(143); + await expect( + evaluationPhase("host", "command-aborted", true, async () => { + throw reason; + }), + ).rejects.toBe(reason); + }); + + test("cleanup failure takes precedence over operator cancellation", async () => { + const cleanup = new EvaluationPersistenceError("credentials", "disk full"); + await expect( + preservePrimaryFailure( + async () => { + throw new CampaignCancelled(130); + }, + async () => { + throw cleanup; + }, + ), + ).rejects.toBe(cleanup); + }); test("turns every grader throw into a non-retryable evaluator failure", () => { for (const thrown of [new Error("grader exploded"), "grader exploded"]) { const evaluated = evaluateScenario( diff --git a/tests/eval-reporting.test.ts b/tests/eval-reporting.test.ts index c3c21cf4..a534f63e 100644 --- a/tests/eval-reporting.test.ts +++ b/tests/eval-reporting.test.ts @@ -1,11 +1,20 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { spawn } from "node:child_process"; +import { ChildProcess, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { CampaignCancelled } from "../evals/campaign-stop.js"; import { askedScoring, + EvalHost, formatRate, isSelfAbortError, isWedged, @@ -51,6 +60,16 @@ function processExists(pid: number): boolean { } } +function mockFetch( + implementation: ( + ...args: Parameters + ) => ReturnType, +) { + return spyOn(globalThis, "fetch").mockImplementation( + Object.assign(implementation, { preconnect: fetch.preconnect }), + ); +} + // Running the harness needs credentials and money, so the rules that decide what // a run *means* are proven here instead. Two were wrong in recorded runs: unpriced // spend printed as `$0.0000`, and a session blocked on an unanswerable question @@ -75,7 +94,7 @@ describe("eval run classification", () => { expect(processExists(childPid)).toBe(false); }); - test("treats an EPERM group probe as stopped after the child exits", async () => { + test("refuses an unconfirmed EPERM group even after the wrapper exits", async () => { if (process.platform === "win32") return; const child = spawn(process.execPath, ["-e", ""]); await new Promise((resolve, reject) => { @@ -94,7 +113,9 @@ describe("eval run classification", () => { }, ); try { - await terminateChildProcessTree(child); + await expect(terminateChildProcessTree(child)).rejects.toThrow( + "permission denied", + ); } finally { probe.mockRestore(); } @@ -386,6 +407,7 @@ wait "$child" expect(request.state()).toEqual({ kind: "rejected", message: "Error: provider disconnected", + error: external, }); return "quiet"; }, @@ -437,6 +459,802 @@ wait "$child" }); }); +describe("eval campaign cancellation", () => { + for (const phase of ["session", "archives"] as const) { + for (const failed of [false, true]) { + test(`R20-01/R10-06 cancellation during ${phase} reads preserves observed provider failure: ${failed}`, async () => { + const scratch = await mkdtemp(join(tmpdir(), "flow-outcome-files-")); + const history = join(scratch, ".flow", "history"); + await mkdir(history, { recursive: true }); + await writeFile(join(history, "a.json"), "{}"); + await writeFile(join(history, "b.json"), "{}"); + const host = Reflect.construct(EvalHost, [ + scratch, + scratch, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + const controller = new AbortController(); + const reason = new CampaignCancelled(130); + const entered = Promise.withResolvers(); + const read = Reflect.get(host, "readJson").bind(host) as ( + path: string, + signal?: AbortSignal, + ) => Promise | null>; + const visited: string[] = []; + Object.assign(host, { + readJson: async (path: string, signal?: AbortSignal) => { + visited.push(path); + if ( + path.endsWith(phase === "session" ? "session.json" : "a.json") + ) { + if (!signal) + throw new Error("Missing filesystem cancellation signal"); + const pending = new Promise((_, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }), + ); + entered.resolve(); + return pending; + } + return read(path, signal); + }, + }); + const requests = mockFetch(async (input) => + String(input).endsWith("/children") + ? Response.json([]) + : Response.json([ + { + info: { + id: "message", + sessionID: "id", + role: "assistant", + time: { created: 1, completed: 2 }, + ...(failed + ? { + error: { + name: "FixtureProviderUnavailable", + data: { message: "Fake provider failure" }, + }, + } + : {}), + }, + parts: [], + }, + ]), + ); + try { + const result = host.outcome(["id"], 1, controller.signal); + const observed = result.catch((error) => error); + await entered.promise; + controller.abort(reason); + if (failed) { + expect(await observed).toMatchObject({ + providerError: { + origin: "provider", + code: "provider-rejected-turn", + }, + providerErrorObservation: { name: "FixtureProviderUnavailable" }, + }); + } else expect(await observed).toBe(reason); + expect(visited.some((path) => path.endsWith("b.json"))).toBe(false); + } finally { + requests.mockRestore(); + await host.stop(); + } + }); + } + } + test("R10-04 preserves a wait rejection queued before the stop signal", async () => { + const controller = new AbortController(); + const failure = new Error("known progress-read failure"); + const waiting = Promise.withResolvers(); + const running = runSessionRequest({ + signal: controller.signal, + post: async () => {}, + url: "http://fixture/session/id/command", + body: {}, + onRejected: () => {}, + wait: () => waiting.promise, + }); + const observed = running.catch((error) => error); + waiting.reject(failure); + controller.abort(new CampaignCancelled(130)); + expect(await observed).toBe(failure); + }); + + for (const waitOwnsCancellation of [false, true]) { + test(`R10-04 preserves an observed POST rejection before cancellation (cooperative wait: ${waitOwnsCancellation})`, async () => { + const controller = new AbortController(); + const reason = new CampaignCancelled(130); + const failure = new Error("known host POST failure"); + const rejected = Promise.withResolvers(); + let cleaned = false; + const running = runSessionRequest({ + signal: controller.signal, + waitOwnsCancellation, + post: async () => { + throw failure; + }, + url: "http://fixture/session/id/command", + body: {}, + onRejected: () => rejected.resolve(), + wait: () => + new Promise((_resolve, reject) => { + if (waitOwnsCancellation) { + controller.signal.addEventListener( + "abort", + () => reject(controller.signal.reason), + { once: true }, + ); + } + }), + onCancelled: async () => { + cleaned = true; + }, + }); + const observed = running.catch((error) => error); + await rejected.promise; + controller.abort(reason); + expect(await observed).toBe(failure); + expect(cleaned).toBe(true); + }); + } + + for (const failure of ["timeout", "wedge"] as const) { + test(`R10-04 drains ${failure} abort cleanup without replacing the failure with cancellation`, async () => { + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-failure-race-")); + const controller = new AbortController(); + const host = Reflect.construct(EvalHost, [ + scratch, + scratch, + controller.signal, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + const aborting = Promise.withResolvers(); + const cleanup = Promise.withResolvers(); + let aborts = 0; + const requests = mockFetch(async (input) => { + const url = String(input); + if (url.endsWith("/abort")) { + aborts += 1; + aborting.resolve(); + return cleanup.promise; + } + if (url.endsWith("/command")) return Response.json({}); + return Response.json( + failure === "timeout" + ? [] + : [ + { + info: { role: "assistant" }, + parts: [ + { + type: "tool", + tool: "bash", + state: { status: "running" }, + }, + ], + }, + ], + ); + }); + let settled = false; + const running = host + .runCommand("id", "flow-auto", "fixture", "fixture/model", { + timeoutMs: failure === "timeout" ? 0 : 20_000, + stalledMs: 0, + }) + .catch((error) => { + settled = true; + return error; + }); + try { + await aborting.promise; + controller.abort(new CampaignCancelled(143)); + await Bun.sleep(1); + expect(settled).toBe(false); + cleanup.resolve(Response.json(true)); + const error = await running; + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(CampaignCancelled); + expect(error.message).toContain( + failure === "timeout" + ? "Scenario exceeded 0ms" + : "Scenario made no progress for 0ms", + ); + expect(aborts).toBe(1); + } finally { + cleanup.resolve(Response.json(true)); + await running; + requests.mockRestore(); + await host.stop(); + } + }, 10_000); + } + + for (const endpoint of ["children", "message"] as const) { + for (const operatorStop of [true, false]) { + test(`R10-06 cancels slow outcome ${endpoint} reads with an explicit ${operatorStop ? "campaign" : "evidence-drain"} signal`, async () => { + const scratch = await mkdtemp( + join(tmpdir(), "flow-eval-outcome-stop-"), + ); + const controller = new AbortController(); + const reason = operatorStop + ? new CampaignCancelled(130) + : new Error("bounded evidence-drain timeout"); + const host = Reflect.construct(EvalHost, [ + scratch, + scratch, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + const reading = Promise.withResolvers(); + let transport: AbortSignal | null | undefined; + let reads = 0; + const requests = mockFetch(async (input, init) => { + reads += 1; + if (String(input).endsWith(`/${endpoint}`)) { + transport = init?.signal; + reading.resolve(); + return new Promise(() => {}); + } + return Response.json([]); + }); + try { + const observed = host + .outcome(["id", "next"], 1, controller.signal) + .catch((error) => error); + await reading.promise; + const admitted = reads; + controller.abort(reason); + expect(await observed).toBe(reason); + expect(transport?.aborted).toBe(true); + expect(reads).toBe(admitted); + await host.stop(); + await expect(readdir(scratch)).rejects.toThrow(); + } finally { + requests.mockRestore(); + await host.stop(); + } + }); + } + } + + for (const afterKill of ["gone", "alive", "unconfirmed"] as const) { + test(`R10-08 checks process-group liveness after SIGKILL: ${afterKill}`, async () => { + if (process.platform === "win32") return; + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-kill-confirm-")); + const source = join(scratch, "original.json"); + const target = join(scratch, "child.json"); + const snapshot = '{"fixture":"before"}'; + const rotated = '{"fixture":"after"}'; + await writeFile(source, snapshot); + await writeFile(target, rotated); + const child = new ChildProcess(); + // No process is spawned. Every signal, including probes, is intercepted; + // the synthetic group cannot target external or unowned processes. + const pid = 2_147_483_647; + Object.assign(child, { pid, exitCode: 0, signalCode: null }); + const host = Reflect.construct(EvalHost, [scratch, scratch]) as EvalHost; + Object.assign(host, { + server: child, + credentialPaths: { source, target, snapshot }, + }); + let killed = false; + let confirmations = 0; + const kill = spyOn(process, "kill").mockImplementation( + (targetPid, signal) => { + expect(targetPid).toBe(-pid); + if (signal === "SIGKILL") killed = true; + if (signal === 0 && killed) { + confirmations += 1; + if (afterKill !== "alive") { + throw Object.assign(new Error(afterKill), { + code: afterKill === "gone" ? "ESRCH" : "EPERM", + }); + } + } + return true; + }, + ); + let now = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => { + now += 10_000; + return now; + }); + try { + if (afterKill === "gone") { + await host.stop(); + await expect(readdir(scratch)).rejects.toThrow(); + } else { + await expect(host.stop()).rejects.toThrow( + "Could not confirm eval host process tree", + ); + expect(await readFile(source, "utf8")).toBe(snapshot); + expect(await readFile(target, "utf8")).toBe(rotated); + } + expect(killed).toBe(true); + expect(confirmations).toBeGreaterThan(0); + } finally { + clock.mockRestore(); + kill.mockRestore(); + await rm(scratch, { recursive: true, force: true }); + } + }); + } + + test("rejects pre-aborted host startup before reading setup options", async () => { + const controller = new AbortController(); + const reason = new CampaignCancelled(143); + controller.abort(reason); + await expect( + EvalHost.start({ + get toolchain(): never { + throw new Error("must not start a host"); + }, + get packageCache(): never { + throw new Error("must not copy a cache"); + }, + opencodeVersion: "fixture", + files: {}, + signal: controller.signal, + }), + ).rejects.toBe(reason); + }); + + test("admits no queue or job for an initially aborted signal", async () => { + const controller = new AbortController(); + controller.abort(new CampaignCancelled(143)); + const started: number[] = []; + expect( + await runQueues( + [[1, 2], [3]], + 2, + async (job) => { + started.push(job); + return job; + }, + undefined, + controller.signal, + ), + ).toEqual([]); + expect(started).toEqual([]); + }); + + test("drains two queues but never admits their third job after cancellation", async () => { + const controller = new AbortController(); + const stopped = new CampaignCancelled(130); + const cancel = Promise.withResolvers(); + const cleanup = Promise.withResolvers(); + const started: string[] = []; + let cleaned = false; + const running = runQueues( + [["a1", "a2", "a3"], ["b1", "b2", "b3"], ["c1"]], + 2, + async (job) => { + started.push(job); + if (job.endsWith("1")) return job; + if (job === "a2") { + await cancel.promise; + await cleanup.promise; + cleaned = true; + throw stopped; + } + controller.abort(stopped); + cancel.resolve(); + return job; + }, + undefined, + controller.signal, + ); + let settled = false; + void running.then(() => { + settled = true; + }); + await cancel.promise; + await Bun.sleep(1); + expect(settled).toBe(false); + cleanup.resolve(); + expect((await running).sort()).toEqual(["a1", "b1", "b2"]); + expect(cleaned).toBe(true); + expect(started).toEqual(["a1", "b1", "a2", "b2"]); + }); + + test("does not swallow unrelated cancellation or an in-flight real rejection", async () => { + const unrelated = new CampaignCancelled(130); + await expect( + runQueues([[1]], 1, async () => { + throw unrelated; + }), + ).rejects.toBe(unrelated); + const controller = new AbortController(); + const own = new CampaignCancelled(143); + const ready = Promise.withResolvers(); + const failed = new Error("credential persistence failed"); + await expect( + runQueues( + [[1], [2]], + 2, + async (job) => { + if (job === 1) { + await ready.promise; + throw own; + } + controller.abort(own); + ready.resolve(); + await Bun.sleep(1); + throw failed; + }, + undefined, + controller.signal, + ), + ).rejects.toBe(failed); + const different = new AbortController(); + await expect( + runQueues( + [[1]], + 1, + async () => { + different.abort(own); + throw unrelated; + }, + undefined, + different.signal, + ), + ).rejects.toBe(unrelated); + }); + + test("does not send a session POST when already cancelled", async () => { + const controller = new AbortController(); + const reason = new CampaignCancelled(143); + controller.abort(reason); + let posted = false; + await expect( + runSessionRequest({ + signal: controller.signal, + post: async () => { + posted = true; + }, + url: "http://fixture/session/id/command", + body: {}, + onRejected: () => {}, + wait: async () => "quiet", + }), + ).rejects.toBe(reason); + expect(posted).toBe(false); + }); + + test("cancels a never-settling POST and waits for session abort cleanup", async () => { + const controller = new AbortController(); + const reason = new CampaignCancelled(130); + const posted = Promise.withResolvers(); + const cleanup = Promise.withResolvers(); + let requestSignal: AbortSignal | undefined; + const rejected: string[] = []; + const running = runSessionRequest({ + signal: controller.signal, + post: (_url, _body, options) => { + requestSignal = options.signal; + posted.resolve(); + return new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => + reject(options.signal.reason), + ); + }); + }, + url: "http://fixture/session/id/command", + body: {}, + onRejected: (message) => rejected.push(message), + wait: () => new Promise(() => {}), + onCancelled: () => cleanup.promise, + }); + let settled = false; + const observed = running.catch((error) => { + settled = true; + return error; + }); + await posted.promise; + controller.abort(reason); + await Bun.sleep(1); + expect(requestSignal?.aborted).toBe(true); + expect(settled).toBe(false); + cleanup.resolve(); + expect(await observed).toBe(reason); + expect(rejected).toEqual([]); + }); + + for (const accepted of [false, true]) { + test(`host cancels ${accepted ? "accepted" : "pending"} commands without cancelling outcome reads`, async () => { + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-cancel-test-")); + const controller = new AbortController(); + const reason = new CampaignCancelled(130); + // Construct a transport-only host: no executable or credential store is used. + const host = Reflect.construct(EvalHost, [ + scratch, + scratch, + controller.signal, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + const posted = Promise.withResolvers(); + let postSignal: AbortSignal | null | undefined; + let aborts = 0; + const requests = mockFetch(async (input, init) => { + const url = String(input); + if (url.endsWith("/command")) { + postSignal = init?.signal; + posted.resolve(); + return accepted ? Response.json({}) : new Promise(() => {}); + } + expect(init?.signal?.aborted).not.toBe(true); + if (url.endsWith("/abort")) { + aborts += 1; + return Response.json(true); + } + return Response.json([]); + }); + try { + const running = host.runCommand( + "id", + "flow-auto", + "fixture", + "fixture/model", + ); + const observed = running.catch((error) => error); + await posted.promise; + await Bun.sleep(1); + controller.abort(reason); + expect(await observed).toBe(reason); + expect(postSignal?.aborted).toBe(true); + expect(aborts).toBe(1); + expect(host.log).not.toContain("POST rejected"); + expect((await host.outcome(["id"], 1)).providerError).toBeNull(); + const first = host.stop(); + expect(host.stop()).toBe(first); + await first; + } finally { + requests.mockRestore(); + await host.stop(); + } + }); + } + + test("bounds a non-cooperative host abort request", async () => { + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-abort-test-")); + const controller = new AbortController(); + const reason = new CampaignCancelled(143); + const host = Reflect.construct(EvalHost, [ + scratch, + scratch, + controller.signal, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + let abortSignal: AbortSignal | null | undefined; + const requests = mockFetch(async (input, init) => { + if (String(input).endsWith("/abort")) { + abortSignal = init?.signal; + return new Promise(() => {}); + } + controller.abort(reason); + return Response.json({}); + }); + try { + await expect( + host.runCommand("id", "flow-auto", "fixture", "fixture/model"), + ).rejects.toBe(reason); + expect(abortSignal?.aborted).toBe(true); + } finally { + requests.mockRestore(); + await host.stop(); + } + }, 10_000); + + test("shares failed stop cleanup and retains rotated credentials in scratch", async () => { + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-retain-test-")); + const target = join(scratch, "auth.json"); + const rotated = JSON.stringify({ fixture: { refresh: "fake-rotated" } }); + await writeFile(target, rotated); + const host = Reflect.construct(EvalHost, [scratch, scratch]) as EvalHost; + Object.assign(host, { + credentialPaths: { + source: join(scratch, "missing-parent", "auth.json"), + target, + snapshot: null, + }, + }); + const complaints = spyOn(console, "error").mockImplementation(() => {}); + try { + const first = host.stop(); + expect(host.stop()).toBe(first); + await expect(first).rejects.toThrow(); + expect(host.stop()).toBe(first); + expect(await readFile(target, "utf8")).toBe(rotated); + expect(complaints).toHaveBeenCalledTimes(1); + } finally { + complaints.mockRestore(); + await rm(scratch, { recursive: true, force: true }); + } + }); + + test("syncs rotated fixture credentials before removing scratch exactly once", async () => { + const fixture = await mkdtemp(join(tmpdir(), "flow-eval-stop-test-")); + const scratch = join(fixture, "scratch"); + await mkdir(scratch); + const source = join(fixture, "auth.json"); + const target = join(scratch, "auth.json"); + const snapshot = JSON.stringify({ fixture: { refresh: "fake-before" } }); + const rotated = { fixture: { refresh: "fake-after" } }; + await writeFile(source, snapshot); + await writeFile(target, JSON.stringify(rotated)); + const host = Reflect.construct(EvalHost, [scratch, scratch]) as EvalHost; + Object.assign(host, { credentialPaths: { source, target, snapshot } }); + try { + const first = host.stop(); + expect(host.stop()).toBe(first); + await first; + expect(JSON.parse(await readFile(source, "utf8"))).toEqual(rotated); + await expect(readdir(scratch)).rejects.toThrow(); + } finally { + await rm(fixture, { recursive: true, force: true }); + } + }); + + test.each(["not-json-sensitive-fixture", "[]"])( + "retains invalid child credentials without echoing their contents: %s", + async (contents) => { + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-invalid-auth-")); + const target = join(scratch, "child.json"); + const source = join(scratch, "original.json"); + await writeFile(target, contents); + await writeFile(source, '{"fixture":"original"}'); + const host = Reflect.construct(EvalHost, [scratch, scratch]) as EvalHost; + Object.assign(host, { + credentialPaths: { source, target, snapshot: null }, + }); + try { + await expect(host.stop()).rejects.toThrow( + `Eval host credentials are invalid; retained at ${target}.`, + ); + expect(await readFile(target, "utf8")).toBe(contents); + expect(await readFile(source, "utf8")).toBe('{"fixture":"original"}'); + } finally { + await rm(scratch, { recursive: true, force: true }); + } + }, + ); + + test("does not replace a cancellation cleanup error with the stop reason", async () => { + const controller = new AbortController(); + const reason = new CampaignCancelled(130); + const failure = new Error("cleanup persistence failed"); + await expect( + runSessionRequest({ + signal: controller.signal, + post: async () => { + controller.abort(reason); + }, + url: "http://fixture/session/id/command", + body: {}, + onRejected: () => {}, + wait: async () => "quiet", + onCancelled: async () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + }); + + for (const operation of ["catalog", "session", "probe"] as const) { + test(`interrupts a never-settling ${operation} request with the exact cancellation`, async () => { + const scratch = await mkdtemp(join(tmpdir(), "flow-eval-request-test-")); + const controller = new AbortController(); + const reason = new CampaignCancelled(143); + const host = Reflect.construct(EvalHost, [ + scratch, + scratch, + controller.signal, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + let requestSignal: AbortSignal | null | undefined; + let aborts = 0; + const requests = mockFetch(async (input, init) => { + const url = String(input); + if (url.endsWith("/abort")) { + aborts += 1; + expect(init?.signal?.aborted).toBe(false); + return Response.json(true); + } + if (init?.method === "DELETE") return Response.json(true); + if (operation === "probe" && url.endsWith("/session")) + return Response.json({ id: "id" }); + requestSignal = init?.signal; + queueMicrotask(() => controller.abort(reason)); + return new Promise(() => {}); + }); + try { + const request = + operation === "catalog" + ? host.catalogModels() + : operation === "session" + ? host.createSession("fixture") + : host.probeModel("fixture/model"); + await expect(request).rejects.toBe(reason); + expect(requestSignal?.aborted).toBe(true); + expect(aborts).toBe(operation === "probe" ? 1 : 0); + } finally { + requests.mockRestore(); + await host.stop(); + } + }); + } + + for (const phase of ["health", "cache", "cache-failure"] as const) { + test(`cleans startup scratch and temporary credentials on ${phase}`, async () => { + const fixture = await mkdtemp(join(tmpdir(), "flow-eval-start-test-")); + const source = join(fixture, "opencode", "auth.json"); + await mkdir(join(fixture, "opencode")); + const credentials = JSON.stringify({ + fixture: { refresh: "fake-original" }, + }); + await writeFile(source, credentials); + const oldData = process.env.XDG_DATA_HOME; + const oldOptOut = process.env.FLOW_EVAL_NO_AUTH_COPY; + process.env.XDG_DATA_HOME = fixture; + delete process.env.FLOW_EVAL_NO_AUTH_COPY; + const controller = new AbortController(); + const reason = new CampaignCancelled(130); + let scratch = ""; + let healthSignal: AbortSignal | null | undefined; + const stop = EvalHost.prototype.stop; + const stopping = spyOn(EvalHost.prototype, "stop").mockImplementation( + function (this: EvalHost) { + scratch = join(this.project, ".."); + return stop.call(this); + }, + ); + const requests = mockFetch(async (_input, init) => { + healthSignal = init?.signal; + queueMicrotask(() => controller.abort(reason)); + return new Promise(() => {}); + }); + try { + const starting = EvalHost.start({ + toolchain: { + executable: process.platform === "win32" ? "cmd.exe" : "/bin/sh", + actualVersion: "fixture", + expectedVersion: "fixture", + environment: {}, + }, + get packageCache() { + if (phase === "cache") + queueMicrotask(() => controller.abort(reason)); + if (phase === "cache-failure") + return join(fixture, "missing-cache"); + return join(fixture, "opencode"); + }, + opencodeVersion: "fixture", + files: { "fixture.txt": "no paid calls" }, + signal: controller.signal, + }); + if (phase === "cache-failure") await expect(starting).rejects.toThrow(); + else await expect(starting).rejects.toBe(reason); + expect(scratch).not.toBe(""); + await expect(readdir(scratch)).rejects.toThrow(); + expect(await readFile(source, "utf8")).toBe(credentials); + if (phase === "health") expect(healthSignal?.aborted).toBe(true); + else expect(requests).not.toHaveBeenCalled(); + } finally { + requests.mockRestore(); + stopping.mockRestore(); + if (oldData === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = oldData; + if (oldOptOut === undefined) delete process.env.FLOW_EVAL_NO_AUTH_COPY; + else process.env.FLOW_EVAL_NO_AUTH_COPY = oldOptOut; + await rm(fixture, { recursive: true, force: true }); + } + }); + } +}); + describe("eval actor and instruction observations", () => { const assistant = (info: Record) => ({ info: { role: "assistant", time: { created: 1, completed: 2 }, ...info }, @@ -1344,8 +2162,8 @@ describe("eval credential sync-back", () => { }); test("keeps running later jobs after one of them throws", async () => { - // A sync that fails is a warning, not the end of the run — and must not take - // every host still to finish down with it. + // A failed sync must not prevent other already-started hosts from completing + // their own credential cleanup. const queue = sequencer(); const ran: string[] = []; const failed = queue(async () => { diff --git a/tests/eval-reserve-cancellation.test.ts b/tests/eval-reserve-cancellation.test.ts new file mode 100644 index 00000000..c37e1361 --- /dev/null +++ b/tests/eval-reserve-cancellation.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deriveReleaseDecision } from "../evals/analysis.js"; +import { deriveEnvironmentReserveState } from "../evals/environment-reserves.js"; +import { + deriveRetainedFailure, + RetainedScenarioEvidenceSchema, +} from "../evals/grader-input.js"; +import { releaseCatalog } from "../evals/release-policy.js"; +import { EvalReportV2Schema, parseReport } from "../evals/report.js"; + +const readJson = async (path: string) => + JSON.parse(await readFile(path, "utf8")); +function required(value: T | null | undefined): T { + if (value === undefined || value === null) + throw new Error("Expected retained fixture evidence is missing."); + return value; +} + +async function runFixture( + root: string, + mode: "handoff" | "reserve", + signal: "SIGINT" | "SIGTERM", +) { + const child = spawn( + process.execPath, + [ + join(import.meta.dir, "fixtures", "eval-reserve-cancellation-child.ts"), + root, + mode, + ], + { + cwd: root, + // Never forward provider credentials, reviewer overrides or global auth. + env: { + PATH: process.env.PATH, + HOME: root, + XDG_CONFIG_HOME: join(root, "config"), + XDG_DATA_HOME: join(root, "data"), + XDG_CACHE_HOME: join(root, "cache"), + FLOW_EVAL_NO_AUTH_COPY: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const events: string[] = []; + let stdout = ""; + let stderr = ""; + let pending = ""; + let expired = false; + const second = signal === "SIGINT" ? "SIGTERM" : "SIGINT"; + // Only handshakes deliver signals; this timer detects hangs, not readiness. + const timeout = setTimeout(() => { + expired = true; + child.kill("SIGKILL"); + }, 20_000); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (data: string) => { + stderr += data; + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (data: string) => { + stdout += data; + pending += data; + let newline = pending.indexOf("\n"); + while (newline !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (line.startsWith("@@eval-reserve:")) { + const name = line.slice("@@eval-reserve:".length); + events.push(name); + if (name === "ready") child.kill(signal); + if (name === "cleanup-wait") child.kill(second); + if (name === `signal:${second}`) child.stdin.end("release\n"); + } + newline = pending.indexOf("\n"); + } + }); + try { + const status = await new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, exitSignal) => + resolve({ code, signal: exitSignal }), + ); + }); + if (expired) throw new Error(`Reserve fixture hung.\n${stdout}\n${stderr}`); + return { ...status, events, stderr }; + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + } +} + +// The canonical host is exercised unchanged on Linux; Darwin only bypasses its +// platform assertion inside the isolated fake-host child. POSIX signals only. +const signalTest = ["linux", "darwin"].includes(process.platform) + ? test + : test.skip; +describe("graceful-eval-stop.R10-05: real release runner reserve cancellation", () => { + for (const signal of ["SIGINT", "SIGTERM"] as const) { + for (const mode of ["handoff", "reserve"] as const) { + const label = + mode === "handoff" + ? "durable final-primary cleanup prevents reserve handoff" + : "second reserve preserves first and skips third"; + signalTest( + `${signal}: ${label}`, + async () => { + const root = await mkdtemp(join(tmpdir(), "flow-eval-reserve-stop-")); + try { + const result = await runFixture(root, mode, signal); + expect(result.stderr).toBe(""); + expect(result.code).toBe(signal === "SIGINT" ? 130 : 143); + expect(result.signal).toBeNull(); + expect(result.events).not.toContain("unexpected-network"); + expect(result.events).toContain("returned"); + const retained = mode === "handoff" ? 76 : 77; + const started = mode === "handoff" ? 76 : 78; + expect(result.events).toContain(`durable:${retained}`); + expect( + result.events.filter((event) => event.startsWith("signal:")), + ).toEqual([ + `signal:${signal}`, + `signal:${signal === "SIGINT" ? "SIGTERM" : "SIGINT"}`, + ]); + for (const phase of ["start", "stop", "cleaned"]) + expect( + result.events.filter((event) => event.startsWith(`${phase}:`)), + ).toEqual( + Array.from( + { length: started + 1 }, + (_, index) => `${phase}:${index}`, + ), + ); + expect( + result.events.filter((event) => event.startsWith("outcome:")), + ).toEqual( + Array.from( + { length: retained }, + (_, index) => `outcome:${index + 1}`, + ), + ); + const results = join(root, "evals", "results"); + const names = await readdir(results); + const reports = names.filter((name) => name.endsWith(".v2")); + expect(reports).toHaveLength(1); + const directory = join(results, required(reports[0])); + expect(await readJson(join(directory, "catalog.json"))).toEqual( + releaseCatalog(), + ); + const parsed = parseReport( + await readJson(join(directory, "report.json")), + releaseCatalog(), + ); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.issues)); + const report = parsed.value; + const primary = report.plan.cells.filter( + (cell) => cell.schedule === "primary", + ); + const reserves = report.plan.cells.filter( + (cell) => cell.schedule === "environment-reserve", + ); + expect(primary).toHaveLength(76); + expect(reserves).toHaveLength(16); + expect(report.attempts.map((attempt) => attempt.cellId)).toEqual([ + ...primary.map((cell) => cell.cellId), + ...(mode === "reserve" ? [required(reserves[0]).cellId] : []), + ]); + expect(report.completion).toMatchObject({ + status: "stopped", + cause: "operator", + observed: { attempts: retained, outputTokens: 0, costUsd: 0 }, + }); + const mutable = EvalReportV2Schema.parse(report); + const state = deriveEnvironmentReserveState( + mutable.plan, + mutable.attempts, + ); + expect(state).toMatchObject({ + activatedReserveCellIds: reserves + .slice(0, 3) + .map((cell) => cell.cellId), + nextReserveCellIds: reserves + .slice(mode === "handoff" ? 0 : 1, 3) + .map((cell) => cell.cellId), + targetsSatisfied: false, + fatal: false, + exhaustedStrata: [], + }); + expect(report.completion.activatedReserveCellIds).toEqual( + state.activatedReserveCellIds, + ); + for (const folder of ["attempts", "transcripts"]) + expect(await readdir(join(directory, folder))).toHaveLength( + retained, + ); + const gaps = report.attempts.filter( + (attempt) => attempt.outcome.kind === "failure", + ); + expect(gaps.map((attempt) => attempt.cellId)).toEqual( + [0, 3, 6].map((index) => required(primary[index]).cellId), + ); + for (const attempt of report.attempts) { + const evidence = RetainedScenarioEvidenceSchema.parse( + await readJson( + join(directory, required(attempt.transcript).artifact), + ), + ); + if (attempt.outcome.kind === "failure") { + expect(attempt.outcome).toEqual({ + kind: "failure", + origin: "provider", + code: "provider-rejected-turn", + retryable: true, + }); + expect(deriveRetainedFailure(evidence)).toEqual( + evidence.failure, + ); + expect(evidence.failureObservation?.kind).toBe( + "provider-error", + ); + } else { + expect(evidence.failure).toBeNull(); + expect(attempt.outcome).toMatchObject({ + kind: "product", + passed: true, + issues: [], + }); + } + } + const legacyFiles = names.filter((name) => name.endsWith(".json")); + expect(legacyFiles).toHaveLength(1); + const legacy = await readJson( + join(results, required(legacyFiles[0])), + ); + expect(legacy.summary).toMatchObject({ + total: retained, + scored: retained - 3, + passed: retained - 3, + environmentBlocked: 3, + aborted: 0, + }); + expect(legacy.results).toHaveLength(retained); + const first = required(report.attempts[0]); + if ("kind" in first.artifact) + throw new Error("Expected packed artifact identity."); + const decision = deriveReleaseDecision({ + report, + catalog: releaseCatalog(), + expected: { + kind: "release", + artifact: first.artifact, + evaluator: first.evaluator, + attempts: report.attempts.map((attempt) => ({ + cellId: attempt.cellId, + hostConfigSha256: attempt.hostConfigSha256, + instructions: attempt.instructions, + actors: attempt.actors.map((actor) => ({ + role: actor.role, + requestedModel: actor.requestedModel, + actualModel: { + kind: "allow-unobserved" as const, + value: actor.requestedModel, + reason: "Explicitly fake host; cancellation test only.", + }, + })), + })), + }, + promotionArtifact: first.artifact, + }); + expect(decision.verdict).not.toBe("VERIFIED"); + expect( + decision.reasons.some( + (reason) => reason.code === "campaign-stopped", + ), + ).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 25_000, + ); + } + } +}); diff --git a/tests/fixtures/eval-cancellation-child.ts b/tests/fixtures/eval-cancellation-child.ts new file mode 100644 index 00000000..61ff83da --- /dev/null +++ b/tests/fixtures/eval-cancellation-child.ts @@ -0,0 +1,465 @@ +// Only this subprocess replaces paid/external boundaries. The scheduler, scenario +// checks, retained evidence, cassette recorder and report store stay real. +import { mock } from "bun:test"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withCampaignSignals } from "../../evals/campaign-stop.js"; +import { providerFailure } from "../../evals/failure-origin.js"; +import type { CommandEnd, EvalHost, Outcome } from "../../evals/harness.js"; + +const [root, mode] = process.argv.slice(2); +if (!root || !mode) + throw new Error("Expected temporary root and fixture mode."); +const repositoryRoot = root; +const models = ["fixture/model-a", "fixture/model-b"]; +let elapsedOffset = 0; +if (mode === "budget-stop") { + const RealDate = Date; + class ControlledDate extends RealDate { + constructor(value?: string | number) { + super(value ?? RealDate.now() + elapsedOffset); + } + static override now() { + return RealDate.now() + elapsedOffset; + } + } + globalThis.Date = ControlledDate as unknown as DateConstructor; +} +const event = (name: string) => + process.stdout.write(`\n@@eval-cancellation:${name}\n`); +// A pending Promise / signal listener alone need not keep Bun's event loop alive. +// The parent owns this pipe and writes only after the cleanup handshake. +process.stdin.resume(); + +// Defence in depth: no inherited credentials, real host, package installation, +// artifact inspection subprocess, or fetch is needed by this fixture. +globalThis.fetch = Object.assign( + () => { + event("unexpected-network"); + throw new Error("Network is forbidden in the cancellation fixture."); + }, + { preconnect: fetch.preconnect }, +); +const realHarness = { ...(await import("../../evals/harness.js")) }; +const realProvenance = { ...(await import("../../evals/provenance.js")) }; +const realStore = { ...(await import("../../evals/report-store.js")) }; +const realFS = { ...(await import("node:fs/promises")) }; +let publishing = false; +let signalledPublication = false; + +function interrupted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); +} + +async function stopHere(signal: AbortSignal): Promise { + const stopped = interrupted(signal); + event("ready"); // Abort listener installed before the parent sends a signal. + return stopped; +} + +async function cleanupGate(): Promise { + const release = new Promise((resolve) => { + process.stdin.once("data", () => resolve()); + }); + event("cleanup-wait"); + await release; +} + +async function publicationGate(): Promise { + if (signalledPublication) return; + signalledPublication = true; + const signal = new Promise((resolve) => { + process.once("SIGINT", () => resolve()); + process.once("SIGTERM", () => resolve()); + }); + event("ready"); + await signal; + await cleanupGate(); +} + +function plannedOutcome(sessionId: string): Outcome { + const flowCalls = ["flow_guidance", "flow_plan_save"].map((tool) => ({ + tool, + status: "completed" as const, + sessionIndex: 0, + agent: "build", + input: tool === "flow_guidance" ? { id: "flow-plan" } : {}, + output: null, + rawOutput: "", + metadata: {}, + })); + return { + flowCalls, + allCalls: flowCalls, + actors: [ + { + role: "manager", + sessionIds: [sessionId], + actualModel: { kind: "unobserved", reason: "field-unavailable" }, + }, + ], + guidanceLoads: [], + // Same narrowed Session v5 shape used by eval-scenario-checks.test.ts. + session: { + version: 5, + goal: "Add farewell(name) to src/greet.ts.", + approval: "pending", + plan: { + features: [{ id: "farewell", title: "Add farewell" }], + evidence: [{ scope: "gate", command: "bun test" }], + }, + runs: [], + closure: null, + }, + archives: [], + finalText: "Fixture plan saved; awaiting approval.", + tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, + costUsd: 0, + assistantMessages: 1, + durationMs: 1, + providerError: null, + }; +} + +let attemptsStarted = 0; +class FakeEvalHost { + readonly project = join(repositoryRoot, "fake-project"); + readonly signal: AbortSignal; + readonly attempt: number; + private pollHost: EvalHost | null = null; + private restoreFetch: (() => void) | null = null; + constructor(signal: AbortSignal, attempt: number) { + this.signal = signal; + this.attempt = attempt; + } + + static async start(options: { + files: Readonly>; + signal?: AbortSignal; + }): Promise { + if (!options.signal) throw new Error("Runner did not pass its signal."); + const preflight = options.files["package.json"]?.includes('"preflight"'); + const host = new FakeEvalHost( + options.signal, + preflight ? 0 : ++attemptsStarted, + ); + event(`start:${host.attempt}`); + if ( + ((mode === "host" || mode === "host-cleanup-failure") && + host.attempt === 2) || + (mode === "preflight-host-cleanup-failure" && preflight) + ) { + try { + await stopHere(host.signal); + } finally { + // Startup owns its partially constructed host, just like EvalHost.start. + await host.stop(); + } + } + return host; + } + + async catalogModels(): Promise { + return models; + } + async probeModel(model: string): Promise { + event(`probe:${model}`); + if (mode === "preflight") await stopHere(this.signal); + return null; + } + async createSession(): Promise { + event(`session:${this.attempt}`); + return `fixture-session-${this.attempt}`; + } + async runCommand( + sessionId: string, + command: string, + args: string, + model: string, + ): Promise { + event(`command:${this.attempt}`); + if (mode?.startsWith("provider-poll-") && this.attempt === 2) { + this.pollHost = Reflect.construct(realHarness.EvalHost, [ + this.project, + this.project, + this.signal, + ]) as EvalHost; + Object.assign(this.pollHost, { baseUrl: "http://fixture" }); + const originalFetch = globalThis.fetch; + this.restoreFetch = () => { + globalThis.fetch = originalFetch; + }; + globalThis.fetch = Object.assign( + async (input: string | URL | Request) => { + const url = String(input); + if (!url.startsWith("http://fixture/")) + throw new Error("Unexpected fixture request"); + if (url.endsWith("/command")) return Response.json({}); + if (url.endsWith("/message")) { + event("actual-progress-poll"); + return Response.json([ + { + info: { + id: "message", + role: "assistant", + sessionID: sessionId, + time: { created: 1, completed: 2 }, + error: { + name: "FixtureProviderUnavailable", + data: { message: "Fake polled provider failure" }, + }, + }, + parts: [], + }, + ]); + } + if (url.endsWith("/abort")) { + event("actual-provider-abort"); + if (mode === "provider-poll-stop") return stopHere(this.signal); + return Response.json(true); + } + event("unexpected-enrichment"); + throw new Error( + "Polled provider evidence must not depend on another host read", + ); + }, + { preconnect: originalFetch.preconnect }, + ); + return this.pollHost.runCommand(sessionId, command, args, model); + } + if (mode === "budget-stop" && this.attempt === 2) + elapsedOffset = 24 * 60 * 60_000; + if ( + this.attempt === 2 && + mode !== "complete" && + mode !== "provider-outcome-stop" && + mode !== "provider-transcript-stop" + ) + await stopHere(this.signal); + return "quiet"; + } + async outcome(sessionIds: string[]): Promise { + event(`outcome:${this.attempt}`); + if (this.pollHost) { + if (mode === "provider-poll-outcome-stop") + await stopHere(this.signal).catch((error) => { + if (error !== this.signal.reason) throw error; + }); + return this.pollHost.outcome(sessionIds, 1, this.signal); + } + if (mode === "provider-transcript-stop" && this.attempt === 2) { + const host = Reflect.construct(realHarness.EvalHost, [ + this.project, + this.project, + ]) as EvalHost; + Object.assign(host, { baseUrl: "http://fixture" }); + const parent = sessionIds[0] ?? "id"; + const originalFetch = globalThis.fetch; + globalThis.fetch = Object.assign( + async (input: string | URL | Request) => { + const url = String(input); + if (!url.startsWith("http://fixture/")) + throw new Error("Unexpected fixture request"); + if (url.endsWith(`/session/${parent}/children`)) + return Response.json([{ id: "reviewer", parentID: parent }]); + if (url.endsWith("/children")) return Response.json([]); + if (url.endsWith(`/session/${parent}/message`)) + return Response.json([ + { + info: { + id: "message", + role: "assistant", + sessionID: parent, + time: { created: 1, completed: 2 }, + error: { + name: "FixtureProviderUnavailable", + data: { message: "Fake earlier provider failure" }, + }, + }, + parts: [], + }, + ]); + if (url.endsWith("/session/reviewer/message")) + return stopHere(this.signal); + throw new Error("Unexpected fixture endpoint"); + }, + { preconnect: originalFetch.preconnect }, + ); + try { + return await host.outcome(sessionIds, 1, this.signal); + } finally { + globalThis.fetch = originalFetch; + } + } + if (mode === "provider-outcome-stop" && this.attempt === 2) { + const observation = { + sessionId: sessionIds[0] ?? "id", + name: "FixtureProviderUnavailable", + message: "Fake observed provider failure", + }; + const failure = providerFailure(observation.message); + await stopHere(this.signal).catch((error) => { + if (error !== this.signal.reason) throw error; + }); + return { + ...plannedOutcome(observation.sessionId), + providerError: failure, + providerErrorObservation: observation, + }; + } + return plannedOutcome(sessionIds[0] ?? "fixture-session"); + } + async stop(): Promise { + event(`stop:${this.attempt}`); + if (this.pollHost) await this.pollHost.stop(); + this.restoreFetch?.(); + if (mode === "last-cleanup" && this.attempt === 1) { + const stopped = interrupted(this.signal).catch(() => {}); + event("ready"); + await stopped; + await cleanupGate(); + } else if (this.signal.aborted) { + await cleanupGate(); + if ( + mode === "cleanup-failure" || + mode === "host-cleanup-failure" || + mode === "preflight-host-cleanup-failure" + ) { + event("injected-cleanup-failure"); + throw new Error("Injected host cleanup failure."); + } + if (mode === "persistence-failure") { + const results = join(repositoryRoot, "evals", "results"); + const directory = (await readdir(results)).find((name) => + name.endsWith(".v2"), + ); + if (!directory) + throw new Error("Real report store was not initialized."); + // A real filesystem conflict, not a fake reportStore implementation. + await mkdir(join(results, directory, "completion.json")); + event("injected-persistence-failure"); + } + } + event(`cleaned:${this.attempt}`); + } +} + +mock.module("../../evals/harness.js", () => ({ + ...realHarness, + EvalHost: FakeEvalHost, + packPlugin: async (_repo: string, packDirectory: string) => { + const path = join(packDirectory, "explicitly-fake-artifact.tgz"); + await writeFile( + path, + "not a release artifact; cancellation fixture only\n", + ); + return path; + }, + preparePackageCache: async (_tarball: string, packDirectory: string) => + packDirectory, +})); +mock.module("../../evals/provenance.js", () => ({ + ...realProvenance, + // Fake external artifact boundary only; digesting and copying its bytes stay real. + inspectArtifact: async ({ tarballPath }: { tarballPath: string }) => ({ + packageVersion: "0.0.0-fixture", + sourceCommit: "explicitly-fake-cancellation-fixture", + sourceTreeSha256: `sha256:${"a".repeat(64)}`, + tarballSha256: await realProvenance.tarballSha256(tarballPath), + unpackedManifestSha256: `sha256:${"b".repeat(64)}`, + }), +})); + +mock.module("../../evals/report-store.js", () => ({ + ...realStore, + createReportStore: ( + options: Parameters[0], + ) => { + const store = realStore.createReportStore({ + ...options, + hooks: { + checkpoint: async (stage) => { + if ( + publishing && + mode === "finalize-write" && + stage === "after-file-sync" + ) + await publicationGate(); + }, + }, + }); + const finalize = store.finalize.bind(store); + store.finalize = async (input) => { + publishing = true; + if (mode === "finalize-read") await publicationGate(); + return finalize(input); + }; + return store; + }, +})); +mock.module("node:fs/promises", () => ({ + ...realFS, + writeFile: async (...args: Parameters) => { + const path = String(args[0]); + if ( + publishing && + ((mode === "legacy-write" && path.endsWith(".json")) || + (mode === "cassette-write" && path.includes(".cassettes"))) + ) + await publicationGate(); + return realFS.writeFile(...args); + }, +})); + +const { runCampaign } = await import("../../evals/run.js"); +const args = [ + "--model", + models[0] ?? "fixture/model-a", + ...(mode === "preflight" ? ["--model", models[1] ?? "fixture/model-b"] : []), + "--scenario", + "plan-only-stops", + "--repeat", + mode === "last-cleanup" || + mode === "complete" || + mode.endsWith("-write") || + mode === "finalize-read" + ? "1" + : "3", + "--concurrency", + "1", +]; +const observedInt = () => event("signal:SIGINT"); +const observedTerm = () => event("signal:SIGTERM"); +try { + process.exitCode = await withCampaignSignals( + async (signal, beginFinalization) => { + process.on("SIGINT", observedInt); + process.on("SIGTERM", observedTerm); + try { + const code = await runCampaign( + signal, + args, + repositoryRoot, + beginFinalization, + ); + event("returned"); + return code; + } finally { + process.removeListener("SIGINT", observedInt); + process.removeListener("SIGTERM", observedTerm); + } + }, + ); +} catch (error) { + event("error"); + console.error(error); + process.exitCode = 1; +} finally { + process.stdin.pause(); +} diff --git a/tests/fixtures/eval-reserve-cancellation-child.ts b/tests/fixtures/eval-reserve-cancellation-child.ts new file mode 100644 index 00000000..7e978a55 --- /dev/null +++ b/tests/fixtures/eval-reserve-cancellation-child.ts @@ -0,0 +1,232 @@ +// Explicitly fake host/artifact/scenario-check boundaries, subprocess-local only. +// Canonical release policy, scheduler, reserve derivation, retained failure +// derivation, cassettes, report store and runCampaign are NOT mocked. +import { mock } from "bun:test"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { withCampaignSignals } from "../../evals/campaign-stop.js"; +import { providerFailure } from "../../evals/failure-origin.js"; +import type { Outcome } from "../../evals/harness.js"; + +const [root, mode] = process.argv.slice(2); +if (!root || (mode !== "handoff" && mode !== "reserve")) + throw new Error("Expected temporary root and handoff/reserve mode."); +const repositoryRoot = root; +const models = ["fixture-a/model", "fixture-b/model"] as const; +const event = (name: string) => + process.stdout.write(`\n@@eval-reserve:${name}\n`); +process.stdin.resume(); // Keep the child alive until the parent releases cleanup. +globalThis.fetch = Object.assign( + () => { + event("unexpected-network"); + throw new Error("Network forbidden in the reserve cancellation fixture."); + }, + { preconnect: fetch.preconnect }, +); +const realHarness = { ...(await import("../../evals/harness.js")) }; +const realProvenance = { ...(await import("../../evals/provenance.js")) }; +const realPolicy = { ...(await import("../../evals/release-policy.js")) }; +const realScenarios = { ...(await import("../../evals/scenarios.js")) }; +// runCampaign hashes the real grader dependency closure relative to its output +// root. Copy those exact source bytes; do not substitute a fake grader bundle. +for (const { path, source } of realPolicy.releaseGraderSourceBundle( + join(import.meta.dir, "../.."), +).files) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, source); +} + +function interrupted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); +} + +async function stopHere(signal: AbortSignal, expectedDurable: number) { + const results = join(repositoryRoot, "evals", "results"); + const name = (await readdir(results)).find((entry) => entry.endsWith(".v2")); + if (!name) throw new Error("Real report store is missing."); + const count = (await readdir(join(results, name, "attempts"))).length; + if (count !== expectedDurable) + throw new Error( + `Expected ${expectedDurable} durable attempts, got ${count}.`, + ); + const stopped = interrupted(signal); + event(`durable:${count}`); + event("ready"); // Listener and durable-prefix assertion precede the OS signal. + return stopped; +} + +async function cleanupGate() { + const release = new Promise((resolve) => { + process.stdin.once("data", () => resolve()); + }); + event("cleanup-wait"); + await release; +} + +let started = 0; +class FakeReleaseHost { + readonly project = join(repositoryRoot, "explicitly-fake-project"); + readonly signal: AbortSignal; + readonly attempt: number; + constructor(signal: AbortSignal, attempt: number) { + this.signal = signal; + this.attempt = attempt; + } + static async start(options: { + files: Readonly>; + signal?: AbortSignal; + }) { + if (!options.signal) throw new Error("Runner omitted its signal."); + const preflight = options.files["package.json"]?.includes('"preflight"'); + const host = new FakeReleaseHost(options.signal, preflight ? 0 : ++started); + event(`start:${host.attempt}`); + return host; + } + async catalogModels() { + return models; + } + async probeModel() { + return null; + } + async createSession() { + return `fixture-session-${this.attempt}`; + } + async runCommand(): Promise<"quiet"> { + if (mode === "reserve" && this.attempt === 78) + await stopHere(this.signal, 77); + return "quiet"; + } + async outcome(sessionIds: string[]): Promise { + event(`outcome:${this.attempt}`); + // One eligible gap in each of the first three canonical case/provider + // strata: three reserves really activate, leaving a third queued behind + // the interrupted second reserve. No outcome/ledger retry flags are patched. + const gap = [1, 4, 7].includes(this.attempt); + const observation = gap + ? { + sessionId: sessionIds[0] ?? "missing-session", + name: "FixtureProviderUnavailable", + message: "Explicitly fake provider outage for reserve scheduling.", + } + : null; + return { + flowCalls: [], + allCalls: [], + actors: [ + { + role: "manager", + sessionIds, + actualModel: { kind: "unobserved", reason: "field-unavailable" }, + }, + ], + guidanceLoads: [], + session: null, + archives: [], + finalText: "Explicitly fake scenario success; not release evidence.", + tokens: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + costUsd: 0, + assistantMessages: 1, + durationMs: 1, + providerError: observation ? providerFailure(observation.message) : null, + providerErrorObservation: observation, + }; + } + async stop() { + event(`stop:${this.attempt}`); + if (mode === "handoff" && this.attempt === 76) + await stopHere(this.signal, 76).catch((error: unknown) => { + if (error !== this.signal.reason) throw error; + }); + if (this.signal.aborted) await cleanupGate(); + event(`cleaned:${this.attempt}`); + } +} + +mock.module("../../evals/harness.js", () => ({ + ...realHarness, + EvalHost: FakeReleaseHost, + packPlugin: async (_repo: string, directory: string) => { + const path = join(directory, "explicitly-fake-artifact.tgz"); + await writeFile( + path, + "Cancellation fixture only; not a release artifact.\n", + ); + return path; + }, + preparePackageCache: async (_tarball: string, directory: string) => directory, +})); +mock.module("../../evals/provenance.js", () => ({ + ...realProvenance, + inspectArtifact: async ({ tarballPath }: { tarballPath: string }) => ({ + packageVersion: "0.0.0-fixture", + sourceCommit: "explicitly-fake-reserve-cancellation", + sourceTreeSha256: `sha256:${"a".repeat(64)}`, + tarballSha256: await realProvenance.tarballSha256(tarballPath), + unpackedManifestSha256: `sha256:${"b".repeat(64)}`, + }), +})); +mock.module("../../evals/scenarios.js", () => ({ + ...realScenarios, + // Preserve canonical cases, order, files and steps. Only the product oracle + // accepts the fake host outcome; this test makes no product-quality claim. + SCENARIOS: realScenarios.SCENARIOS.map((scenario) => ({ + ...scenario, + check: () => [], + })), +})); +if (process.platform === "darwin") + mock.module("../../evals/release-policy.js", () => ({ + ...realPolicy, + assertReleaseHost: () => {}, // Local simulation, NOT Linux qualification. + })); + +const { runCampaign } = await import("../../evals/run.js"); +const observedInt = () => event("signal:SIGINT"); +const observedTerm = () => event("signal:SIGTERM"); +try { + process.exitCode = await withCampaignSignals( + async (signal, beginFinalization) => { + process.on("SIGINT", observedInt); + process.on("SIGTERM", observedTerm); + try { + const code = await runCampaign( + signal, + [ + "--release", + "--model", + models[0], + "--model", + models[1], + "--concurrency", + "1", + ], + repositoryRoot, + beginFinalization, + ); + event("returned"); + return code; + } finally { + process.removeListener("SIGINT", observedInt); + process.removeListener("SIGTERM", observedTerm); + } + }, + ); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + process.stdin.pause(); +} diff --git a/tests/release-qualification.test.ts b/tests/release-qualification.test.ts index c83fd562..f0c8f859 100644 --- a/tests/release-qualification.test.ts +++ b/tests/release-qualification.test.ts @@ -216,6 +216,87 @@ describe("repository-owned v2 qualification", () => { expect(inconclusive.decision.verdict).toBe("INCONCLUSIVE"); }); + test("refuses an operator stop with otherwise VERIFIED full-target evidence", () => { + const report = releaseReport(); + const verified = qualifyV2({ + reportInput: report, + catalogInput: releaseCatalog(), + artifact: ARTIFACT, + }); + expect(verified.decision.verdict).toBe("VERIFIED"); + expect(verified.decision.reasons).toEqual([]); + expect(verified.report.attempts).toHaveLength(76); + expect(verified.decision.totals).toEqual({ + scheduled: 76, + scored: 76, + passed: 76, + }); + + const stopped = qualifyV2({ + reportInput: { + ...report, + completion: { + ...report.completion, + status: "stopped", + cause: "operator", + }, + }, + catalogInput: releaseCatalog(), + artifact: ARTIFACT, + }); + expect(stopped.decision).toEqual({ + ...verified.decision, + verdict: "INCONCLUSIVE", + reasons: [ + { + severity: "gap", + code: "campaign-stopped", + message: + "Campaign stopped by the operator; release qualification requires a completed campaign.", + caseId: null, + caseVersion: null, + provider: null, + }, + ], + }); + }); + + test("R27-01 refuses full-target evidence after a budget stop", () => { + const report = releaseReport(); + expect( + qualifyV2({ + reportInput: report, + catalogInput: releaseCatalog(), + artifact: ARTIFACT, + }).decision.verdict, + ).toBe("VERIFIED"); + const first = report.attempts[0]; + if (!first) throw new Error("Missing qualifying fixture attempt"); + first.usage.outputTokens = report.plan.budget.maxOutputTokens + 1; + report.completion.observed.outputTokens = report.attempts.reduce( + (sum, attempt) => sum + attempt.usage.outputTokens, + 0, + ); + const stopped = qualifyV2({ + reportInput: { + ...report, + completion: { + ...report.completion, + status: "stopped", + cause: "budget", + }, + }, + catalogInput: releaseCatalog(), + artifact: ARTIFACT, + }); + expect(stopped.decision.verdict).not.toBe("VERIFIED"); + expect( + stopped.decision.reasons.some( + (reason) => reason.code === "campaign-stopped", + ), + ).toBe(true); + }); + test("qualifies with a retained host failure and its canonical passing reserve", () => { const report = releaseReport(true); const failed = report.attempts[0]; diff --git a/tests/triage-report.test.ts b/tests/triage-report.test.ts index 2d44ac6f..ff820f82 100644 --- a/tests/triage-report.test.ts +++ b/tests/triage-report.test.ts @@ -5,6 +5,10 @@ // that long is the same as no list. These pin the two exclusions that fixed it. import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { completionHonesty, reviewerActivity } from "../evals/metrics.js"; import { triage } from "../scripts/triage-report.js"; @@ -38,6 +42,49 @@ function flagged(runs: readonly Run[]): string[][] { } describe("eval report triage", () => { + test.each(["provider", "host", "evaluator"] as const)( + "R20-03 identifies %s failure without a product-failure label", + (origin) => { + expect( + flagged([ + run({ + passed: false, + failure: { + origin, + code: "fixture", + detail: "failed request", + retryable: false, + }, + }), + ]), + ).toEqual([[`${origin} failure (fixture): failed request`]]); + }, + ); + + test("R20-03 CLI shows operator stop even when retained runs have no flags", async () => { + const root = await mkdtemp(join(tmpdir(), "flow-triage-stop-")); + try { + const path = join(root, "report.json"); + await writeFile( + path, + JSON.stringify({ + results: [run()], + completion: { status: "stopped", cause: "operator" }, + }), + ); + const result = spawnSync( + process.execPath, + [join(import.meta.dir, "../scripts/triage-report.ts"), path], + { encoding: "utf8" }, + ); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Campaign: stopped (operator)"); + expect(result.stdout).toContain("Not release qualification"); + expect(result.stdout).not.toContain("Nothing flagged across"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); test("flags nothing about an ordinary passing run", () => { const { ranked, quiet } = triage({ results: [run()] }); expect(ranked).toEqual([]);