feat(gate): drive a real browser per matrix version, remove the exit-2 guard - #91
Conversation
…2 guard Closes #62. `npm run gate:matrix` refused to run for real, and its dry-run path hard-coded every step to PASS. The driver that was missing — bring up a seeded version, open a browser against it, replay the compiled program, tear it down — now lives in experiments/gate-v1/live-run.ts. Per version: compose up -> /api/health readiness -> seed -> seed-fingerprint gate -> Chromium -> login preamble -> ReplayRunner with dryRun:false -> teardown in a finally. Nothing in that file retries a step, downgrades an outcome, or catches an assertion failure; repair is the only permitted second attempt. A skip is not a failure. A version whose container never started, whose login broke, or whose seed state differs from the base version produced no measurement: it is recorded in out/matrix-run.json with a stage and a reason and never reaches the NDJSON. Counting it as a failed run would invent a data point. Two things the live path exposed: - The committed example bundle asserts getByRole("form") on Grafana's login page, which matches zero elements — an unnamed <form> has no ARIA role. The driver reported it honestly on its first live run rather than crashing. - contracts/metrics.schema.json has additionalProperties:false and no field for a failure reason, and stub repair always proposes null, so every genuine failure emits REPAIR_EXHAUSTED. LOCATOR_NOT_FOUND, ASSERTION_FAILED and TIMEOUT are flattened into one value. Widening the contract needs an ADR, so StepAttemptResult.first_pass_outcome keeps the real outcome in memory and the driver records it in its own ledger. That is a workaround, not a fix, and both docs say so. The bundle -> CompiledProgram adapter moves from tests/integration into src/runner/program.ts. Its comment said it was test-local because "the runtime never needs it today"; this is the change that made the runtime need it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code reviewI read the PR description, the full diff, and cross-checked the runtime behavior against OverviewCloses #62: removes the exit-2 guard that blocked The PR is explicit about what it doesn't claim: it doesn't fix the broken example bundle assertion, doesn't widen the metrics contract, and doesn't produce a gate number (one run per version isn't a sample). That matches the repo's stated culture well. CorrectnessA crash mid-matrix silently discards every already-measured version — this is the one I'd fix before merge.
Net effect: if that happens, Two independent fixes, either is sufficient:
Minor: skip stage mislabeling for matrix-config-unavailable versions. In Everything else checks out — I verified rather than trusted the PR's own claims:
Code quality & conventionsStrong fit with the repo's documented style — every non-obvious decision has a comment explaining why, matching CONTRIBUTING's tone. Naming is consistent with existing code. Docs ( Test coverage16 new unit tests cover exactly the parts the PR claims: bundle→program ordering/bookkeeping-stripping, Gap: nothing tests the crash-data-loss scenario above, and it's hard to unit-test given it needs the full driver. A cheap regression test: mock SecurityNo credential handling changes. Fixture constants only, no shell interpolation on SummarySolid, well-documented, honest implementation that does what it says. The one thing worth fixing before merge is the crash-mid-matrix data loss (unguarded 🤖 Reviewed with Claude Code |
…ding) # Conflicts: # docs/gate/runner.md # experiments/gate-v1/README.md # experiments/gate-v1/live-run.ts # experiments/gate-v1/run-matrix.ts # tests/unit/gate-matrix.test.ts
…ruption (#92) * feat(gate): drive a real browser per matrix version, remove the exit-2 guard Closes #62. `npm run gate:matrix` refused to run for real, and its dry-run path hard-coded every step to PASS. The driver that was missing — bring up a seeded version, open a browser against it, replay the compiled program, tear it down — now lives in experiments/gate-v1/live-run.ts. Per version: compose up -> /api/health readiness -> seed -> seed-fingerprint gate -> Chromium -> login preamble -> ReplayRunner with dryRun:false -> teardown in a finally. Nothing in that file retries a step, downgrades an outcome, or catches an assertion failure; repair is the only permitted second attempt. A skip is not a failure. A version whose container never started, whose login broke, or whose seed state differs from the base version produced no measurement: it is recorded in out/matrix-run.json with a stage and a reason and never reaches the NDJSON. Counting it as a failed run would invent a data point. Two things the live path exposed: - The committed example bundle asserts getByRole("form") on Grafana's login page, which matches zero elements — an unnamed <form> has no ARIA role. The driver reported it honestly on its first live run rather than crashing. - contracts/metrics.schema.json has additionalProperties:false and no field for a failure reason, and stub repair always proposes null, so every genuine failure emits REPAIR_EXHAUSTED. LOCATOR_NOT_FOUND, ASSERTION_FAILED and TIMEOUT are flattened into one value. Widening the contract needs an ADR, so StepAttemptResult.first_pass_outcome keeps the real outcome in memory and the driver records it in its own ledger. That is a workaround, not a fix, and both docs say so. The bundle -> CompiledProgram adapter moves from tests/integration into src/runner/program.ts. Its comment said it was test-local because "the runtime never needs it today"; this is the change that made the runtime need it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(gate): repeat runs per version, per-version variance, safe interruption Closes #66. Stacked on #91 (the live matrix runner it repeats). One run per version is not a sample. PRD section 9 specifies 3x/day for 14 days -- >=42 runs and >=400 step-executions -- and swapping the calendar for the version matrix does not change the statistics. Eight pins at one run each is 8 runs, and it cannot tell "this locator broke on v12" from "that run flaked". --runs <n>, default 3. Clearing the section 9 floor across the eight pins needs --runs 6 (48 runs); --runs 5 gives 40 and lands two short, so the issue's own suggestion does not quite clear its own floor. The shortfall is reported rather than enforced: the CLI prints it before the first container boots and report.json carries a sample section with meets_floor and the exact gap. Each run is independent -- a fresh browser context and a fresh login per run, not per version. Reusing either would correlate the repeats and understate the spread, which is the one thing repeat runs exist to measure. perVersionBreakdown() in src/metrics/aggregate.ts reports runs_attempted, runs_succeeded, step_validity_per_run and step_validity_spread. A pooled ratio makes 3/3 and 2/3 the same number. status is no_data rather than 0 when a run emitted no step rows, because 0 is indistinguishable from every step failing. Interruption is safe, and building it found a real bug: Playwright installs its own SIGINT handler and closes the browser on Ctrl-C, which fired mid-run and crashed the matrix with "Target page, context or browser has been closed". chromium.launch now takes handleSIGINT/SIGTERM/SIGHUP false -- the driver owns that lifecycle. Ctrl-C finishes the run in flight, tears down, and records the version as SKIPPED (interrupted) with how far it got. Rows are appended after every run instead of written once at the end, so a partial NDJSON is valid. No run is ever discarded, including a failed one and one cut short. Measured: three live repeats of 9.5.21 against one unchanged container agreed exactly (spread 0). That is a weak probe -- a 2-step example program -- and the 12-step gate task from #25 is where flakiness would surface. Recorded as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: myselfsiddharth <siddharthmehta0906@gmail.com>
Closes #62.
npm run gate:matrixrefused to run for real (exit 2), and the dry-run path hard-coded everystep to
PASS.ReplayRunneralready supported live mode; what was missing was the driver.It now lives in
experiments/gate-v1/live-run.ts.Per version: compose up →
/api/healthreadiness → seed → seed-fingerprint gate → Chromium →login preamble →
ReplayRunnerwithdryRun: false→ teardown in afinally. Nothing in thatfile retries a step, downgrades an outcome, or catches an assertion failure — repair is the only
permitted second attempt.
It runs, and it reports failure honestly
Two versions live, same program, on a real daemon:
Corrupted locator — every candidate in step 1's action chain broken:
{"step": 0, "outcome": "PASS"} {"step": 1, "outcome": "REPAIR_EXHAUSTED", "first_pass": "LOCATOR_NOT_FOUND"}task_success: false,steps_valid=1/2. Not a crash, not a pass.Container killed mid-run — the matrix records it and keeps going:
docker ps -aafter every run above: zeroparagent-*containers.A skip is not a failure
A version whose container never started, whose image would not pull, whose login broke, or whose
seed state differs from the base version produced no measurement. It goes to
out/matrix-run.jsonwith a stage (compose-up,readiness,seed,fingerprint,browser,login-preamble) and a reason, and never reaches the NDJSON. Recording it as a failed run wouldinvent a data point; dropping it would shrink the denominator in silence.
The fingerprint gate is the non-obvious one: if a version's seeded state differs from the base
version's, a step failure could be the seed's fault rather than the surface's, and there is no
honest way to attribute it afterwards. That version yields nothing instead of something
misleading.
Two things the live path exposed
1. The committed example bundle asserts something that cannot hold. Step 0 targets
getByRole("form")on Grafana's login page. Measured on 9.5.21:An unnamed
<form>has no ARIA role, so the assertion times out. That bundle is a compile of ahand-written example — exactly what #25 exists to replace. The driver surfaced it on its first
live run instead of crashing, which is the harness working. This PR does not fix the bundle;
hand-editing a compile artifact is not a fix, and the gate task lands with #25.
2. The NDJSON cannot say why a step failed.
contracts/metrics.schema.jsonsetsadditionalProperties: falseand has no field for a failure reason, andStubRepairModelClientalways proposes
null— so every genuine failure emitsREPAIR_EXHAUSTED, flatteningLOCATOR_NOT_FOUND,ASSERTION_FAILEDandTIMEOUTinto one value. The issue's own acceptancecriterion ("expect
LOCATOR_NOT_FOUNDin the NDJSON") is unmeetable today, including at--max-repairs 0.Widening the metric row is a contract change and is deliberately not made here. Instead
StepAttemptResult.first_pass_outcomekeeps the real outcome in memory and the driver records itin its own ledger, which is harness output rather than a contract artifact. Both
gate/runner.mdand the harness README say plainly that this is a workaround, and it is filed as an open question
needing an ADR.
Also moved: the bundle → program adapter
tests/integration/pipeline.test.tscarried it with the comment "local to this test on purpose:the runtime never needs it today". This is the change that made the runtime need it, so it moved
to
src/runner/program.tsand the integration test now exercises thesame adapter the gate runs. It is a mapping, not a schema change — no field is invented on either
contract, and cache bookkeeping (
confidence,pool_eligible, …) is dropped rather thansmuggled through.
architecture.mdbreak 2 and stub 7 are narrowed accordingly; thebundle → cache hop is still unwired.
Dry-run is unchanged and still labelled
--dry-runkeeps working — it exercises the harness without Docker and the CI job depends on it.modeis now recorded per run and dry rows still carrydry-run — tokens remain 0; not a gate measurement. Mixing live and dry rows in one report isthe easiest way to publish a fabricated gate number.
What this still does not give you
A gate number. One run per version is not a sample — §9 asks for ≥42 runs and ≥400
step-executions, and nothing here can separate churn from flakiness yet. That is #66, which
needed this driver first: repeat runs of a dry run all produce the same hard-coded row.
New flags
--headed,--keep-up,--program <path>(aCompiledProgramor acompiled_trajectorybundle),
--param k=v(repeatable),--port <n>,--no-preamblefor programs that log in as ameasured step.
base_url/host/portare bound by the driver and cannot be overridden by--param— a program must not be able to redirect itself away from the version being measured.Tests
16 new unit tests covering what decides what gets recorded, none of which need a container:
the bundle→program mapping, the fingerprint-mismatch decision, and skip-reason extraction. Guard
proven — deleting the
step_indexsort failsorders steps by step_index, not by position in the file.composeFailureReasonexists because the first version of this reported"Creating"as thereason a version was skipped: compose interleaves progress with the cause and does not
consistently pick a stream.
🤖 Generated with Claude Code