Skip to content

feat(qa-v): video ground truth harness for cmuxlayer claims - #497

Merged
EtanHey merged 7 commits into
mainfrom
wt/qa-video-harness
Aug 20, 2026
Merged

feat(qa-v): video ground truth harness for cmuxlayer claims#497
EtanHey merged 7 commits into
mainfrom
wt/qa-video-harness

Conversation

@EtanHey

@EtanHey EtanHey commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Why

cmuxlayer is the only witness to cmuxlayer. Every claim we verify is verified by the same tool suite
that produced it, so a receipt that lies is indistinguishable from one that tells the truth. Two of
the lead's diagnoses on 2026-08-19 were wrong and were caught only because other leads happened to be
watching their panes.

This lane builds the alternative Etan specified: screen-record a live probe in an isolated window,
then have cheap vision sub-agents adjudicate the frames
— evidence from outside the system under
test. One-time build cost; afterwards it validates that the unit tests are testing the right thing.

Zero src/ changes. This is a test harness, not a product feature.

What's here

file what
scripts/qa-video-harness.mjs runner: isolated cmux window, recorder, MCP stdio client, probe sequence, frame extraction
scripts/qa-video-lib.mjs pure logic: probe catalogue, frame planning, receipt reading, reconciliation, report rendering
scripts/qa-video-windows.py CoreGraphics window/display enumeration
tests/qa-video-harness.test.ts 31 tests over the pure logic and the runner's safety guards
docs/qa-video-harness.md runbook, design notes, requirements, known limits

Probes re-run the repros the fleet actually reported: send_to to a busy agent (#432/#484), send_to
to a stale-terminal registry row (#484), close_surface(scope:"agent") (#485), list_agents ×3 for
closure flap (#488), wait_for on a working agent (#473), and spawn under keystroke injection
(#434/#440). Every tool receipt is captured verbatim against the recording clock.

Adjudication is Sonnet sub-agents, in-process, one narrow question each, never shown the receipt.
The report reconciles verdict against claim into AGREE / CONTRADICT / NOT OBSERVABLE / MISSING.
Contradictions are the product.

Status — read this before believing the harness

Proven end to end, twice: dry-run → isolated recording → dense frame extraction → Sonnet
adjudication → frame-cited verdict. The adjudicator located the clapper nonce and named the frame and
timestamp.

The full six-probe suite ran live four times and a fully clean run was not achieved on this
machine.
Run 1 produced adjudicable frames for its early probes; later probes, and most of runs 2–4,
were recorded while the probe window was occluded or on a different display than the recorder was
capturing. The harness now detects exactly that and refuses to ask questions about those frames.
Report: docs.local/reports/qa-video-2026-08-19.md (gitignored).

Findings from run 1

Harness bugs the dry-run and the adjudicators caught first

Each produced frames that looked entirely plausible and showed the wrong thing. Each is now guarded
and covered by a test.

  1. Targeting "the frontmost process" cropped the first recording to a browser window and captured the
    operator's private content. Purged; the window is addressed by an assigned title now.
  2. cmux focus-window does not restack macOS windows — the operator's own cmux window got recorded.
    Three adjudicators independently reported seeing the wrong workspace.
  3. cmux does not always open its window on the main display; the recorder was capturing display 0
    while the probe window sat on display 1.
  4. Inheriting cmux's CMUX_SURFACE_ID/TAB/WORKSPACE made cmuxlayer resolve the harness as the
    operator's own agent and refuse terminal I/O to its surface.
  5. Re-asserting focus with cmux focus-window between probes churned surface topology until
    spawn_agent failed with not live or uniquely resolvable in a complete fresh topology — the
    observer was perturbing the observed.
  6. A killed harness leaked an isolated window onto the desktop.

Rejected approach, documented so it is not retried: screencapture -l <CGWindowID> captures a
single window and is immune to occlusion, display placement and focus stealing. It returns a blank
content area
for cmux, which renders its terminals with Metal.

PREDICTION

list-closure-flap (#488) is the hardest probe to adjudicate from frames, and I expect it to stay
that way.
closure is a registry-internal field with no pixels at all. The video can only answer
the nearest observable proxy — "did the pane visibly change during the span?" — and the step from
"the pane looked static" to "closure should not have flapped" is an inference, not an observation. It
can corroborate a flap; it can never witness one.

Runner-up, and this one already bit: busy-send.submitted / stale-terminal-send.submitted. The
question is binary — composer or transcript — and Cursor's TUI has a third state, a queued
"follow-ups" panel that is neither. Run 1 landed exactly there, and the adjudicator had to explain the
distinction in a free-text note rather than a verdict. That wording should go three-way before the
next run.

Verification

  • npx vitest run tests/qa-video-harness.test.ts — 31 passed
  • npx tsc -p tsconfig.json --noEmit — clean
  • CMUX_QA_VIDEO=1 npm run qa:video:dry-run — isolated recording, 60 frames extracted, Sonnet
    adjudicator returned YES citing f-0028 at t≈2.73s

🤖 Generated with Claude Code


Note

Low Risk
New scripts, docs, and tests only; product MCP code is untouched. Risk is operational (screen recording, live cmux/agents, macOS permissions) rather than production runtime behavior.

Overview
Adds an opt-in macOS QA video harness (CMUX_QA_VIDEO=1) that records an isolated cmux probe window and compares MCP tool receipts to pixels via vision adjudication—no src/ changes.

Runner (scripts/qa-video-harness.mjs): creates a dedicated QAV-* window, uses CoreGraphics (qa-video-windows.py) for display/crop/occlusion, records with ffmpeg/avfoundation and a wall↔video clock anchor, runs six fleet repro probes (or a clapper dry-run) over MCP stdio with sterile env (caller identity stripped), logs marks/receipts on the timeline, extracts JPEG frames, splits questions.json vs expectations.json, prunes old runs, and supports a report subcommand.

Pure lib (scripts/qa-video-lib.mjs): probe catalogue, frame windows, receipt interpretation, reconciliation (AGREE/CONTRADICT/NOT OBSERVABLE/MISSING), and Markdown reports.

Wiring: qa:video / qa:video:dry-run in package.json, runbook in docs/qa-video-harness.md, gitignore for results/qa-video/, and 31 vitest cases in tests/qa-video-harness.test.ts.

Reviewed by Cursor Bugbot for commit e8dff24. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add video ground truth harness for cmuxlayer claims

  • Adds qa-video-harness.mjs to record an isolated cmux window via ffmpeg, run probes against an MCP server, and emit adjudication artifacts.
  • Adds qa-video-lib.mjs with pure utilities for probe specs, receipt parsing, frame planning, manifest building, reconciliation, and report rendering.
  • Adds qa-video-windows.py to enumerate macOS CoreGraphics windows and displays for geometry and occlusion detection.
  • Adds tests in qa-video-harness.test.ts, documentation in qa-video-harness.md, and qa:video npm scripts.
  • Risk: harness is macOS-only and requires CMUX_QA_VIDEO=1; incorrect probeWindowGeometry crop coordinates or unoccluded checks can yield misleading frame evidence.

Macroscope summarized e8dff24.

EtanHey and others added 4 commits August 19, 2026 21:32
cmuxlayer is currently the only witness to cmuxlayer: every claim is
verified by the same tool suite that produced it, so a receipt that lies
looks exactly like one that tells the truth. This adds evidence from
outside the system under test.

The harness opens an isolated cmux window, screen-records it, runs the
repros the fleet actually reported (#432/#484 busy send, #484 stale
terminal row, #485 close_surface scope=agent, #488 closure flap, #473
wait_for on a working agent, #434/#440 spawn under keystroke injection),
captures every tool receipt verbatim against the recording clock, and
emits an adjudication manifest that Sonnet vision sub-agents answer one
narrow question at a time. The report reconciles receipt against pixels;
contradictions are the product.

Zero src/ changes. New files only:
- scripts/qa-video-harness.mjs  runner (cmux window lifecycle, ffmpeg
  recorder, MCP stdio client, probe sequence, frame extraction)
- scripts/qa-video-lib.mjs      pure probe catalogue, frame planning,
  receipt reading, reconciliation, report rendering
- tests/qa-video-harness.test.ts  27 tests over the pure logic
- docs/qa-video-harness.md      runbook

Four bugs the dry-run caught before the harness ever touched live panes,
each now guarded and covered by a test:
- targeting "the frontmost process" resolved to whatever the human last
  touched; the first recording cropped to a browser and captured private
  content. The window is now addressed by the title the harness assigns.
- cmux focus-window does not restack macOS windows, so the operator's own
  window got recorded. AXRaise plus an AXMain check now proves isolation,
  and the run aborts rather than recording the whole desktop.
- inheriting cmux's CMUX_SURFACE_ID/TAB/WORKSPACE made cmuxlayer resolve
  the harness as the operator's own agent and refuse terminal I/O.
- re-asserting focus with cmux focus-window between probes churned surface
  topology until spawn_agent failed; the re-assert is AX-only now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uncommitted work parked by the lead during a fleet-wide converge-before-close;
not reviewed, not claimed complete.

Co-Authored-By: cmuxlayerClaude running claude-fable-5 <noreply@anthropic.com>
Co-Authored-By: cmuxlayerClaude running claude-fable-5 <noreply@anthropic.com>
Four live runs of the harness produced frames that looked plausible and
showed the wrong thing. Each cause is now guarded, and each guard is a
test.

- The probe window is located through CoreGraphics (scripts/qa-video-windows.py),
  not System Events. A freshly created cmux window is intermittently absent
  from the accessibility window list, and drops out of the on-screen list
  whenever its Space is inactive, so a single miss is a flap and is retried.
- The recorder captures the display the probe window is actually on. cmux
  does not always open on the main display; two runs recorded display 0
  while the window sat on display 1.
- Isolation prefers a whole display over a z-order fight. The harness runs
  from inside a cmux pane, so the operator's own cmux window is raised by
  the very commands driving the probe; occlusion is per-display, so moving
  the probe window to the least-occupied display makes stacking moot. On a
  single-display machine this is a no-op and the z-order checks still apply.
- Occlusion is judged from CoreGraphics front-to-back order. Every mark
  records whether the window was clear, the manifest refuses to generate a
  question for any mark that was not, and the run aborts rather than
  recording a covered region.
- Per-probe re-assert is AXRaise only. Calling cmux focus-window between
  probes churned surface topology until spawn_agent failed with "not live
  or uniquely resolvable in a complete fresh topology".
- SIGINT/SIGTERM tear the isolated window down, so an interrupted run does
  not leak a window onto the desktop.
- Frame windows reach further past each mark: a mark is the instant of the
  tool call and the pixels lag it by ~1.7s, measured.

Documents one rejected approach so it is not retried: screencapture -l
<CGWindowID> captures a single window and is immune to occlusion, display
placement and focus stealing, but cmux renders terminals with Metal, so it
returns the chrome with a blank content area.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@cursor

cursor Bot commented Aug 19, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_d6d991e7-45c3-4f5c-a8c5-b1f508bc198d)

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@EtanHey, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ec15316f-a69b-4af1-a92a-5a9e4868d9b8

📥 Commits

Reviewing files that changed from the base of the PR and between 8091677 and e8dff24.

📒 Files selected for processing (7)
  • .gitignore
  • docs/qa-video-harness.md
  • package.json
  • scripts/qa-video-harness.mjs
  • scripts/qa-video-lib.mjs
  • scripts/qa-video-windows.py
  • tests/qa-video-harness.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

busySpawn.error = spawnA.error ?? "spawn_agent returned no agent_id";
return;
}
const busyState = await pollAgentState(client, agentA, (agent) => agent.state === "working", 90_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-harness.mjs:956

When pollAgentState does not observe working, runFullProbes still records normal busy-send and wait-for-working adjudications even though their precondition was not met; registryStateAtSend is never used to suppress or invalidate those questions. The stale-terminal path also treats idle as settled, so it runs stale-terminal-send for a non-terminal registry row. Gate each probe on its required state and mark skipped precondition failures as unadjudicable.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around line 956:

When `pollAgentState` does not observe `working`, `runFullProbes` still records normal `busy-send` and `wait-for-working` adjudications even though their precondition was not met; `registryStateAtSend` is never used to suppress or invalidate those questions. The stale-terminal path also treats `idle` as settled, so it runs `stale-terminal-send` for a non-terminal registry row. Gate each probe on its required state and mark skipped precondition failures as unadjudicable.

if (before.has(windowId)) {
throw new Error(`new-window returned a pre-existing window (${windowId}); refusing to proceed`);
}
await sleep(1_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-harness.mjs:881

A failure while listing the workspace, renaming the window, or focusing it leaves the newly created cmux window open on the desktop. runOnce only installs teardown after createProbeWindow resolves, so rejected setup never reaches destroyProbeWindow; close the window in a catch inside createProbeWindow before rethrowing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around line 881:

A failure while listing the workspace, renaming the window, or focusing it leaves the newly created cmux window open on the desktop. `runOnce` only installs teardown after `createProbeWindow` resolves, so rejected setup never reaches `destroyProbeWindow`; close the window in a `catch` inside `createProbeWindow` before rethrowing.

Comment thread scripts/qa-video-lib.mjs
Comment on lines +467 to +469
if (contradictions.length === 0) {
lines.push("None. Every adjudicable receipt matched the frames.");
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-lib.mjs:467

The prominent Contradictions section reports a clean match when adjudication has MISSING rows, so a run with unanswered or invalid verdicts is presented as successful. reconcile deliberately assigns those rows MISSING, but this branch checks only contradictions.length; include missing results in the clean-case condition and state that adjudication is incomplete when they exist.

-  if (contradictions.length === 0) {
-    lines.push("None. Every adjudicable receipt matched the frames.");
-  } else {
+  if (contradictions.length === 0 && totals.MISSING === 0) {
+    lines.push("None. Every adjudicable receipt matched the frames.");
+  } else if (contradictions.length === 0) {
+    lines.push(`None found, but ${totals.MISSING} adjudication result(s) are missing or invalid.`);
+  } else {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-lib.mjs around lines 467-469:

The prominent `Contradictions` section reports a clean match when adjudication has `MISSING` rows, so a run with unanswered or invalid verdicts is presented as successful. `reconcile` deliberately assigns those rows `MISSING`, but this branch checks only `contradictions.length`; include missing results in the clean-case condition and state that adjudication is incomplete when they exist.

Comment thread scripts/qa-video-lib.mjs
text: (ctx) =>
`Two seconds after the send, is \`${ctx.nonce}\` STILL sitting unsent in the composer of ${ctx.paneHint}? Answer YES if it is still in the composer (i.e. it was never submitted), NO if the composer is clear or the text has moved into the transcript above the composer (i.e. it was submitted).`,
// A receipt that claims submitted implies the composer is clear -> NO.
expectedIfReceiptTrue: (step) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-lib.mjs:48

The submitted expectations return "YES" for every receipt that is not submitted, so an undelivered send_to call (or a step with no receipt) is reconciled as CONTRADICT even though the nonce is correctly absent from the composer. The same logic exists in stale-terminal-send; return "YES" only for delivered or queued-but-unsubmitted receipts, and "NO" when receiptDelivered(step) is false.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-lib.mjs around line 48:

The `submitted` expectations return `"YES"` for every receipt that is not `submitted`, so an undelivered `send_to` call (or a step with no receipt) is reconciled as `CONTRADICT` even though the nonce is correctly absent from the composer. The same logic exists in `stale-terminal-send`; return `"YES"` only for delivered or queued-but-unsubmitted receipts, and `"NO"` when `receiptDelivered(step)` is false.

Comment thread scripts/qa-video-lib.mjs
);
const rows = manifest.questions.map((question) => {
const verdict = byId.get(question.id);
if (question.unadjudicable_reason && !verdict) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-lib.mjs:396

An unadjudicable question with a supplied verdict is currently reconciled as AGREE or CONTRADICT, so occluded or frame-less evidence can incorrectly affect the report. The guard only forces NOT_OBSERVABLE when !verdict; remove that condition so question.unadjudicable_reason always takes precedence.

Suggested change
if (question.unadjudicable_reason && !verdict) {
if (question.unadjudicable_reason) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-lib.mjs around line 396:

An unadjudicable question with a supplied verdict is currently reconciled as `AGREE` or `CONTRADICT`, so occluded or frame-less evidence can incorrectly affect the report. The guard only forces `NOT_OBSERVABLE` when `!verdict`; remove that condition so `question.unadjudicable_reason` always takes precedence.

Comment thread scripts/qa-video-harness.mjs
Comment on lines +249 to +255
.filter(
(window) =>
window.layer === probe.layer &&
window.bounds.w > 40 &&
window.bounds.h > 40 &&
rectsIntersect(window.bounds, probe.bounds),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High scripts/qa-video-harness.mjs:249

Higher-layer windows that appear ahead of the probe are ignored, so geometry.clear can be true while the recording region is visibly covered by unrelated or private content. The filter only treats windows with window.layer === probe.layer as occluders; consider checking every sufficiently large window ahead of the probe regardless of layer.

Suggested change
.filter(
(window) =>
window.layer === probe.layer &&
window.bounds.w > 40 &&
window.bounds.h > 40 &&
rectsIntersect(window.bounds, probe.bounds),
)
(window) =>
window.bounds.w > 40 &&
window.bounds.h > 40 &&
rectsIntersect(window.bounds, probe.bounds),
)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around lines 249-255:

Higher-layer windows that appear ahead of the probe are ignored, so `geometry.clear` can be `true` while the recording region is visibly covered by unrelated or private content. The filter only treats windows with `window.layer === probe.layer` as occluders; consider checking every sufficiently large window ahead of the probe regardless of layer.


async stop() {
if (!this.child) return;
const exited = new Promise((done) => this.child.once("close", done));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High scripts/qa-video-harness.mjs:556

When ffmpeg has already emitted close, Recorder.stop() waits forever, so a failed start() or unexpected recorder exit hangs runOnce in its finally block instead of reporting the failure and cleaning up. Check the child’s exit state before waiting for a future close event.

-    const exited = new Promise((done) => this.child.once("close", done));
+    if (this.child.exitCode !== null || this.child.signalCode !== null) {
+      this.child = null;
+      return;
+    }
+    const exited = new Promise((done) => this.child.once("close", done));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around line 556:

When ffmpeg has already emitted `close`, `Recorder.stop()` waits forever, so a failed `start()` or unexpected recorder exit hangs `runOnce` in its `finally` block instead of reporting the failure and cleaning up. Check the child’s exit state before waiting for a future `close` event.

Comment thread scripts/qa-video-harness.mjs
@EtanHey

EtanHey commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Review — PR #497, head 59f3e5e

VERDICT: ITERATE — two blockers, three should-fixes. The design is right and the hard part (isolating and recording a specific window, refusing to adjudicate occluded frames) is genuinely solved. What is not yet solid is the clock the whole thing hangs off, and the tests that are supposed to protect the safety properties.

Everything below was reproduced from this seat. Mutations ran in a throwaway clone, never in the worktree.


BLOCKER 1 — the recording clock anchors to the END of the recording

Recorder.start() resolves on the first -progress block carrying a real out_time_us, but it never removes the stdout data listener. Every later progress block reassigns this.t0WallMs / this.t0VideoS, so by the time the run finishes the anchor has walked to the last block.

This is not a reading of the code — it is in the artifact this PR ships:

results/qa-video/dry-run-2026-08-19T19-08-54-359Z/run.json
  video.t0VideoS  = 17.133334
  video.durationS = 17.133334      <- identical to six decimals
  video.t0WallMs  = 1787166560681
  steps[0].marks.clap.wallMs = 1787166554084   <- 6.597s BEFORE the anchor
  steps[0].marks.clap.videoS = 9.097

If the anchor were taken at recording start, t0VideoS would be ≈0 and t0WallMs would precede every mark. Instead the anchor sits at the end of a 17.1s video, 6.6s after a mark stamped at 9.097s.

Marks are currently right by accident: RunLog.mark() always calls secondsAt(nowMs()), and an anchor that keeps updating to "now" makes secondsAt(now) come out approximately correct. Nothing else is. The anchor persisted into run.json is wrong by the full run length, and wallToVideoSeconds(video, wallMs) — the exported, unit-tested helper written for exactly this job — returns garbage when handed it. That is brief item 3 (frames addressable by time so a frame can be tied to a receipt timestamp) failing at the root, in a harness whose entire product is time-correlated evidence.

Fix: detach the listener once anchored (this.child.stdout.off("data", handler)), or guard with if (this.t0WallMs !== null) return;. Then make mark() go through wallToVideoSeconds() so the persisted anchor and the live one cannot diverge again.

BLOCKER 2 — 11 of the 31 tests certify prose, not behaviour

tests/qa-video-harness.test.ts tests the runner by readFileSync-ing it and asserting toContain on its source — including on its comments. I broke three of the safety properties those tests are named after. All 31 tests still passed, every time:

mutation what it breaks result
sterileEnv()return {...env} caller identity leaks into the MCP server — the exact failure its own comment describes 31 passed
SIGINT/SIGTERM onSignal → empty body a killed harness leaks the isolated window onto the desktop 31 passed
preflight gate → if (false) full run drives live panes with no recorder self-test 31 passed

Each mutation left the grepped strings in place — the function name, the CMUX_SURFACE_ID constant, the process.once("SIGINT", onSignal) line, the error message — because those strings are all the tests check. it("keeps the rejected window-capture approach documented so it is not retried") asserts toContain("REJECTED") and toContain("Metal"), both of which live only in a comment: it would pass with the recorder deleted and the comment kept.

Root cause is mechanical: qa-video-harness.mjs calls main() at module scope, so nothing in it can be imported and tested. Fix: guard the entry point (if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()), then test assertOptIn, sterileEnv, parseArgs, rectsIntersect and displayContaining for real. assertOptIn(env) and sterileEnv(env) already take their environment as a parameter — they were written to be testable and then weren't.

Credit where it is due: the other 20 tests, over qa-video-lib.mjs, are the real thing. I mutated three properties there and each was caught by exactly one correctly-named test:

mutation caught by
occlusion guard → if (false) refuses to ask about frames recorded while the probe window was occluded
missing verdict → AGREE reports a partial adjudication as MISSING rather than as a pass
frame density → 1 fps samples densely around the mark rather than uniformly across the video

That is what the other eleven should look like.


SHOULD-FIX 3 — adjudicator independence is enforced by prose, not by structure

Brief item 2, and the good news first: the reader is genuinely independent. Verdicts come from outside as a verdicts.json; reconcile() compares them against expected_if_receipt_true. The receipt is the claim under test, never the source of the answer. This is not the failure mode the brief warns about.

But docs/qa-video-harness.md says "Never show a sub-agent the receipt" while manifest.json puts receipt_claim and expected_if_receipt_true in the same object as question, frame_dir and frame_times. The one witness that is supposed to be outside the system is one careless "here's the manifest entry" away from being told the convenient answer, and the thing standing in the way is a sentence in a runbook.

Fix: emit two files — questions.json (id, question, frame_dir, frame_times) for the adjudicator, and expectations.json (id, receipt_claim, expected_if_receipt_true) for report. Then the adjudicator cannot see the receipt, and the property holds without depending on the operator's discipline.

SHOULD-FIX 4 — no cap, no cleanup, no retention

Brief item 4, measured rather than estimated. From the shipped dry-run: the recording is 3456×2168 at 0.37 MB/s, and PNG frames extracted from it average 2.58 MB each. A full run plans 9 questions covering 68s of window at 10 fps ≈ 680 frames ≈ 1.7 GB of PNGs, before the preflight run's own artifacts and the video. grep finds no rm, prune, retention or size cap anywhere in the script or the docs; --scale-width defaults to native. results/qa-video/ accumulates until someone notices.

The cheapest real fix is already half-written: the extraction passes -q:v 2, which the PNG encoder ignores — it is dead config today. Point the same flag at .jpg output and I measured 1.18 MB vs 2.58 MB per frame at unchanged resolution, a 2.2× cut for free. The consumer is a vision model reading terminal text, not an archive. Add a printed artifact size in the closing banner and a documented prune command.

SHOULD-FIX 5 — the frame↔time mapping is inferred, never measured

planFrameWindow predicts exactly one frame more than ffmpeg produces, every time. Measured against the shipped video:

window planned actual
[1, 4] @ 10fps 31 30
[0, 7] @ 10fps 71 70
[2, 4.5] @ 10fps 26 25
[5, 6] @ 15fps 16 15

runOnce absorbs it silently: frame_times = frame_times.slice(0, present.length). That truncates from the end, which is correct only if the missing frames are trailing — true for this off-by-one, and the whole mapping still rests on the assumption that f-000N sits at start + (N-1)/fps. Screen capture is variable-framerate at source; the fps= filter normalises it, but nothing here ever checks. One dropped frame mid-window silently mislabels every frame after it, and the harness would report a confident timestamp for the wrong pixels.

Fix: correct the count (Math.round((end - start) * fps)), and extract with -frame_pts 1 or a showinfo sidecar so a frame's time is read, not inferred. Then brief item 3 is true by construction rather than by luck.


What holds up

  • Item 1 — it records the right thing. A dedicated QAV-<runId> window, cropped from CoreGraphics bounds, following the window to whichever display cmux actually put it on, and refusing to record at all when another window covers the region. Capture 15 fps (--capture-fps), extraction 10 fps (--frame-fps), both configurable; windows are tight around each mark (2.5s–21.5s) rather than uniform across the video. The rejected screencapture -l approach is documented with the reason (Metal-rendered terminals come back blank), which is exactly the note that stops someone re-litigating it in six months.
  • Item 5 — it stays out of the normal test path. tests/qa-video-harness.test.ts does file reads only, no spawn, no capture. The harness is gated on CMUX_QA_VIDEO=1 and reachable only through its own npm scripts. Nothing in bun run test touches a screen.
  • The occlusion refusal works in the field. The one committed run directory is an occluded run: frontmost: false, extracted_frames: 0, and the manifest carrying "the probe window was occluded at this mark; the recording captured a different window" instead of a question. The harness caught itself. Worth saying plainly, because it also means the body's "60 frames extracted, adjudicator cited f-0028" has no artifact backing it in the tree — that successful run's output is gone.
  • Zero src/ changes, results/qa-video/ correctly gitignored and untracked (git check-ignore confirms; no results path in the diff).

Smaller notes

  • wallToVideoSeconds() is exported and unit-tested but called by nothing in the runner — grep finds the definition plus three test references and no production caller. Either route mark() through it (which would have surfaced Blocker 1) or delete it.
  • agentReadyTimeoutMs sits in DEFAULTS with no matching parseArgs case: a constant wearing an option's clothes.
  • parseArgs validates captureFps/frameFps/waitTimeoutMs but not scaleWidth; --scale-width abc yields NaN and an ffmpeg filter of scale=NaN:-2.

Local and CI

The lane delivered the thing that is hard to get right — evidence from outside the system under test, with the harness honest enough to refuse its own bad frames. Fix the clock and make the wiring tests bite, and this is the ground truth cmuxlayer has been missing.

— cmuxlayerClaude-reviewer-497 (reviewer) · claude-code/claude-opus-5

EtanHey and others added 2 commits August 20, 2026 07:18
Keep the first recorder progress anchor stable, expose behavioral test seams, split adjudication prompts from expectations, and use bounded JPEG extraction with PTS-derived timing.

Co-Authored-By: cmuxlayerCodex-d776c1b4 running gpt-5.6-sol <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 20, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_d5fc18e7-40b8-4018-8cdf-23aeb8534714)


/** Accurate-seek extraction whose filenames carry the actual filtered frame PTS. */
async function extractFrames({ video, outDir, relativeDir, start, end, fps }) {
await mkdir(outDir, { recursive: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-harness.mjs:672

Re-running the harness with the same --root includes stale JPEGs from the previous run in the current manifest, so earlier recording frames can be presented as evidence for the new run. extractFrames creates outDir without clearing it, and readExtractedFrameMapping reads every matching file, including higher-numbered files that the new extraction did not overwrite. Clear outDir before creating it.

Suggested change
await mkdir(outDir, { recursive: true });
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around line 672:

Re-running the harness with the same `--root` includes stale JPEGs from the previous run in the current manifest, so earlier recording frames can be presented as evidence for the new run. `extractFrames` creates `outDir` without clearing it, and `readExtractedFrameMapping` reads every matching file, including higher-numbered files that the new extraction did not overwrite. Clear `outDir` before creating it.

exit = (code) => process.exit(code),
} = {},
) {
const onSignal = () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High scripts/qa-video-harness.mjs:1228

When cmux close-window stalls, SIGINT/SIGTERM never terminates the harness, so Ctrl-C or service shutdown can hang indefinitely. onSignal waits indefinitely for the execFileFn callback; add a bounded fallback that calls exit(130) and guard against exiting twice.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around line 1228:

When `cmux close-window` stalls, SIGINT/SIGTERM never terminates the harness, so Ctrl-C or service shutdown can hang indefinitely. `onSignal` waits indefinitely for the `execFileFn` callback; add a bounded fallback that calls `exit(130)` and guard against exiting twice.

const completed = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!existsSync(join(baseDir, entry.name, "run.json"))) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/qa-video-harness.mjs:1279

An interrupted run with only run.json is classified as completed and can be deleted by retention, so partial runs are not preserved as the function's contract promises. Require questions.json and expectations.json (or a final completion marker written last) before adding a directory to completed.

-    if (!existsSync(join(baseDir, entry.name, "run.json"))) continue;
+    if (!existsSync(join(baseDir, entry.name, "run.json")) || !existsSync(join(baseDir, entry.name, "questions.json")) || !existsSync(join(baseDir, entry.name, "expectations.json"))) continue;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around line 1279:

An interrupted run with only `run.json` is classified as completed and can be deleted by retention, so partial runs are not preserved as the function's contract promises. Require `questions.json` and `expectations.json` (or a final completion marker written last) before adding a directory to `completed`.

Comment thread scripts/qa-video-lib.mjs
Comment on lines +298 to +312
if (mark && mark.frontmost === false) {
questions.push({
id,
step: step.id,
step_title: spec.title,
issues: spec.issues,
mark: question.mark,
mark_video_s: mark.videoS,
question: safeText(question, step),
receipt_claim: claim,
expected_if_receipt_true: safeExpected(question, step),
frames: [],
frame_times: [],
unadjudicable_reason:
"the probe window was occluded at this mark; the recording captured a different window",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical scripts/qa-video-lib.mjs:298

When the runner sets run.video.occlusionRisk, buildAdjudicationManifest still emits the captured frame paths as adjudicable evidence, allowing pixels from another window to be sent to the vision worker. The current check only examines mark.frontmost at the individual mark, so gate the manifest on the run-level occlusion flag and mark those questions unadjudicable.

-      if (mark && mark.frontmost === false) {
+      if (run?.video?.occlusionRisk === true || (mark && mark.frontmost === false)) {
         questions.push({
           id,
           step: step.id,
           step_title: spec.title,
           issues: spec.issues,
           mark: question.mark,
           mark_video_s: mark.videoS,
           question: safeText(question, step),
           receipt_claim: claim,
           expected_if_receipt_true: safeExpected(question, step),
           frames: [],
           frame_times: [],
-          unadjudicable_reason:
-            "the probe window was occluded at this mark; the recording captured a different window",
+          unadjudicable_reason:
+            "the recording may include another window because the probe was occluded during the run",
         });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-lib.mjs around lines 298-312:

When the runner sets `run.video.occlusionRisk`, `buildAdjudicationManifest` still emits the captured frame paths as adjudicable evidence, allowing pixels from another window to be sent to the vision worker. The current check only examines `mark.frontmost` at the individual mark, so gate the manifest on the run-level occlusion flag and mark those questions unadjudicable.

Comment on lines +272 to +273
const display = displayContaining(state.displays, probe.bounds);
if (!display) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High scripts/qa-video-harness.mjs:272

A probe window that crosses a display edge or extends off-screen is marked clear/usable and produces a crop outside the selected display, so the recorder passes invalid geometry to ffmpeg and the run can fail or omit part of the window. displayContaining selects by center, but this function never verifies that probe.bounds is wholly within display.bounds; reject such geometry before constructing the crop.

  const display = displayContaining(state.displays, probe.bounds);
-  if (!display) return null;
+  if (
+    !display ||
+    probe.bounds.x < display.bounds.x ||
+    probe.bounds.y < display.bounds.y ||
+    probe.bounds.x + probe.bounds.w > display.bounds.x + display.bounds.w ||
+    probe.bounds.y + probe.bounds.h > display.bounds.y + display.bounds.h
+  ) return null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qa-video-harness.mjs around lines 272-273:

A probe window that crosses a display edge or extends off-screen is marked `clear`/usable and produces a crop outside the selected display, so the recorder passes invalid geometry to ffmpeg and the run can fail or omit part of the window. `displayContaining` selects by center, but this function never verifies that `probe.bounds` is wholly within `display.bounds`; reject such geometry before constructing the crop.

@EtanHey

EtanHey commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

Re-review — PR #497 round 2, head 9cbcf7d

VERDICT: ITERATE — narrow. Every round-1 finding is genuinely fixed and I verified each one from
this seat rather than reading the report. The one thing standing in the way is two guards added in
this round that no test protects — and one of them is the code path that deletes the operator's
evidence. No production change is needed; this is two tests.

Mutations ran in a throwaway copy at /tmp/.../mut497. The worktree was clean before and after
(git status --porcelain empty at both ends, and after every single revert).


The four required mutations: all four reproduce, exactly as reported

Baseline tests/qa-video-harness.test.ts36 passed. Each mutation applied alone, reverted before
the next. Each produced exactly one failure, by the named test:

mutation result test that caught it
sterileEnv → identity (drop the delete loop) 35 passed, 1 failed strips caller identity while preserving the cmux socket address
onSignal() => {} 35 passed, 1 failed closes the isolated window on SIGINT and SIGTERM
preflight gate → if (false) 35 passed, 1 failed refuses a full run when the recorder preflight produced no frames
recorder stdout.off("data", onProgress) deleted 35 passed, 1 failed keeps the first recorder progress block as the stable clock anchor

Round 2's central claim holds. The tests bite now.

B1 — fixed, and the regression test is the real thing

Recorder.start() sets t0WallMs/t0VideoS on the first positive out_time_us and then detaches
the listener (qa-video-harness.mjs:588). secondsAt() routes through wallToVideoSeconds()
(:597), and RunLog.mark() goes through secondsAt() (:881), so the persisted anchor and the
live one are now the same number by construction — a persisted t0VideoS can no longer walk to the
run duration. The test drives a fake child through two progress blocks and asserts the anchor
does not move; that is what the mutation above kills.

B2 — structurally fixed

isMainModule(import.meta.url) guards the entry point. I imported the runner with process.argv[1]
pointed elsewhere: imported clean in 7ms, 14 exports, main() did not run. And I grepped the
test file myself — no readFileSync of the runner, no assertion on source text or comments
anywhere.
The surviving toContain calls are all on runtime values: manifest objects, generated
markdown, and the actual ffmpeg argv (:494). The 11 prose tests are gone.

S3 — independence is structural now

manifest.json is no longer written at all. splitAdjudicationManifest() destructures
receipt_claim and expected_if_receipt_true out of every question; only questions.json
(receipt-free) and expectations.json reach disk, and they are rejoined only inside report().
Leaking the claim back into the questions file kills
keeps receipt claims structurally absent from the adjudicator questions file. The property no
longer depends on a sentence in the runbook.

S4 — the code deletes what it claims, and nothing else

pruneRunDirectories only considers directories, only ones containing run.json, sorts newest-first,
keeps keep, removes the rest — and main() skips pruning entirely when --root is set
(:1432-1436), so a custom root is never touched. --keep-runs 0 is rejected by parseArgs
(positive-integer validation at :147), so retention cannot be argued down to zero. JPEG -q:v 2,
750-frame cap enforced by assertFrameBudget before extraction, size printed in the closing banner.
The logic is correct. See the finding below for what is missing around it.

S5 — measured, not inferred

planFrameWindow now uses Math.round((end - start) * fps). I re-ran round 1's failing cases against
the shipped dry-run-2026-08-19.../video.mov with the production ffmpeg invocation:

window planned actual
[2, 4.5] @ 10fps 25 25
[5, 6] @ 15fps 15 15

PTS filenames come back contiguous from f-0000000000, and readExtractedFrameMapping derives
timeS from the parsed PTS rather than array position — relabelling by index kills
reads timestamp gaps from frame PTS filenames instead of relabelling by array index. The off-by-one
is gone and the mapping is read, not assumed. A mid-window drop now shows up as a gap in the PTS
sequence instead of silently shifting every later frame.


The finding — two round-2 guards that no test protects

I mutated the code round 2 added, not just the code round 1 complained about. Six of eight died.
Two survived with all 36 tests green:

1. (the one that matters) pruneRunDirectories — the incomplete-run guard is unprotected.

Deleting qa-video-harness.mjs:1279:

    if (!existsSync(join(baseDir, entry.name, "run.json"))) continue;

36 passed. Prune now deletes half-written runs — a crashed or in-progress capture, exactly the
artifact an operator most wants to keep — and nothing in the suite notices. Every fixture in
prunes old completed runs while preserving the newest configured runs writes a run.json, so the
completeness half of the property is never exercised.

This is the one path in the PR that calls rm(..., { recursive: true, force: true }) on a user's
directory, and the round-2 report lists "incomplete runs are preserved" as a delivered property. It
isn't a delivered property; it's an untested line. The shipped code is correct — this is purely
the round-1 failure mode (a named safety property with no test that bites) reappearing on the
destructive path.

Fix: add a directory with no run.json to that fixture and assert it survives a keep: 1 prune.

2. combineAdjudicationArtifacts — the run-id cross-check is unprotected.

Changing qa-video-lib.mjs:396 to if (false)36 passed. That guard is what stops
questions.json from run A being reconciled against expectations.json from run B, which would
produce a confident report scoring the wrong claims against the wrong frames. Same class, lower blast
radius. One expect(() => combine(qA, expB)).toThrow(/different QA video runs/).

Smaller notes (non-blocking)

  • formatBytes is exported but referenced by no test — it is used in production (:1444), so the
    export is just surface. Drop the keyword or cover it.
  • "custom roots are not automatically pruned" is real but lives in un-exported main(), so it is
    claimed and untestable. Not worth restructuring main() for; worth knowing it rests on inspection.
  • Round 1's agentReadyTimeoutMs note: it is used (three call sites); it simply has no CLI flag.
    Fine as a constant.
  • run.json still holds the receipts and sits in the same directory whose frames/ the adjudicator
    is handed. The split is honoured by the artifacts we point at, but a sub-agent given a frame path
    is one .. from the receipts. Worth a line in the runbook, not a code change.

Verification from this seat

  • Worktree .worktrees/qa-video-harness at 9cbcf7d, clean before and after all mutations.
  • bun run typecheckexit 0.
  • bun run test138 files, 3204 passed, 1 skipped (3205), 37.39s. Full green locally, including
    the release-receipts and send_to files that were red pre-fix(ci): the suite was green only on the maintainer's Mac (#490) #494.
  • tests/qa-video-harness.test.ts alone → 36 passed.
  • Import probe: runner imports without executing main().
  • PR touches 7 files; zero src/ changes; no *.test.ts under docs.local/; the recorder test
    injects spawnFn, so nothing captures a screen during bun run test.
  • YAGNI: every one of the ~300 net runner lines traces to a round-1 finding (B2 testability seams,
    S3 split, S4 cap/prune/size, S5 PTS mapping). Nothing speculative.

The lane fixed the clock, made the wiring tests bite, and made adjudicator independence a property of
the artifacts instead of the runbook. Close the two coverage holes above — no production edit — and
this is ready.

— cmuxlayerClaude-reviewer-497r2 (reviewer) · claude-code/claude-opus-5

Co-Authored-By: cmuxlayerCodex-6a37496e running gpt-5.6-sol <noreply@openai.com>
@cursor

cursor Bot commented Aug 20, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_e205269c-6817-473c-b2ab-f1b985d9bf6b)

@EtanHey
EtanHey merged commit 0146ba5 into main Aug 20, 2026
7 checks passed
@EtanHey
EtanHey deleted the wt/qa-video-harness branch August 20, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant