From b5b97cf90304cb8397ec1ed78ef9aafdd48a3d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:01:24 +0000 Subject: [PATCH 1/2] fix(gate,cache): assign every gate:matrix flag, and wire the cache write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #165. Closes #166. ## #165 — gate:matrix silently dropped four flags `parseArgs` held a `valued` set and `assignValue` held an `else if` chain, and the two disagreed: `--from-cache`, `--site-key`, `--task-key` and `--repair-model` were consumed by the parser, handed over, and fell off the end of the chain. No error — they were in `valued`, so they never tripped `unknown argument` either. Closed structurally rather than by adding four branches: one `VALUED_FLAGS` table maps each flag to its assigner, the parser derives its accepted set from those keys, and `assignValue` throws on a key it does not recognise instead of returning. A flag cannot now be accepted-but-unassigned. The test walks the table rather than naming the four that were broken, and a second test asserts the sample map covers every flag so a new one cannot slip past uncovered. **`--repair-model` was dropped twice, and the issue names only the first.** Assigning it is not sufficient: nothing in `run-matrix.ts` ever read `args.repairModel`, so `ReplayRunner` still defaulted to `StubRepairModelClient` and the run still reported a self-heal rate of 0 and a `cost_repair` of zero that both look measured. The flag now builds an `AnthropicRepairModelClient` and passes it to `runVersionLive`. Constructed before anything boots, so a missing `ANTHROPIC_API_KEY` is a named refusal rather than a stub run — the whole reason that client throws at construction. `--repair-model` under `--dry-run` is refused outright: no step fails, so the repair loop never runs and no token is ever spent. Any pre-existing artifact produced with `--repair-model` reported stub numbers. ## #166 — nothing populated the cache `writeCacheRow()` had no caller outside `tests/`. The read half landed with #118, so `gate:matrix --from-cache ` resolved programs out of a directory no shipped code path could fill, and `cacheHitRate()` could only report `no_data` for a reason unrelated to the experiment. `paragent compile --to-cache ` closes it, via `src/cache/ingest.ts`. The compiler CLI owns the write because the compiler is the only stage holding `steps_total` — the `ProgramRef` `resolveProgram` needs to prove completeness — and because a measurement harness that populated on first run would make its populating run differ from every run after it, inside one reported sample. All-or-nothing: the authority runs over the whole bundle before anything is persisted, so a rejected step cannot leave a resolvable-looking prefix on disk. `src/cache/` stays library-only and takes a structurally-typed bundle, so the dependency runs one way and the compiler still does not import the cache's types. Verified end to end: compile --to-cache -> `gate:matrix --from-cache` reports `cache HIT prog-traj-gate-live-... (12 steps)` and rows carry `program_source: cache`. ### Finding: the pre-check and the authority disagree, in the safe direction Now that the authority actually runs on real data, the two can be compared. On the committed 12-step live bundle the compiler marks 1 row poolable and `writeCacheRow` marks 7. One rule accounts for the gap: where a row's whole locator chain is tainted but the row carries `flow_topology`, `buildPoolRow` degrades it to a `topology_only` pool row carrying no locator at all, while `decidePoolEligibility` refuses it as `topology_only_degraded`. Legal (a pre-check may be stricter, never looser) and nothing tenant-derived escapes either way. Deliberately not reconciled here: changing it changes what every committed bundle artifact claims about pool eligibility, which wants its own ADR rather than a rider on a wiring fix. Reported by `--to-cache` on every run and recorded in docs/gate/compiler.md and docs/architecture.md. ## Tests - Table-driven flag coverage in `tests/unit/gate-matrix.test.ts`. - `tests/integration/cache-ingest-bundle.test.ts`: round trip to a complete resolveProgram HIT, rows on disk in both files, the authority overriding the pre-check, whole-bundle rejection leaving an empty directory, and the real committed 12-step bundle. - Canary suite unmodified and green, as #166 requires. Both guards were checked by reverting: reintroducing the accepted-but-unassigned flag shape fails 2 tests; dropping the two-pass write leaves a prefix on disk. npm run ci green (534 unit, 33 integration); npm run test:canary green (52). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016CAt2F7SR83iuqvnM4GzAf --- docs/architecture.md | 43 ++- docs/gate/compiler.md | 29 +- docs/gate/runner.md | 15 +- experiments/gate-v1/run-matrix.ts | 148 +++++++-- src/cache/index.ts | 6 + src/cache/ingest.ts | 195 ++++++++++++ src/compiler/cli.ts | 60 +++- tests/integration/cache-ingest-bundle.test.ts | 284 ++++++++++++++++++ tests/unit/gate-matrix.test.ts | 92 +++++- 9 files changed, 822 insertions(+), 50 deletions(-) create mode 100644 src/cache/ingest.ts create mode 100644 tests/integration/cache-ingest-bundle.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index c10f25e..5400a12 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ doc_type: spec status: draft owner: B0 created: 2026-07-25 -updated: 2026-08-12 +updated: 2026-08-16 confidence: HIGH supersedes: null sources_verified: true @@ -33,6 +33,8 @@ than resolved here. Solid edges are wired in code. **Dashed red edges are hops that do not exist yet** — the artifact on the left is never handed to the box on the right by any code path outside tests. +There are none left: the last one, bundle → cache, was drawn solid by +[#166](https://github.com/DevToolie/Paragent/issues/166). ```mermaid flowchart LR @@ -59,7 +61,7 @@ flowchart LR TRAJ -->|"trajectory.schema.json"| COMP COMP -->|"cache-row.schema.json + assertion.schema.json
wrapper has no $id"| BUNDLE - BUNDLE -.->|"NOT WIRED — see break 1"| CACHE + BUNDLE -->|"paragent compile --to-cache
src/cache/ingest.ts → writeCacheRow (#166)"| CACHE CACHE -->|"cache-row.schema.json"| POOL CACHE -->|"cache-row.schema.json"| TEN @@ -72,8 +74,6 @@ flowchart LR NDJSON -->|"PRD §9 aggregates, no_data-safe"| REPORT INTENT -->|"resolveTaskIntent() → task_key or MISS
src/recorder/select-task.ts (#124)"| REC - - linkStyle 4 stroke:#c00,stroke-width:2px ``` ### Reading the diagram @@ -87,12 +87,24 @@ synthesized assertion and writes a `compiled_trajectory` bundle to `artifacts/co **The two breaks.** Two hops the prose elsewhere implies are wired are not: -1. **The bundle never reaches the cache.** Nothing outside `src/cache/` and `tests/` imports - the cache package — verified by grepping every import in `src/` and `experiments/`. The - `pool_eligible` flag on a bundle row comes from the compiler's own pre-check - (`src/compiler/pool.ts`, `decidePoolEligibility`), *not* from the authoritative write-time - boundary (`src/cache/write.ts`, `writeCacheRow`). Both fail closed, and the compiler's own - doc calls itself a pre-check — but today nothing calls the authority. +1. ~~**The bundle never reaches the cache.**~~ **Closed by + [#166](https://github.com/DevToolie/Paragent/issues/166).** `writeCacheRow` had no caller + outside `tests/`: the read half landed with [#118](https://github.com/DevToolie/Paragent/issues/118) + (`gate:matrix --from-cache`), and the write half did not, so the flag reaching disk came from + the compiler's pre-check (`src/compiler/pool.ts`, `decidePoolEligibility`) while the authority + (`src/cache/write.ts`, `writeCacheRow`) was defended by the canary suite and called by nothing. + `paragent compile --to-cache ` now routes every row through the authority via + `src/cache/ingest.ts`, which is the directory `--from-cache` reads. + + **The two implementations disagree, and the pre-check is the stricter one.** On the committed + 12-step live bundle the compiler marks 1 row poolable and the authority marks 7: where a row's + whole locator chain is tainted but it carries `flow_topology`, `buildPoolRow` degrades it to a + `topology_only` pool row — carrying no locator at all — while `decidePoolEligibility` refuses + it outright as `topology_only_degraded`. That direction is legal (a pre-check may be stricter, + never looser) and the dangerous direction is pinned by + `tests/integration/live-bundle-pool.test.ts`. The pre-check is deliberately left as-is here: + changing it changes what every committed bundle artifact claims about pool eligibility, which + is a privacy-adjacent decision that wants its own ADR rather than a rider on a wiring fix. 2. ~~**The bundle never reaches the runner.**~~ **Closed by [#62](https://github.com/DevToolie/Paragent/issues/62).** The runner consumes `CompiledProgram` (`src/runner/types.ts`), a different shape from @@ -101,8 +113,9 @@ synthesized assertion and writes a `compiled_trajectory` bundle to `artifacts/co hand-written `experiments/gate-v1/fixtures/compiled-program.json` remains the default, because it is the only program that runs without a recording. -Issue [#52](https://github.com/DevToolie/Paragent/issues/52) (end-to-end integration test: -record → compile → cache-write → replay) is the issue that closes both. +Both are now closed, and the end-to-end path they blocked — +record → compile → cache-write → resolve → replay — is covered by +`tests/integration/cache-ingest-bundle.test.ts` and `tests/integration/cache-resolve-program.test.ts`. **A new entry point, ahead of the recorder.** `src/intent/` ([#124](https://github.com/DevToolie/Paragent/issues/124), [ADR-0015](./decisions/ADR-0015-task-identity-and-intent-resolution.md)) resolves a @@ -113,7 +126,7 @@ one a human had typed. `src/recorder/cli.ts` calls it through `--task-key`. **Not shown as an edge into `CACHE`:** the more obviously cache-shaped call site — resolving intent in front of `gate:matrix --from-cache`, which already looks up a program by `(site_key, task_key)` — is not wired yet (ADR-0015 Open Questions); drawing that edge now would -claim a hop that does not exist, the same reason break 1 above is dashed rather than solid. +claim a hop that does not exist — the standard break 1 above had to meet before it could be drawn solid. --- @@ -124,8 +137,8 @@ claim a hop that does not exist, the same reason break 1 above is dashed rather | `src/intent/` | Resolve a natural-language goal to a `task_key`, or a typed MISS — normalized exact match against a known-task catalog, behind a swappable `IntentMatcher` (#124) | `src/intent/index.ts` (library only — called from `src/recorder/select-task.ts`) | none | none — `task_key` is an opaque string handed to a caller, not a contract field this package owns | [decisions/ADR-0015](./decisions/ADR-0015-task-identity-and-intent-resolution.md) | | `src/testbed/` | Boot + seed Grafana OSS at a pinned tag: compose project, provisioning overlay, HTTP seed | `src/testbed/index.ts`; CLI `src/testbed/cli.ts` (`npm run testbed`) | `scripts/testbed/matrix.json` (not a JSON Schema) | none | [gate/testbed.md](./gate/testbed.md) | | `src/recorder/` | Capture a Playwright run as parameterised steps with ranked locator candidates; refuse literal secrets | `src/recorder/index.ts`; CLI `src/recorder/cli.ts` (`npm run recorder`) | none | `trajectory.schema.json` | [gate/recorder.md](./gate/recorder.md) | -| `src/compiler/` | One cache row per step: locator fallback chain, synthesized assertion, fail-closed `pool_eligible` pre-check | `src/compiler/index.ts`; CLI `src/compiler/cli.ts` (`npm run compile`) | `trajectory.schema.json` | `cache-row.schema.json`, `assertion.schema.json` | [gate/compiler.md](./gate/compiler.md) | -| `src/cache/` | Write-time privacy boundary: allowlist, locator taint, pool/tenant row split, canary pipeline; append-only JSONL store (#63) | `src/cache/index.ts` (library only — no CLI) | `cache-row.schema.json`, `assertion.schema.json` (inspected for pool safety) | `cache-row.schema.json` | [privacy/boundary-spec.md](./privacy/boundary-spec.md) | +| `src/compiler/` | One cache row per step: locator fallback chain, synthesized assertion, fail-closed `pool_eligible` pre-check | `src/compiler/index.ts`; CLI `src/compiler/cli.ts` (`npm run compile`, `--to-cache` to populate a cache) | `trajectory.schema.json` | `cache-row.schema.json`, `assertion.schema.json` | [gate/compiler.md](./gate/compiler.md) | +| `src/cache/` | Write-time privacy boundary: allowlist, locator taint, pool/tenant row split, canary pipeline; append-only JSONL store (#63); bundle ingest through the authority (#166) | `src/cache/index.ts` (library only — no CLI of its own; `paragent compile --to-cache` calls `ingestBundle`) | `cache-row.schema.json`, `assertion.schema.json` (inspected for pool safety) | `cache-row.schema.json` | [privacy/boundary-spec.md](./privacy/boundary-spec.md) | | `src/runner/` | Replay a compiled program in Playwright; repair actions only on failure; emit measured metrics | `src/runner/index.ts` (library only — driven by `experiments/gate-v1/run-matrix.ts`) | `cache-row.schema.json`, `assertion.schema.json` shapes (via `CompiledProgram`) | none directly — emits through `src/metrics/` | [gate/runner.md](./gate/runner.md) | | `src/metrics/` | Cost arithmetic, NDJSON emitter, PRD §9 aggregates that report `no_data` on an empty denominator | `src/metrics/index.ts` (library only) | `metrics.schema.json` (`readMetricNdjson`) | `metrics.schema.json` | §9 sections in [prd/PRD-trajectory-cache-v0.2.md](./prd/PRD-trajectory-cache-v0.2.md) | | `src/shared/` | **Not a pipeline stage.** In-page JS source strings two capture sites must run identically — today the `visible_landmarks` enumeration | `src/shared/index.ts` (library only) | none | none — feeds the `trajectory.schema.json` `visible_landmarks` field written by the recorder | see below | diff --git a/docs/gate/compiler.md b/docs/gate/compiler.md index e7d358c..8c447c0 100644 --- a/docs/gate/compiler.md +++ b/docs/gate/compiler.md @@ -4,7 +4,7 @@ doc_type: spec status: draft owner: B3 created: 2026-07-25 -updated: 2026-08-12 +updated: 2026-08-16 confidence: MED supersedes: null sources_verified: true @@ -260,14 +260,39 @@ Loosening a privacy rule is B5's call and does not belong in a compiler PR — f here, not fixed. Note the direction: B5 refusing too much is safe; the compiler claiming too much was not. +**Measured, once the authority actually ran (#166).** `paragent compile --to-cache` puts every +row through `writeCacheRow`, so the two implementations can now be compared on real data instead +of in principle. On the committed 12-step live bundle the compiler marks **1** row poolable and +B5 marks **7**. The gap is one rule, in the safe direction: when a row's whole locator chain is +tainted but the row carries `flow_topology`, `buildPoolRow` degrades it to a `topology_only` +pool row — a row carrying no locator at all, only "a click happened here, in `main`, between a +click and a fill" — whereas `decidePoolEligibility` refuses it outright as +`topology_only_degraded`. Nothing tenant-derived escapes either way; the pre-check simply +declines to pool a row B5 is willing to strip and pool. + +That divergence is **not** reconciled here, for the reason the paragraph above gives about the +URL path: it changes what every committed bundle artifact claims about pool eligibility, and the +`pool_eligible` flag in a bundle file is no longer what reaches disk anyway. The number to watch +is the one `--to-cache` prints (`authority pooled N step(s) the compiler pre-check did not`). + ## CLI ```bash npm run compile -- --in contracts/examples/trajectory.example.json # writes artifacts/compiled/.bundle.json + +# ...and populate the cache gate:matrix --from-cache reads (#166) +npm run compile -- --in --to-cache .cache/paragent ``` -Options: `--out `, `--no-validate`, `--help`. +Options: `--out `, `--to-cache `, `--no-validate`, `--help`. + +`--to-cache` writes every row through `writeCacheRow` — the authority, not this package's +pre-check — into ``, which is what `gate:matrix --from-cache --site-key +--task-key ` resolves a program out of. It is all-or-nothing: a bundle carrying a claim the +boundary refuses is rejected whole, so a rejected step cannot leave a resolvable-looking prefix +on disk. See [`src/cache/ingest.ts`](../../src/cache/ingest.ts) for why the compiler CLI owns +this write rather than `gate:matrix` or a separate `cache write` command. ## Known blind spots (cannot yet assert) diff --git a/docs/gate/runner.md b/docs/gate/runner.md index e393ce7..3d6369d 100644 --- a/docs/gate/runner.md +++ b/docs/gate/runner.md @@ -4,7 +4,7 @@ doc_type: spec status: draft owner: B4 created: 2026-07-24 -updated: 2026-08-11 +updated: 2026-08-16 confidence: MED supersedes: null sources_verified: true @@ -331,6 +331,19 @@ npm run gate:report network call unless asked. **Still unmeasured:** no live repair has been observed. The client is covered by 21 mocked-SDK tests; a self-heal rate remains structurally 0 until someone runs it with a real key, which is the exit criterion #27 names and this repo will not fabricate. + + **That opt-in did not actually work until + [#165](https://github.com/DevToolie/Paragent/issues/165).** `--repair-model` was accepted by + the parser, documented in `usage()`, and dropped twice over: `assignValue` had no branch for + it, and nothing in `run-matrix.ts` read `args.repairModel` to build a client. So a run asking + for the real model got `StubRepairModelClient` — a null proposal and zero tokens — and + reported a self-heal rate of 0 and a `cost_repair` of zero that both look measured. The client + refuses to degrade silently (it throws when `ANTHROPIC_API_KEY` is unset); the CLI in front of + it degraded anyway and never reached that constructor. Both halves are wired now, and the + construction happens before anything boots, so a missing key is a named refusal rather than a + stub run. **Any pre-#165 artifact produced with `--repair-model` reported stub numbers** — a + stub run has `cost_repair` all-zero on every row and no `repair_context_level`, so check the + rows rather than the command that was typed. - Whether `compiled_trajectory` bundle `$id` becomes a first-class contract (B3 packaging convention today). - Fresh-reasoning cost capture for `cost_fresh` — measured separately; defaults to zeros when unwired. Since [#123](https://github.com/DevToolie/Paragent/issues/123) this field means the diff --git a/experiments/gate-v1/run-matrix.ts b/experiments/gate-v1/run-matrix.ts index 1e05422..9805b0a 100644 --- a/experiments/gate-v1/run-matrix.ts +++ b/experiments/gate-v1/run-matrix.ts @@ -27,6 +27,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { MetricsEmitter, readMetricNdjson } from "../../src/metrics/emitter.js"; import { ReplayRunner } from "../../src/runner/replay.js"; +import type { RepairModelClient } from "../../src/runner/repair.js"; +import { AnthropicRepairModelClient } from "../../src/runner/repair-anthropic.js"; import { bundleToProgram, isCompiledBundle, rowsToProgram } from "../../src/runner/program.js"; import { JsonlCacheStore } from "../../src/cache/store.js"; import { resolveProgram } from "../../src/cache/resolve.js"; @@ -92,7 +94,7 @@ export const DRY_RUN_PARAMS: ParamBindings = { resource_label: "widget", }; -interface Args { +export interface Args { dryRun: boolean; help: boolean; headed: boolean; @@ -142,6 +144,69 @@ interface Args { costFresh?: string; } +/** + * Every value-taking flag, and the one place each is assigned (#165). + * + * This used to be two lists that had to agree and did not: a `valued` set here + * in the parser, and an `else if` chain in `assignValue`. The parser consumed a + * flag's value, handed it over, and the chain fell off its end without matching + * — so `--from-cache`, `--site-key`, `--task-key` and `--repair-model` were + * accepted, documented in `usage()`, and **silently dropped**. `--repair-model` + * was the dangerous one: a run asking for the real repair client got + * `StubRepairModelClient`, which returns a null action and zero tokens, so the + * run reported a self-heal rate of 0 and a `cost_repair` of zero that both look + * like measurements. `AnthropicRepairModelClient` throws at construction rather + * than degrade to a no-op for exactly this reason; the CLI in front of it + * degraded anyway and never reached that constructor. + * + * One table closes the class rather than the four instances. A flag exists here + * or it does not exist at all: the parser derives its accepted set from these + * keys, so an entry cannot be accepted-but-unassigned, and an assigner cannot be + * unreachable. `tests/unit/gate-matrix.test.ts` walks the table and asserts each + * key lands in `Args`. + */ +const VALUED_FLAGS = { + versions: (args, value) => { + args.versions = value; + }, + program: (args, value) => { + args.program = value; + }, + param: (args, value) => { + const eq = value.indexOf("="); + if (eq <= 0) throw new Error(`--param expects key=value, got: ${value}`); + args.params[value.slice(0, eq)] = value.slice(eq + 1); + }, + runs: (args, value) => { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n < 1) throw new Error(`--runs must be >= 1, got: ${value}`); + args.runs = n; + }, + port: (args, value) => { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid --port: ${value}`); + args.port = n; + }, + "from-cache": (args, value) => { + args.fromCache = value; + }, + "site-key": (args, value) => { + args.siteKey = value; + }, + "task-key": (args, value) => { + args.taskKey = value; + }, + "repair-model": (args, value) => { + args.repairModel = value; + }, + "cost-fresh": (args, value) => { + args.costFresh = value; + }, +} satisfies Record void>; + +/** Flag names without the leading `--`, in declaration order. */ +export const VALUED_FLAG_NAMES = Object.keys(VALUED_FLAGS) as (keyof typeof VALUED_FLAGS)[]; + function parseArgs(argv: string[]): Args { const args: Args = { dryRun: false, @@ -153,18 +218,7 @@ function parseArgs(argv: string[]): Args { runs: DEFAULT_RUNS_PER_VERSION, poolOnly: false, }; - const valued = new Set([ - "--versions", - "--program", - "--port", - "--param", - "--runs", - "--from-cache", - "--site-key", - "--task-key", - "--repair-model", - "--cost-fresh", - ]); + const valued = new Set(VALUED_FLAG_NAMES.map((name) => `--${name}`)); for (let i = 0; i < argv.length; i++) { const a = argv[i] ?? ""; if (a === "--dry-run") args.dryRun = true; @@ -188,23 +242,15 @@ function parseArgs(argv: string[]): Args { } export function assignValue(args: Args, key: string, value: string): void { - if (key === "versions") args.versions = value; - else if (key === "program") args.program = value; - else if (key === "param") { - const eq = value.indexOf("="); - if (eq <= 0) throw new Error(`--param expects key=value, got: ${value}`); - args.params[value.slice(0, eq)] = value.slice(eq + 1); - } else if (key === "runs") { - const n = Number.parseInt(value, 10); - if (!Number.isFinite(n) || n < 1) throw new Error(`--runs must be >= 1, got: ${value}`); - args.runs = n; - } else if (key === "port") { - const n = Number.parseInt(value, 10); - if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid --port: ${value}`); - args.port = n; - } else if (key === "cost-fresh") { - args.costFresh = value; - } + const assign = Object.prototype.hasOwnProperty.call(VALUED_FLAGS, key) + ? VALUED_FLAGS[key as keyof typeof VALUED_FLAGS] + : undefined; + // Throwing rather than returning matches the parser's own posture two lines + // up — it throws on `unknown argument`. A flag the parser accepted and nobody + // consumed is the same caller error one layer in, and silence there is what + // made #165 invisible for four flags. + if (!assign) throw new Error(`unhandled valued flag: --${key}`); + assign(args, value); } function usage(): void { @@ -393,6 +439,11 @@ interface WalkOptions { shouldStop: () => boolean; /** Measured fresh-baseline (#39), attached to every LIVE run row's cost_fresh. */ costFresh?: Cost; + /** + * Real repair client (#27), built from `--repair-model`. Absent means the + * stub — `ReplayRunner`'s default, and what every run got before #165. + */ + repairClient?: RepairModelClient; } /** @@ -453,6 +504,7 @@ async function walkVersions( await opts.persist(); }, shouldStop: opts.shouldStop, + ...(opts.repairClient ? { repairClient: opts.repairClient } : {}), ...(baseline ? { baseline } : {}), ...(opts.costFresh ? { costFresh: opts.costFresh } : {}), }); @@ -697,6 +749,41 @@ async function main(): Promise { } } + // #165: `--repair-model` reached `Args` and stopped there — nothing built a + // client from it, so `ReplayRunner` fell back to `StubRepairModelClient` and + // the run reported a self-heal rate of 0 and zero repair cost that both look + // measured. Assigning the flag was only half the bug; this is the half that + // makes the flag mean what `usage()` says it means. + // + // Constructed here, before anything boots, for the same reason --cost-fresh + // is loaded here: the client throws when ANTHROPIC_API_KEY is unset rather + // than degrading to a no-op, and a caller who asked for a real model should + // learn that from a named refusal on line one, not after the first container + // is up. + let repairClient: RepairModelClient | undefined; + if (args.repairModel !== undefined) { + if (args.dryRun) { + // A dry run hard-codes every outcome, so no step ever fails and the + // repair loop is never entered. Silently accepting the flag would let a + // caller believe they had priced a real repair. + console.error( + "gate:matrix: --repair-model is meaningless under --dry-run — no step " + + "fails, so the repair loop never runs and no token is ever spent. " + + "Drop one of the two flags.", + ); + process.exit(2); + return; + } + try { + repairClient = new AnthropicRepairModelClient({ model: args.repairModel }); + console.log(` repair-model: ${args.repairModel} (REAL client — this run spends tokens)`); + } catch (err) { + console.error(`gate:matrix: ${errMessage(err)}`); + process.exit(2); + return; + } + } + const mode = args.dryRun ? "dry-run" : "live"; const plannedRuns = walked.length * args.runs; const plannedSteps = plannedRuns * program.steps.length; @@ -755,6 +842,7 @@ async function main(): Promise { persist, shouldStop: () => stopRequested, ...(costFresh ? { costFresh } : {}), + ...(repairClient ? { repairClient } : {}), }); await persist(); diff --git a/src/cache/index.ts b/src/cache/index.ts index 7e08529..db7e6ee 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -42,6 +42,12 @@ export { type CacheUpdateResult, type StepOutcomeReport, } from "./update.js"; +export { + ingestBundle, + type IngestableBundle, + type IngestOptions, + type IngestSummary, +} from "./ingest.js"; export { resolveProgram, type ProgramKey, diff --git a/src/cache/ingest.ts b/src/cache/ingest.ts new file mode 100644 index 0000000..ad7744f --- /dev/null +++ b/src/cache/ingest.ts @@ -0,0 +1,195 @@ +/** + * Compiled bundle → cache, through the write-time authority (issue #166). + * + * ## The break this closes + * + * `writeCacheRow()` is the authoritative privacy boundary — invariant 2 in + * `docs/architecture.md` — and until this module it had **no caller outside + * `tests/`**. The only runtime code that touched the cache at all was + * `experiments/gate-v1/run-matrix.ts`, and it only *read*: `--from-cache` + * resolved a program out of a `JsonlCacheStore` in a directory that no shipped + * code path ever populated. `cacheHitRate()` is a reported §9 section whose + * denominator could only be non-empty if a human hand-wrote JSONL first. + * + * Two consequences, and the second is the quieter one: + * + * 1. A §9 metric could not produce a number for a reason that had nothing to + * do with the experiment — nothing could fill the cache. + * 2. The `pool_eligible` flag that reached disk was decided by + * `src/compiler/pool.ts::decidePoolEligibility`, the compiler's own + * **pre-check**, while the canary suite defended a function nothing called. + * Both fail closed, so this was never a live leak; it was an authority and + * a lookalike, with the lookalike doing the work. + * + * ## Why the compiler CLI owns the write + * + * `paragent compile --to-cache `, rather than a `gate:matrix` that + * populates on first run or a separate `paragent cache write`: + * + * - **The compiler is the only stage holding `steps_total`.** `resolveProgram` + * refuses to return anything it cannot prove complete (ADR-0013), and the + * `ProgramRef` carrying that proof is written by the compiler. Whoever writes + * rows has to already hold the whole bundle; the compiler does, by + * construction. + * - **A measurement harness should not also be a data producer.** If + * `gate:matrix` populated on first run, the populating run would differ from + * every run after it — the first would be a file replay and the rest cache + * hits, inside one reported sample. That is precisely the "comparing two + * different things" hazard #39 warns about for the fresh/repair ratio. + * - **A separate `cache write` command would need a third reader of the bundle + * shape.** The compiler already parses, validates and emits it. + * + * This module lives in `src/cache/` and takes a structurally-typed bundle, so + * the dependency runs one way — the compiler's CLI calls the cache, the cache + * knows nothing about the compiler. `src/cache/` stays library-only; it gains a + * caller, not a CLI. + * + * ## What it does not do + * + * It does not renegotiate what `writeCacheRow` permits. Every row goes through + * the authority with its fail-closed checks intact, and a bundle that claims + * pool eligibility the authority refuses raises `CacheWriteRejectedError` — + * unchanged behaviour, now reachable from a shipped path instead of only from a + * test. + */ + +import { writeCacheRowPair, CacheWriteRejectedError, type WriteLogSink } from "./write.js"; +import type { CacheStore } from "./store.js"; +import type { CacheRow, CacheRowCandidate } from "./types.js"; + +/** + * The bundle shape this module needs, declared structurally. + * + * Deliberately not an import of `CompiledTrajectoryBundle`: `CacheRow` is + * declared twice in this repo — once here and once in `src/compiler/types.ts` — + * specifically so the compiler does not depend on the cache package. Importing + * the compiler's types here would close that loop from the other side. + */ +export interface IngestableBundle { + site_key: string; + task_key: string; + rows: readonly CacheRowCandidate[]; +} + +export interface IngestOptions { + store: CacheStore; + log?: WriteLogSink; +} + +export interface IngestSummary { + site_key: string; + task_key: string; + /** From the rows' `ProgramRef`, when they carry one. */ + program_id?: string; + steps: number; + /** Pool rows the authority judged shareable. */ + pool_eligible: number; + /** Steps the authority kept tenant-scoped, with its reason. */ + tenant_only: { step_index: number; reason: string }[]; + /** + * Steps where the compiler's pre-check said "not poolable" and the authority + * said otherwise. + * + * This direction is legal — a pre-check may be stricter than the authority, + * never looser — but it is not nothing: it means the flag on disk before this + * module existed was more conservative than the boundary requires. Reported + * rather than smoothed over, because two fail-closed implementations of one + * rule drifting apart is exactly how the pre-check stops being a pre-check. + */ + widened: number[]; +} + +/** Steps that carry no `program` ref cannot be resolved back. Refuse early. */ +function assertResolvable(bundle: IngestableBundle): string | undefined { + const missing = bundle.rows + .filter((r) => !r.program) + .map((r) => r.step_index); + if (missing.length > 0) { + throw new Error( + `bundle rows ${missing.join(", ")} carry no program ref (ADR-0013), so ` + + "resolveProgram() would report them as no_program_ref forever. " + + "Recompile the trajectory with a current compiler before caching it.", + ); + } + const ids = new Set(bundle.rows.map((r) => r.program?.program_id)); + if (ids.size > 1) { + throw new Error( + `bundle mixes program ids (${[...ids].join(", ")}); refusing to write a ` + + "cache whose rows disagree about which program they belong to.", + ); + } + return bundle.rows[0]?.program?.program_id; +} + +/** + * Write every row of a compiled bundle through `writeCacheRowPair`. + * + * All-or-nothing: the authority runs over the whole bundle **before** anything + * is persisted, so a rejection on step 7 cannot leave steps 0-6 on disk. A + * partial write would not be a correctness hazard — `resolveProgram` fails + * closed on a prefix and reports `incomplete` — but "the cache holds a program + * or it does not" is a cheaper thing to reason about than a truncation that + * resolves as a miss for a reason unrelated to what the caller did wrong. + * + * The cost is that the authority runs twice per row. That is deliberate and it + * is the point: the second pass is the one that persists, and it is the same + * call, so nothing can decide `pool_eligible` on the way to disk except + * `writeCacheRow`. + * + * @throws CacheWriteRejectedError when a row claims pool eligibility the + * authority refuses, or when a tenant row would reach the pool file. + */ +export function ingestBundle( + bundle: IngestableBundle, + options: IngestOptions, +): IngestSummary { + const programId = assertResolvable(bundle); + + // Pass 1 — decide, persist nothing. `writeCacheRowPair` with no `store` runs + // every fail-closed check and returns the rows it would have written. + const decided: { candidate: CacheRowCandidate; pool: CacheRow; tenant: CacheRow }[] = []; + for (const candidate of bundle.rows) { + try { + const { pool, tenant } = writeCacheRowPair(candidate); + decided.push({ candidate, pool, tenant }); + } catch (err) { + if (err instanceof CacheWriteRejectedError) { + throw new CacheWriteRejectedError( + `step ${candidate.step_index}: ${err.message}`, + err.reason, + ); + } + throw err; + } + } + + // Pass 2 — same call, now persisting. Nothing reached disk until every row + // was known to be acceptable. + const widened: number[] = []; + const tenantOnly: { step_index: number; reason: string }[] = []; + let poolEligible = 0; + for (const { candidate, pool } of decided) { + writeCacheRowPair(candidate, { store: options.store, ...(options.log ? { log: options.log } : {}) }); + if (pool.pool_eligible) { + poolEligible++; + // The compiler's claim travelled on the candidate; the authority ignored + // it and decided for itself. Where the two differ in this direction, say so. + if (candidate.pool_eligible === false) widened.push(candidate.step_index); + } else { + tenantOnly.push({ + step_index: candidate.step_index, + reason: pool.pool_ineligible_reason ?? "other", + }); + } + } + + return { + site_key: bundle.site_key, + task_key: bundle.task_key, + ...(programId ? { program_id: programId } : {}), + steps: bundle.rows.length, + pool_eligible: poolEligible, + tenant_only: tenantOnly, + widened, + }; +} diff --git a/src/compiler/cli.ts b/src/compiler/cli.ts index 3dfb412..ea3d9ab 100644 --- a/src/compiler/cli.ts +++ b/src/compiler/cli.ts @@ -10,16 +10,28 @@ import path from "node:path"; import { compileTrajectory } from "./compile.js"; import type { Trajectory } from "./types.js"; import { validateCompiledBundle } from "./validate.js"; +import { ingestBundle, type IngestableBundle } from "../cache/ingest.js"; +import { CacheWriteRejectedError } from "../cache/write.js"; +import { DEFAULT_CACHE_DIR, JsonlCacheStore } from "../cache/store.js"; function usage(): never { console.log(`paragent compiler (B3) Usage: paragent compile --in [--out ] [--no-validate] + paragent compile --in --to-cache npm run compile -- --in [--out ] (from a clone) Reads a trajectory conforming to contracts/trajectory.schema.json and emits a -compiled_trajectory bundle (one cache-row per step).`); +compiled_trajectory bundle (one cache-row per step). + + --to-cache Also write every row through the cache's write-time privacy + boundary into (#166) — the directory + \`gate:matrix --from-cache\` reads. Conventionally + ${DEFAULT_CACHE_DIR}. pool_eligible on disk is decided by + writeCacheRow(), not by the compiler's pre-check; a bundle + claiming eligibility the boundary refuses is rejected and + nothing is written.`); process.exit(0); } @@ -75,6 +87,52 @@ async function main(): Promise { console.log( `rows=${bundle.rows.length} pool_eligible=${bundle.rows.filter((r) => r.pool_eligible).length}`, ); + + // #166: the compiled bundle is a file until something puts it where the + // replay path looks. `writeCacheRow()` had no caller outside tests, so + // `gate:matrix --from-cache` read a directory nothing populated. + const cacheDir = getArg(args, "--to-cache"); + if (cacheDir !== undefined) { + const absCache = path.resolve(process.cwd(), cacheDir); + const store = new JsonlCacheStore({ dir: absCache }); + let summary; + try { + summary = ingestBundle(bundle as unknown as IngestableBundle, { store }); + } catch (err) { + if (err instanceof CacheWriteRejectedError) { + // Not a crash to smooth over: the compiler's pre-check claimed pool + // eligibility the boundary refused, which is the one direction that is + // never allowed. Nothing was written. + console.error(`cache write refused (${err.reason}): ${err.message}`); + console.error(" nothing was written — the whole bundle is rejected, not the row."); + process.exit(1); + } + throw err; + } + const rel = path.relative(process.cwd(), absCache).replace(/\\/g, "/"); + console.log( + `cached ${summary.steps} steps to ${rel} ` + + `(${summary.pool_eligible} pool-eligible, ${summary.tenant_only.length} tenant-only)`, + ); + if (summary.program_id) { + console.log( + ` resolve with: --from-cache ${rel} ` + + `--site-key ${summary.site_key} --task-key ${summary.task_key}`, + ); + } + if (summary.widened.length > 0) { + // Legal (a pre-check may be stricter) but worth saying out loud — it + // means the flag the compiler wrote into the bundle file is more + // conservative than the boundary itself. + console.log( + ` note: authority pooled ${summary.widened.length} step(s) the compiler ` + + `pre-check did not: ${summary.widened.join(", ")}`, + ); + } + for (const t of summary.tenant_only) { + console.log(` step ${t.step_index}: tenant-only (${t.reason})`); + } + } } main().catch((err: unknown) => { diff --git a/tests/integration/cache-ingest-bundle.test.ts b/tests/integration/cache-ingest-bundle.test.ts new file mode 100644 index 0000000..df6d294 --- /dev/null +++ b/tests/integration/cache-ingest-bundle.test.ts @@ -0,0 +1,284 @@ +/** + * `paragent compile --to-cache` end to end (issue #166). + * + * `cache-resolve-program.test.ts` already drives compile → write → resolve, but + * it does so by calling `writeCacheRowPair` itself, row by row, from the test. + * That is the seam; it is not the **shipped path**. Until #166 there was no + * shipped path: `writeCacheRow()` had no caller outside `tests/`, so + * `gate:matrix --from-cache ` read a directory nothing in the product + * could populate, and `cacheHitRate()` could only ever report `no_data` for a + * reason that had nothing to do with the experiment. + * + * So what is under test here is the hop itself — `ingestBundle`, the function + * the compiler CLI calls — and the two properties that make it safe to put in + * front of the privacy boundary: + * + * 1. **The authority decides `pool_eligible`, not the compiler's pre-check.** + * A pre-check may be stricter than `writeCacheRow`; it may never be looser, + * and a bundle that claims otherwise is refused rather than written. + * 2. **All or nothing.** A rejection part-way through cannot leave a prefix on + * disk. `resolveProgram` would fail closed on a prefix anyway (`incomplete` + * is a miss, not a truncated replay), but a cache that half-holds a program + * is a worse thing to debug than one that does not hold it. + * + * The last test runs the **committed 12-step live gate bundle** rather than a + * synthetic one, for the same reason `live-bundle-pool.test.ts` does: synthetic + * fixtures are what let the pre-check/authority divergence through in the first + * place. + */ + +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { compileTrajectory } from "../../src/compiler/compile.js"; +import type { Trajectory } from "../../src/compiler/types.js"; +import { ingestBundle, type IngestableBundle } from "../../src/cache/ingest.js"; +import { CacheWriteRejectedError } from "../../src/cache/write.js"; +import { JsonlCacheStore, POOL_FILE, TENANT_FILE } from "../../src/cache/store.js"; +import { resolveProgram } from "../../src/cache/resolve.js"; +import { rowsToProgram } from "../../src/runner/program.js"; + +const SITE = "fixture@local"; +const TASK = "ingest-three-step"; + +const LIVE_BUNDLE = path.join( + process.cwd(), + "artifacts/compiled/traj-gate-live-create-stat-dashboard-from-testdata-9.5.21.bundle.json", +); + +const fingerprint = (url: string) => ({ + url_template: url, + title_template: "Fixture", + dom_digest: "digest", + visible_landmarks: ["main"], + network_idle: true, +}); + +function trajectory(): Trajectory { + return { + schema_version: "1.0.0", + trajectory_id: "traj-ingest-integration", + site_key: SITE, + task_key: TASK, + recorded_at: "2026-08-11T10:00:00.000Z", + base_url_template: "http://{host}:{port}/", + provenance: { recorder: "test", agent_model: "human", testbed_version: "fixture-v1" }, + parameters: { host: "string", port: "integer", username: "string" }, + steps: [ + { + step_index: 0, + intent: "Open the app", + action: { type: "navigate" as const, url_template: "http://{host}:{port}/" }, + locator_candidates: [], + pre_state: fingerprint("http://{host}:{port}/"), + post_state: fingerprint("http://{host}:{port}/"), + timing_ms: { started_offset_ms: 0, duration_ms: 5 }, + }, + { + step_index: 1, + intent: "Type the username", + action: { type: "fill" as const, param_refs: ["username"] }, + locator_candidates: [ + { strategy: "role_name" as const, rank: 0, role: "textbox", name: "Username" }, + ], + pre_state: fingerprint("http://{host}:{port}/"), + post_state: fingerprint("http://{host}:{port}/"), + timing_ms: { started_offset_ms: 5, duration_ms: 5 }, + }, + { + step_index: 2, + intent: "Save", + action: { type: "click" as const }, + locator_candidates: [ + { strategy: "role_name" as const, rank: 0, role: "button", name: "Save" }, + ], + pre_state: fingerprint("http://{host}:{port}/"), + post_state: fingerprint("http://{host}:{port}/saved"), + timing_ms: { started_offset_ms: 10, duration_ms: 5 }, + }, + ], + } as unknown as Trajectory; +} + +const asIngestable = (bundle: unknown) => bundle as unknown as IngestableBundle; + +/** + * Make one row claim pool eligibility the authority will refuse. + * + * Via the assertion rather than the locator chain, deliberately: a tainted + * locator on a row that carries `flow_topology` legally degrades to a pooled + * `topology_only` row, so it is *not* a refusal on every step. A tenant literal + * in `expected.template` is checked first in `buildPoolRow` and short-circuits + * before that fallback, which makes the violation independent of which step it + * is applied to — the property this helper's callers actually need. + */ +function poison(bundle: { rows: unknown[] }, stepIndex: number): { rows: unknown[] } { + const rows = structuredClone(bundle.rows) as Record[]; + const row = rows[stepIndex] as { + pool_eligible: boolean; + assertion: { expected?: Record }; + }; + row.pool_eligible = true; + row.assertion.expected = { + ...row.assertion.expected, + template: "Acme Widgets Inc invoice #4471", + }; + return { ...bundle, rows }; +} + +describe("compiled bundle → cache, through the write authority (#166)", () => { + let root: string; + let n = 0; + const freshDir = () => path.join(root, `case-${n++}`); + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "paragent-ingest-")); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("populates a cache that resolveProgram then returns as a complete program", () => { + const dir = freshDir(); + const store = new JsonlCacheStore({ dir }); + const bundle = compileTrajectory(trajectory()); + + const summary = ingestBundle(asIngestable(bundle), { store }); + expect(summary.steps).toBe(3); + expect(summary.program_id).toBe("prog-traj-ingest-integration"); + + // The claim that matters: a *different* store, opened on the same directory, + // resolves the whole program. That is the `--from-cache` path exactly — it + // does not share process state with whoever wrote the rows. + const reader = new JsonlCacheStore({ dir }); + const resolution = resolveProgram(reader, { site_key: SITE, task_key: TASK }); + expect(resolution.status).toBe("hit"); + if (resolution.status !== "hit") return; + + expect(resolution.steps_total).toBe(3); + expect(resolution.rows.map((r) => r.step_index)).toEqual([0, 1, 2]); + expect(resolution.program_id).toBe("prog-traj-ingest-integration"); + + // And it is replayable — `required_params` derived from the rows, which is + // what a caller is told to bind before anything opens a browser. + const program = rowsToProgram(resolution, "11.0.0"); + expect(program.steps).toHaveLength(3); + }); + + it("writes rows to disk, not just into an in-process index", () => { + const dir = freshDir(); + ingestBundle(asIngestable(compileTrajectory(trajectory())), { + store: new JsonlCacheStore({ dir }), + }); + + // Both files, because writeCacheRowPair persists the tenant row too and the + // store routes it by `pool_eligible`. A pool row in the tenant file (or the + // reverse) is the leak `tests/canary/store-leak.test.ts` guards from disk. + expect(existsSync(path.join(dir, POOL_FILE))).toBe(true); + expect(existsSync(path.join(dir, TENANT_FILE))).toBe(true); + }); + + it("lets the authority, not the compiler pre-check, decide pool_eligible", () => { + const dir = freshDir(); + const store = new JsonlCacheStore({ dir }); + const bundle = compileTrajectory(trajectory()); + + const summary = ingestBundle(asIngestable(bundle), { store }); + + // Every pool row on disk carries the authority's verdict. Whatever the + // compiler wrote into the bundle file is a pre-check and does not travel. + // + // Filtered rather than `list().filter(...)`: the default view merges pool + // and tenant per key and the tenant row wins, because it is written second. + // Asking the store for pool rows is what `--pool-only` does. + const pooled = store.list({ pool_eligible: true }); + expect(pooled).toHaveLength(summary.pool_eligible); + for (const row of pooled) { + for (const loc of row.compiled_action.locator_fallback_chain) { + expect(loc.tenant_scoped).not.toBe(true); + expect(loc.strategy).not.toBe("text"); + } + } + // Reported, not smoothed over — see IngestSummary.widened. + expect(Array.isArray(summary.widened)).toBe(true); + }); + + it("refuses the whole bundle, writing nothing, when a row claims eligibility the authority denies", () => { + const dir = freshDir(); + const store = new JsonlCacheStore({ dir }); + const bundle = compileTrajectory(trajectory()); + + // A tenant literal in the assertion can never be pool-safe; claiming it is, + // is the one direction a pre-check is never allowed to be wrong in. + expect(() => + ingestBundle(asIngestable(poison(bundle, 2)), { store }), + ).toThrow(CacheWriteRejectedError); + + // All-or-nothing: step 2 was rejected, so steps 0 and 1 must not be on disk. + // Without the two-pass write this directory holds a resolvable-looking + // prefix of a program that was refused. + const onDisk = existsSync(dir) ? readdirSync(dir) : []; + expect(onDisk).toEqual([]); + expect(resolveProgram(new JsonlCacheStore({ dir }), { site_key: SITE, task_key: TASK }).status).toBe("miss"); + }); + + it("names the offending step in the rejection", () => { + // `writeCacheRowPair` knows the row but not its position in a bundle, so a + // bare rejection reads the same for all twelve steps of the live task. + const bundle = compileTrajectory(trajectory()); + expect(() => + ingestBundle(asIngestable(poison(bundle, 1)), { + store: new JsonlCacheStore({ dir: freshDir() }), + }), + ).toThrow(/step 1:/); + }); + + it("refuses rows with no program ref rather than caching something unresolvable", () => { + // Without ADR-0013 identity these rows resolve as `no_program_ref` forever. + // Writing them would produce a cache that is populated and permanently + // useless — the worst of both, and invisible until someone reads a miss reason. + const bundle = compileTrajectory(trajectory()); + const rows = structuredClone(bundle.rows) as unknown as Record[]; + for (const r of rows) delete r.program; + + expect(() => + ingestBundle(asIngestable({ ...bundle, rows }), { + store: new JsonlCacheStore({ dir: freshDir() }), + }), + ).toThrow(/no program ref/); + }); + + it("round-trips the committed 12-step live gate bundle", async () => { + // The real artifact, not a synthetic one. This is the bundle whose + // url-matches rows the compiler once called poolable and the authority + // refused (#25) — so it is the one that proves the shipped path survives + // real data rather than fixtures built to pass. + const bundle = JSON.parse(await readFile(LIVE_BUNDLE, "utf8")) as { + site_key: string; + task_key: string; + rows: { step_index: number }[]; + }; + const dir = freshDir(); + const summary = ingestBundle(asIngestable(bundle), { + store: new JsonlCacheStore({ dir }), + }); + + expect(summary.steps).toBe(12); + + const resolution = resolveProgram(new JsonlCacheStore({ dir }), { + site_key: bundle.site_key, + task_key: bundle.task_key, + }); + expect(resolution.status).toBe("hit"); + if (resolution.status !== "hit") return; + expect(resolution.rows).toHaveLength(12); + + // The point of the exercise, stated as a number: before #166 this + // denominator could not be non-empty without a human hand-writing JSONL. + expect(summary.pool_eligible + summary.tenant_only.length).toBe(12); + }); +}); diff --git a/tests/unit/gate-matrix.test.ts b/tests/unit/gate-matrix.test.ts index f6eba27..3aa61d6 100644 --- a/tests/unit/gate-matrix.test.ts +++ b/tests/unit/gate-matrix.test.ts @@ -26,7 +26,13 @@ import { runsToClearSection9, substituteRunIndex, } from "../../experiments/gate-v1/live-run.js"; -import { buildSection9Floor, loadCostFreshBaseline } from "../../experiments/gate-v1/run-matrix.js"; +import { + assignValue, + buildSection9Floor, + loadCostFreshBaseline, + VALUED_FLAG_NAMES, + type Args as MatrixArgs, +} from "../../experiments/gate-v1/run-matrix.js"; import { perVersionBreakdown, section9SampleFloor, @@ -541,3 +547,87 @@ describe("loadCostFreshBaseline (#39)", () => { await expect(loadCostFreshBaseline(file)).rejects.toThrow(/missing mean_cost_fresh/); }); }); + +// --------------------------------------------------------------------------- +// #165 — every value-taking flag must land somewhere in `Args`. +// +// The bug was structural, not four typos: `parseArgs` held a `valued` set and +// `assignValue` held an `else if` chain, and the two disagreed. A flag in the +// first but not the second was consumed, dropped, and never complained about — +// it did not even trip `unknown argument`, because the parser had accepted it. +// +// So this walks the flag table rather than naming the four that were broken. A +// new flag that reaches the parser without an assigner fails here, and one +// added without a sample below fails the coverage check. +// --------------------------------------------------------------------------- + +describe("gate:matrix valued flags (#165)", () => { + /** A representative value per flag, and what it must produce in `Args`. */ + const SAMPLES: Record< + (typeof VALUED_FLAG_NAMES)[number], + { value: string; expect: (args: MatrixArgs) => unknown; want: unknown } + > = { + versions: { value: "11.0.0", expect: (a) => a.versions, want: "11.0.0" }, + program: { value: "/tmp/p.json", expect: (a) => a.program, want: "/tmp/p.json" }, + param: { value: "resource_label=widget", expect: (a) => a.params, want: { resource_label: "widget" } }, + runs: { value: "7", expect: (a) => a.runs, want: 7 }, + port: { value: "3100", expect: (a) => a.port, want: 3100 }, + "from-cache": { value: "/tmp/cache", expect: (a) => a.fromCache, want: "/tmp/cache" }, + "site-key": { value: "grafana-oss@example", expect: (a) => a.siteKey, want: "grafana-oss@example" }, + "task-key": { value: "open-dashboards-list", expect: (a) => a.taskKey, want: "open-dashboards-list" }, + "repair-model": { value: "claude-opus-5", expect: (a) => a.repairModel, want: "claude-opus-5" }, + "cost-fresh": { value: "/tmp/baseline.json", expect: (a) => a.costFresh, want: "/tmp/baseline.json" }, + }; + + const emptyArgs = (): MatrixArgs => ({ + dryRun: false, + help: false, + headed: false, + keepUp: false, + preamble: true, + params: {}, + runs: 3, + poolOnly: false, + }); + + it("has a sample for every flag the parser accepts", () => { + // Guards the guard: without this, adding a flag and forgetting its sample + // would leave the table-driven test below silently not covering it. + expect(Object.keys(SAMPLES).sort()).toEqual([...VALUED_FLAG_NAMES].sort()); + }); + + it.each(VALUED_FLAG_NAMES)("--%s is assigned, not silently dropped", (flag) => { + const sample = SAMPLES[flag]; + const args = emptyArgs(); + assignValue(args, flag, sample.value); + expect(sample.expect(args)).toEqual(sample.want); + }); + + it("leaves the four flags #165 dropped actually reaching Args", () => { + // The regression in its original terms. `--repair-model` is the one that + // mattered: dropped, the run used StubRepairModelClient and reported a + // self-heal rate of 0 and zero repair cost that both looked measured. + const args = emptyArgs(); + for (const flag of ["from-cache", "site-key", "task-key", "repair-model"] as const) { + assignValue(args, flag, SAMPLES[flag].value); + } + expect(args.fromCache).toBe("/tmp/cache"); + expect(args.siteKey).toBe("grafana-oss@example"); + expect(args.taskKey).toBe("open-dashboards-list"); + expect(args.repairModel).toBe("claude-opus-5"); + }); + + it("throws on a valued flag with no assigner instead of dropping it", () => { + // The structural half: silence here is what made the original bug + // invisible, and the parser already throws for `unknown argument`. + expect(() => assignValue(emptyArgs(), "not-a-flag", "x")).toThrow( + /unhandled valued flag: --not-a-flag/, + ); + }); + + it("still validates values it does assign", () => { + expect(() => assignValue(emptyArgs(), "runs", "0")).toThrow(/--runs must be >= 1/); + expect(() => assignValue(emptyArgs(), "port", "-1")).toThrow(/invalid --port/); + expect(() => assignValue(emptyArgs(), "param", "novalue")).toThrow(/expects key=value/); + }); +}); From 6762f4719d4b7279484550d883ee1c6dc83cd3dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:18:18 +0000 Subject: [PATCH 2/2] docs: point the deferred pool-eligibility divergence at #170 The divergence between decidePoolEligibility and writeCacheRow was recorded as an open finding with no tracking issue to reach. Filed as #170, which lays out the three ways it could go; both docs now link it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016CAt2F7SR83iuqvnM4GzAf --- docs/architecture.md | 3 ++- docs/gate/compiler.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5400a12..6b4434a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,8 @@ synthesized assertion and writes a `compiled_trajectory` bundle to `artifacts/co never looser) and the dangerous direction is pinned by `tests/integration/live-bundle-pool.test.ts`. The pre-check is deliberately left as-is here: changing it changes what every committed bundle artifact claims about pool eligibility, which - is a privacy-adjacent decision that wants its own ADR rather than a rider on a wiring fix. + is a privacy-adjacent decision that wants its own ADR rather than a rider on a wiring fix — + tracked in [#170](https://github.com/DevToolie/Paragent/issues/170). 2. ~~**The bundle never reaches the runner.**~~ **Closed by [#62](https://github.com/DevToolie/Paragent/issues/62).** The runner consumes `CompiledProgram` (`src/runner/types.ts`), a different shape from diff --git a/docs/gate/compiler.md b/docs/gate/compiler.md index 8c447c0..1fe83fc 100644 --- a/docs/gate/compiler.md +++ b/docs/gate/compiler.md @@ -274,6 +274,8 @@ That divergence is **not** reconciled here, for the reason the paragraph above g URL path: it changes what every committed bundle artifact claims about pool eligibility, and the `pool_eligible` flag in a bundle file is no longer what reaches disk anyway. The number to watch is the one `--to-cache` prints (`authority pooled N step(s) the compiler pre-check did not`). +Filed as [#170](https://github.com/DevToolie/Paragent/issues/170), which lays out the three ways +it could go and why each is a decision rather than a repair. ## CLI