harnery 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -2
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +61 -1
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +48 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +349 -21
  17. package/dist/commands/fetch.js +1 -0
  18. package/dist/commands/qa-record.d.ts +144 -0
  19. package/dist/commands/qa-record.d.ts.map +1 -0
  20. package/dist/commands/qa-record.js +0 -0
  21. package/dist/commands/qa-run.d.ts +7 -3
  22. package/dist/commands/qa-run.d.ts.map +1 -1
  23. package/dist/commands/qa-run.js +268 -13
  24. package/dist/commands/qa-status.d.ts +71 -0
  25. package/dist/commands/qa-status.d.ts.map +1 -0
  26. package/dist/commands/qa-status.js +490 -0
  27. package/dist/commands/qa-verify.d.ts +40 -0
  28. package/dist/commands/qa-verify.d.ts.map +1 -0
  29. package/dist/commands/qa-verify.js +180 -0
  30. package/dist/commands/review-pack.d.ts +4 -0
  31. package/dist/commands/review-pack.d.ts.map +1 -0
  32. package/dist/commands/review-pack.js +1001 -0
  33. package/dist/core/agents/qa-signal.d.ts +111 -0
  34. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  35. package/dist/core/agents/qa-signal.js +231 -0
  36. package/dist/core/agents/session-name-display.d.ts +20 -5
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +67 -7
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  41. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  43. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  44. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  45. package/dist/core/agents/state/live-coordination-view.js +1 -0
  46. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  47. package/dist/core/artifacts/constants.d.ts +1 -1
  48. package/dist/core/artifacts/constants.js +1 -1
  49. package/dist/core/artifacts/index.d.ts +57 -6
  50. package/dist/core/artifacts/index.d.ts.map +1 -1
  51. package/dist/core/artifacts/index.js +265 -10
  52. package/dist/core/config.d.ts +8 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +16 -0
  55. package/dist/core/diagnostics/bundle.d.ts +16 -0
  56. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  57. package/dist/core/diagnostics/bundle.js +101 -7
  58. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  59. package/dist/core/events/v3/bootstrap.js +10 -0
  60. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  61. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  62. package/dist/core/events/v3/coordination-view.js +69 -8
  63. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  64. package/dist/core/events/v3/producers/intake.js +9 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts +23 -0
  66. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  67. package/dist/core/events/v3/producers/recorder.js +264 -18
  68. package/dist/core/hooks/cli.js +120 -27
  69. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  70. package/dist/core/hooks/resolve/transcript.js +10 -3
  71. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  72. package/dist/core/hooks/session-name-presence.js +4 -1
  73. package/dist/core/qa-artifacts.d.ts +20 -0
  74. package/dist/core/qa-artifacts.d.ts.map +1 -0
  75. package/dist/core/qa-artifacts.js +110 -0
  76. package/dist/core/resources/contract.d.ts +6 -0
  77. package/dist/core/resources/contract.d.ts.map +1 -1
  78. package/dist/core/resources/sampler.d.ts +7 -0
  79. package/dist/core/resources/sampler.d.ts.map +1 -1
  80. package/dist/core/resources/sampler.js +106 -5
  81. package/dist/lib/admission.d.ts +71 -0
  82. package/dist/lib/admission.d.ts.map +1 -0
  83. package/dist/lib/admission.js +264 -0
  84. package/dist/lib/agent-browser/client.d.ts +1 -1
  85. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  86. package/dist/lib/agent-browser/client.js +1 -5
  87. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  88. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  89. package/dist/lib/browser/capture-fidelity.js +84 -0
  90. package/dist/lib/browser/client.d.ts +41 -1
  91. package/dist/lib/browser/client.d.ts.map +1 -1
  92. package/dist/lib/browser/client.js +167 -9
  93. package/dist/lib/browser/critique.d.ts +38 -1
  94. package/dist/lib/browser/critique.d.ts.map +1 -1
  95. package/dist/lib/browser/critique.js +34 -6
  96. package/dist/lib/browser/index.d.ts +4 -2
  97. package/dist/lib/browser/index.d.ts.map +1 -1
  98. package/dist/lib/browser/index.js +2 -0
  99. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  100. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  101. package/dist/lib/browser/page-review-judge.js +270 -0
  102. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  103. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  104. package/dist/lib/browser/page-review-pack.js +1751 -0
  105. package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
  106. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  107. package/dist/lib/browser/qa-run-contracts.js +136 -1
  108. package/dist/lib/browser/qa-run.d.ts +100 -10
  109. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  110. package/dist/lib/browser/qa-run.js +768 -169
  111. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  112. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  113. package/dist/lib/browser/request-diagnostics.js +18 -0
  114. package/dist/lib/browser/tiling.d.ts +19 -0
  115. package/dist/lib/browser/tiling.d.ts.map +1 -1
  116. package/dist/lib/browser/tiling.js +28 -0
  117. package/dist/lib/cookies/client.d.ts +9 -0
  118. package/dist/lib/cookies/client.d.ts.map +1 -1
  119. package/dist/lib/cookies/client.js +197 -44
  120. package/dist/lib/cookies/extra.d.ts +18 -0
  121. package/dist/lib/cookies/extra.d.ts.map +1 -0
  122. package/dist/lib/cookies/extra.js +14 -0
  123. package/dist/lib/cookies/index.d.ts +2 -1
  124. package/dist/lib/cookies/index.d.ts.map +1 -1
  125. package/dist/lib/cookies/index.js +2 -1
  126. package/dist/lib/durable-job.d.ts +124 -0
  127. package/dist/lib/durable-job.d.ts.map +1 -0
  128. package/dist/lib/durable-job.js +296 -0
  129. package/dist/lib/http/client.d.ts +7 -1
  130. package/dist/lib/http/client.d.ts.map +1 -1
  131. package/dist/lib/http/client.js +2 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +5 -2
  134. package/package.json +8 -2
  135. package/src/commander.ts +50 -2
  136. package/src/commands/admission.ts +699 -0
  137. package/src/commands/agents.ts +87 -1
  138. package/src/commands/artifacts.ts +97 -21
  139. package/src/commands/browse-ai.ts +10 -5
  140. package/src/commands/browse.ts +481 -20
  141. package/src/commands/fetch.ts +1 -0
  142. package/src/commands/qa-record.ts +682 -0
  143. package/src/commands/qa-run.ts +335 -16
  144. package/src/commands/qa-status.ts +608 -0
  145. package/src/commands/qa-verify.ts +238 -0
  146. package/src/commands/review-pack.ts +1281 -0
  147. package/src/core/agents/qa-signal.ts +261 -0
  148. package/src/core/agents/session-name-display.ts +78 -7
  149. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  150. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  151. package/src/core/agents/state/live-coordination-view.ts +1 -0
  152. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  153. package/src/core/artifacts/constants.ts +1 -1
  154. package/src/core/artifacts/index.ts +370 -21
  155. package/src/core/config.ts +23 -0
  156. package/src/core/diagnostics/bundle.ts +119 -11
  157. package/src/core/events/v3/bootstrap.ts +10 -0
  158. package/src/core/events/v3/coordination-view.ts +100 -11
  159. package/src/core/events/v3/producers/intake.ts +9 -2
  160. package/src/core/events/v3/producers/recorder.ts +312 -18
  161. package/src/core/hooks/cli.ts +140 -32
  162. package/src/core/hooks/resolve/transcript.ts +10 -3
  163. package/src/core/hooks/session-name-presence.ts +6 -1
  164. package/src/core/qa-artifacts.ts +126 -0
  165. package/src/core/resources/contract.ts +7 -0
  166. package/src/core/resources/sampler.ts +138 -6
  167. package/src/lib/admission.ts +347 -0
  168. package/src/lib/agent-browser/client.ts +2 -10
  169. package/src/lib/browser/capture-fidelity.ts +98 -0
  170. package/src/lib/browser/client.ts +206 -10
  171. package/src/lib/browser/critique.ts +62 -7
  172. package/src/lib/browser/index.ts +36 -0
  173. package/src/lib/browser/page-review-judge.ts +360 -0
  174. package/src/lib/browser/page-review-pack.ts +2384 -0
  175. package/src/lib/browser/qa-run-contracts.ts +366 -3
  176. package/src/lib/browser/qa-run.ts +868 -190
  177. package/src/lib/browser/request-diagnostics.ts +27 -0
  178. package/src/lib/browser/tiling.ts +32 -0
  179. package/src/lib/cookies/client.ts +228 -42
  180. package/src/lib/cookies/extra.ts +28 -0
  181. package/src/lib/cookies/index.ts +2 -0
  182. package/src/lib/durable-job.ts +407 -0
  183. package/src/lib/http/client.ts +13 -1
  184. package/src/lib/instructions/templates.ts +5 -2
@@ -10,18 +10,73 @@
10
10
  // outcome in the result — nothing is filtered away.
11
11
  //
12
12
  // Toolkit tier: this module must not import src/core (layering check).
13
- import { execFile } from "node:child_process";
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { join } from "node:path";
16
- import { computeVerdict, mergeCoverage, QA_RUN_RESULT_SCHEMA_VERSION, } from "./qa-run-contracts.js";
17
- /** Set on critique children unless the job permits metered critique: the
18
- * host's critique provider must stay on subscription-backed headless
19
- * harnesses and surface exhaustion instead of falling back to a metered API. */
13
+ import { spawn } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { cpus, freemem, loadavg, totalmem } from "node:os";
17
+ import { basename, join } from "node:path";
18
+ import { DEFAULT_CRITIQUE_RUBRIC } from "./critique.js";
19
+ import { judgePageReviewPack, toCritiqueRecords } from "./page-review-judge.js";
20
+ import { finalizePageReviewPack, gateHitsFromEnvelope, PAGE_REVIEW_DEFAULT_RETENTION_MINUTES, PAGE_REVIEW_FINDINGS_FILENAME, PAGE_REVIEW_PACK_DIRNAME, PAGE_REVIEW_PACK_SCHEMA, PAGE_REVIEW_REVIEW_FILENAME, readPackContext, readPackDom, readPackFullPage, readPackManifest, readPackSignature, } from "./page-review-pack.js";
21
+ import { QA_CRITIQUE_CONTRACT_VERSION, rubricDigest } from "./qa-reuse.js";
22
+ import { computeJobDigest, computeVerdict, mergeCoverage, QA_RUN_RESULT_SCHEMA_VERSION, QA_RUN_STATUS_SCHEMA_VERSION, } from "./qa-run-contracts.js";
23
+ import { saveQaSnapshot } from "./qa-snapshot.js";
24
+ /** Set on the runner's own environment before the host's critique provider is
25
+ * loaded for the judge stage, unless the job permits metered critique: the
26
+ * provider must stay on subscription-backed headless harnesses and surface
27
+ * exhaustion instead of falling back to a metered API. */
20
28
  export const QA_RUN_HEADLESS_ONLY_ENV = "HARNERY_CRITIQUE_HEADLESS_ONLY";
21
29
  /** Result document written into the run's output directory. */
22
30
  export const QA_RUN_RESULT_FILENAME = "page-qa-result.json";
31
+ /** Pointer document written into the parent output directory after every
32
+ * run, naming the newest run's directory and verdict. Consumers resolve the
33
+ * current result through this pointer instead of guessing at loose files. */
34
+ export const QA_RUN_LATEST_FILENAME = "latest.json";
35
+ /**
36
+ * Publish the parent directory's latest-result pointer without allowing an
37
+ * older completion to replace a newer one. Both runner and manual evidence
38
+ * use this writer so every producer preserves the same ordering invariant.
39
+ */
40
+ export function writeLatestPointer(outParent, input) {
41
+ const pointerPath = join(outParent, QA_RUN_LATEST_FILENAME);
42
+ let pointerIsNewer = true;
43
+ try {
44
+ const existing = JSON.parse(readFileSync(pointerPath, "utf8"));
45
+ if (typeof existing.completed_at === "string") {
46
+ const existingCompletedAt = Date.parse(existing.completed_at);
47
+ const candidateCompletedAt = Date.parse(input.completed_at);
48
+ pointerIsNewer =
49
+ Number.isNaN(existingCompletedAt) || candidateCompletedAt >= existingCompletedAt;
50
+ }
51
+ }
52
+ catch {
53
+ // No readable pointer yet: this result becomes the first one.
54
+ }
55
+ if (pointerIsNewer) {
56
+ const pointer = {
57
+ schema_version: 1,
58
+ ...input,
59
+ result: join(input.dir, QA_RUN_RESULT_FILENAME),
60
+ };
61
+ const pointerTmp = join(outParent, `.${QA_RUN_LATEST_FILENAME}.${input.run_id}.tmp`);
62
+ writeFileSync(pointerTmp, `${JSON.stringify(pointer, null, 2)}\n`);
63
+ renameSync(pointerTmp, pointerPath);
64
+ }
65
+ return pointerPath;
66
+ }
67
+ /** Live status document beside the result (QaRunStatusDocument): written at
68
+ * start, every stage boundary, and on a heartbeat timer, so a disconnected
69
+ * client can tell a running job from a dead one without guessing. */
70
+ export const QA_RUN_STATUS_FILENAME = "run-status.json";
71
+ /** The effective validated job, written into the run directory so a
72
+ * reconnecting client can re-derive the job digest (`qa-verify --job`). */
73
+ export const QA_RUN_JOB_FILENAME = "job.json";
74
+ const STATUS_HEARTBEAT_MS = 15_000;
23
75
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
76
+ const DEFAULT_RUN_DEADLINE_MS = 900_000;
24
77
  const DEFAULT_COMMAND_CONCURRENCY = 2;
78
+ /** Gate-hit rectangles forwarded to one context's capture child (`--review-pack-hit-rect`). */
79
+ const REVIEW_PACK_HIT_RECTS_PER_CONTEXT = 50;
25
80
  const EXEC_MAX_BUFFER = 16 * 1024 * 1024;
26
81
  /** The planner's deterministic vocabulary is an executable contract, not a
27
82
  * report-only hint. A job may add checks, but these arguments always run for
@@ -33,38 +88,142 @@ const MANIFEST_DETERMINISTIC_ARGS = {
33
88
  truncation: ["--check-truncation", "--check-truncation-fail"],
34
89
  placeholder: ["--check-placeholder", "--check-placeholder-fail"],
35
90
  };
36
- /** Default executor: execFile with argv arrays only (never a shell string),
37
- * closed stdin, bounded output buffers, and the policy timeout. */
91
+ /** Grace between the timeout's SIGTERM and the follow-up SIGKILL. A child
92
+ * that catches SIGTERM (Bun installs a handler by default) gets this long to
93
+ * exit before the kill is made non-negotiable. */
94
+ export const QA_RUN_KILL_GRACE_MS = 5_000;
95
+ /** After the group SIGKILL, how long to wait for stdio to drain before
96
+ * destroying the streams and settling anyway. An escaped grandchild (setsid)
97
+ * can hold the pipes open forever; the result must not wait on it. */
98
+ const KILL_DRAIN_MS = 2_000;
99
+ /** Default executor: spawn with argv arrays only (never a shell string),
100
+ * closed stdin, bounded output buffers, and the policy timeout.
101
+ *
102
+ * Timeout enforcement is escalated and group-wide, via `spawn` rather than
103
+ * `execFile` for two live-verified reasons. First, a child that catches
104
+ * SIGTERM while awaiting its own grandchildren turns a single polite kill
105
+ * into an unbounded wait (a critique command outlived its 120s cap by 10x).
106
+ * Second, `execFile` resolves only when the child's stdio closes, and an
107
+ * orphaned grandchild inheriting the pipe keeps it open after the child is
108
+ * dead — so even a delivered kill did not settle the call. The child is
109
+ * therefore spawned detached into its own process group; at the deadline the
110
+ * whole group gets SIGTERM, then SIGKILL after a grace, and the result
111
+ * settles on exit with whatever output drained, never waiting on a pipe an
112
+ * orphan still holds. A timed-out command reports an error even if the child
113
+ * then exits 0 — a result produced after the deadline cannot be trusted. */
38
114
  export const defaultQaRunExec = (argv, options) => new Promise((resolvePromise) => {
39
115
  const [command, ...args] = argv;
40
116
  if (!command) {
41
117
  resolvePromise({ exitCode: null, stdout: "", stderr: "", error: "empty argv" });
42
118
  return;
43
119
  }
44
- const child = execFile(command, args, {
45
- timeout: options.timeoutMs,
46
- maxBuffer: EXEC_MAX_BUFFER,
120
+ // Windows has no process groups; the direct-child kill is the best
121
+ // available fallback there.
122
+ const groupKill = process.platform !== "win32";
123
+ const child = spawn(command, args, {
47
124
  env: options.env,
48
- killSignal: "SIGTERM",
49
- }, (err, stdout, stderr) => {
50
- const out = String(stdout ?? "");
51
- const errOut = String(stderr ?? "");
52
- if (!err) {
53
- resolvePromise({ exitCode: 0, stdout: out, stderr: errOut });
125
+ stdio: ["ignore", "pipe", "pipe"],
126
+ ...(groupKill ? { detached: true } : {}),
127
+ });
128
+ let stdout = "";
129
+ let stderr = "";
130
+ let outBytes = 0;
131
+ let timedOut = false;
132
+ let overflowed = false;
133
+ let settled = false;
134
+ let termTimer;
135
+ let killTimer;
136
+ let drainTimer;
137
+ const settle = (result) => {
138
+ if (settled)
54
139
  return;
55
- }
56
- const failure = err;
57
- if (typeof failure.code === "number") {
58
- // Completed with a nonzero exit code: a real outcome, not an error.
59
- resolvePromise({ exitCode: failure.code, stdout: out, stderr: errOut });
140
+ settled = true;
141
+ clearTimeout(termTimer);
142
+ clearTimeout(killTimer);
143
+ clearTimeout(drainTimer);
144
+ resolvePromise(result);
145
+ };
146
+ const signalGroup = (signal) => {
147
+ const pid = child.pid;
148
+ if (!pid)
60
149
  return;
150
+ if (groupKill) {
151
+ try {
152
+ process.kill(-pid, signal);
153
+ return;
154
+ }
155
+ catch {
156
+ // Group already gone, or the leader died before setpgid: fall
157
+ // through to the direct child so the kill still lands somewhere.
158
+ }
159
+ }
160
+ try {
161
+ child.kill(signal);
162
+ }
163
+ catch {
164
+ // Nothing left to kill.
61
165
  }
62
- const reason = failure.killed || failure.signal
63
- ? `killed by ${failure.signal ?? "signal"} (timeout ${options.timeoutMs}ms)`
64
- : failure.message || "spawn failed";
65
- resolvePromise({ exitCode: null, stdout: out, stderr: errOut, error: reason });
166
+ };
167
+ const collect = (chunk, sink) => {
168
+ const text = String(chunk);
169
+ outBytes += text.length;
170
+ if (sink === "stdout")
171
+ stdout += text;
172
+ else
173
+ stderr += text;
174
+ if (outBytes > EXEC_MAX_BUFFER && !overflowed) {
175
+ overflowed = true;
176
+ signalGroup("SIGKILL");
177
+ }
178
+ };
179
+ child.stdout?.on("data", (chunk) => collect(chunk, "stdout"));
180
+ child.stderr?.on("data", (chunk) => collect(chunk, "stderr"));
181
+ child.on("error", (err) => {
182
+ settle({ exitCode: null, stdout, stderr, error: err.message || "spawn failed" });
66
183
  });
67
- child.stdin?.end();
184
+ const finish = (code, signal) => {
185
+ if (timedOut) {
186
+ settle({
187
+ exitCode: null,
188
+ stdout,
189
+ stderr,
190
+ error: `timed out after ${options.timeoutMs}ms (process group killed)`,
191
+ });
192
+ }
193
+ else if (overflowed) {
194
+ settle({
195
+ exitCode: null,
196
+ stdout,
197
+ stderr,
198
+ error: `output exceeded ${EXEC_MAX_BUFFER} bytes (process group killed)`,
199
+ });
200
+ }
201
+ else if (signal) {
202
+ settle({ exitCode: null, stdout, stderr, error: `killed by ${signal}` });
203
+ }
204
+ else {
205
+ settle({ exitCode: code, stdout, stderr });
206
+ }
207
+ };
208
+ // "close" is the clean path: process exited AND stdio drained. "exit"
209
+ // arms the drain failsafe so an orphan holding the pipes cannot postpone
210
+ // the result forever.
211
+ child.on("close", (code, signal) => finish(code, signal));
212
+ child.on("exit", (code, signal) => {
213
+ drainTimer = setTimeout(() => {
214
+ child.stdout?.destroy();
215
+ child.stderr?.destroy();
216
+ finish(code, signal);
217
+ }, KILL_DRAIN_MS);
218
+ drainTimer.unref?.();
219
+ });
220
+ termTimer = setTimeout(() => {
221
+ timedOut = true;
222
+ signalGroup("SIGTERM");
223
+ }, options.timeoutMs);
224
+ killTimer = setTimeout(() => signalGroup("SIGKILL"), options.timeoutMs + QA_RUN_KILL_GRACE_MS);
225
+ termTimer.unref?.();
226
+ killTimer.unref?.();
68
227
  });
69
228
  /** One-line tail excerpt of a failed child's output for blockers and
70
229
  * failures, preferring stderr and falling back to stdout (browse prints its
@@ -186,39 +345,181 @@ function parseConsoleFailures(envelope) {
186
345
  }
187
346
  return failures;
188
347
  }
189
- function providerLabel(critique) {
190
- const meta = critique?.provider_meta;
191
- if (meta) {
192
- for (const key of ["provider", "route", "model"]) {
193
- const value = meta[key];
194
- if (typeof value === "string" && value.length > 0)
195
- return value;
196
- }
348
+ /**
349
+ * Lift per-backend vision latency out of the envelope's provider_meta. The
350
+ * shape is host-owned (`providers[name].latency_ms = {count, p50, p95}`), so
351
+ * read defensively: a malformed entry or one with zero calls is dropped.
352
+ * Without this the runner kept only the provider label, and a slow tile was
353
+ * visible in `ps` and nowhere in the result document.
354
+ */
355
+ function critiqueLatency(providerMeta) {
356
+ const providers = providerMeta?.providers;
357
+ if (!providers || typeof providers !== "object" || Array.isArray(providers))
358
+ return undefined;
359
+ const out = {};
360
+ for (const [name, state] of Object.entries(providers)) {
361
+ const lat = state?.latency_ms;
362
+ if (!lat || typeof lat !== "object")
363
+ continue;
364
+ const { count, p50, p95 } = lat;
365
+ if (typeof count !== "number" || count <= 0)
366
+ continue;
367
+ if (typeof p50 !== "number" || typeof p95 !== "number")
368
+ continue;
369
+ out[name] = { count, p50, p95 };
197
370
  }
198
- return critique?.provider ? "host" : "none";
371
+ return Object.keys(out).length > 0 ? out : undefined;
199
372
  }
200
373
  /**
201
374
  * Execute the whole QA matrix for one validated job and return the result
202
375
  * (also written to `<outDir>/page-qa-result.json`). Stages: plan →
203
- * deterministic gates (bounded pool) → interactions (serial) → critique (one
204
- * context at a time; the critique provider owns tile concurrency) → snapshot.
205
- * The verdict is computeVerdict over everything recorded — fail-closed.
376
+ * deterministic gates (bounded pool) → interactions (serial) → capture (each
377
+ * context rendered once into the run's page review pack through the same
378
+ * pool, browser closed) → critique (one in-process pool of vision calls over
379
+ * every tile of every context, no browser open) → snapshot (persisted from
380
+ * the pack's files in signoff). The verdict is computeVerdict over
381
+ * everything recorded — fail-closed.
206
382
  */
207
383
  export async function runQaMatrix(options) {
208
- const { job, outDir, browseArgv } = options;
384
+ const { job, outParent, browseArgv } = options;
209
385
  const exec = options.exec ?? defaultQaRunExec;
210
386
  const log = options.onLog ?? (() => { });
211
387
  const timeoutMs = job.policy?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS;
212
388
  const concurrency = job.policy?.command_concurrency ?? DEFAULT_COMMAND_CONCURRENCY;
389
+ const hostSample = () => {
390
+ const holders = options.admission?.holders?.() ?? undefined;
391
+ return {
392
+ captured_at: new Date().toISOString(),
393
+ loadavg_1m: loadavg()[0] ?? 0,
394
+ free_mem_bytes: freemem(),
395
+ total_mem_bytes: totalmem(),
396
+ cpu_count: cpus().length,
397
+ // Exclude this run itself: a holder list that names the sampler tells
398
+ // the reader nothing about contention.
399
+ ...(holders ? { competing: holders.filter((holder) => holder.pid !== process.pid) } : {}),
400
+ };
401
+ };
402
+ const stageHostSamples = {};
403
+ const runId = options.runId ?? randomUUID();
404
+ const outDir = join(outParent, `run-${runId}`);
213
405
  mkdirSync(outDir, { recursive: true });
406
+ const startedAtIso = new Date().toISOString();
407
+ writeFileSync(join(outDir, QA_RUN_JOB_FILENAME), `${JSON.stringify(job, null, 2)}\n`);
408
+ // Live status: state + stage + heartbeat, written atomically so a client
409
+ // that lost its terminal can distinguish this run being alive from dead.
410
+ let statusState = "running";
411
+ let statusStage = null;
412
+ let statusQueue;
413
+ let statusVerdict;
414
+ const writeStatus = () => {
415
+ const status = {
416
+ schema_version: QA_RUN_STATUS_SCHEMA_VERSION,
417
+ run_id: runId,
418
+ pid: process.pid,
419
+ state: statusState,
420
+ stage: statusStage,
421
+ started_at: startedAtIso,
422
+ updated_at: new Date().toISOString(),
423
+ ...(statusQueue ? { queue: statusQueue } : {}),
424
+ ...(statusVerdict ? { verdict: statusVerdict } : {}),
425
+ };
426
+ try {
427
+ const tmp = join(outDir, `.${QA_RUN_STATUS_FILENAME}.tmp`);
428
+ writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`);
429
+ renameSync(tmp, join(outDir, QA_RUN_STATUS_FILENAME));
430
+ }
431
+ catch {
432
+ // Status is advisory; the result document is the authoritative record.
433
+ }
434
+ };
435
+ const heartbeat = setInterval(writeStatus, STATUS_HEARTBEAT_MS);
436
+ heartbeat.unref();
437
+ /** Enter a stage: record the host pressure it starts under and refresh the
438
+ * live status document in one place. */
439
+ const enterStage = (stage) => {
440
+ statusStage = stage;
441
+ stageHostSamples[stage] = hostSample();
442
+ writeStatus();
443
+ };
444
+ const revision = job.tested_revision !== undefined
445
+ ? { tested_revision: job.tested_revision, revision_source: "job" }
446
+ : options.revisionProbe?.tested_revision !== undefined
447
+ ? {
448
+ tested_revision: options.revisionProbe.tested_revision,
449
+ revision_source: "git",
450
+ ...(options.revisionProbe.worktree_dirty !== undefined
451
+ ? { worktree_dirty: options.revisionProbe.worktree_dirty }
452
+ : {}),
453
+ }
454
+ : { revision_source: "unknown" };
455
+ const jobDigest = computeJobDigest(job);
456
+ const wall = {
457
+ plan: 0,
458
+ gates: 0,
459
+ interactions: 0,
460
+ capture: 0,
461
+ critique: 0,
462
+ snapshot: 0,
463
+ total: 0,
464
+ };
465
+ // ------------------------------------------------------------- admission
466
+ // Queue wait happens before the runner clock starts: wall_time_ms.total
467
+ // stays pure runner time and the wait is reported as wall_time_ms.queue.
468
+ let releaseAdmission;
469
+ let admissionFailure;
470
+ if (options.admission) {
471
+ statusState = "queued";
472
+ statusQueue = {
473
+ resource: options.admission.resource,
474
+ waiting_since: new Date().toISOString(),
475
+ };
476
+ writeStatus();
477
+ const queueStart = Date.now();
478
+ try {
479
+ releaseAdmission = await options.admission.acquire((message) => {
480
+ log(message);
481
+ writeStatus();
482
+ });
483
+ }
484
+ catch (err) {
485
+ admissionFailure = err instanceof Error ? err.message : String(err);
486
+ }
487
+ wall.queue = Date.now() - queueStart;
488
+ statusQueue = undefined;
489
+ }
490
+ statusState = "running";
491
+ writeStatus();
492
+ const hostStart = hostSample();
214
493
  const startedAt = Date.now();
215
- const wall = { plan: 0, gates: 0, interactions: 0, critique: 0, snapshot: 0, total: 0 };
216
494
  const blockers = [];
495
+ // Overall deadline: the clock starts after admission (pure runner time) and
496
+ // is consulted before every command, so the worst overshoot is one command
497
+ // timeout plus the kill grace. Exceeding it fails closed as incomplete.
498
+ const runDeadlineMs = job.policy?.run_deadline_ms ?? DEFAULT_RUN_DEADLINE_MS;
499
+ let deadlineHit = false;
500
+ const pastDeadline = () => {
501
+ if (deadlineHit)
502
+ return true;
503
+ if (Date.now() - startedAt < runDeadlineMs)
504
+ return false;
505
+ deadlineHit = true;
506
+ blockers.push({
507
+ stage: "deadline",
508
+ reason: `run deadline of ${runDeadlineMs}ms exceeded — remaining commands were skipped and ` +
509
+ "the result finalized as incomplete (raise policy.run_deadline_ms for a legitimately " +
510
+ "larger matrix)",
511
+ });
512
+ log(`deadline: ${runDeadlineMs}ms exceeded, skipping remaining commands`);
513
+ return true;
514
+ };
217
515
  const commands = [];
218
516
  const critique = [];
517
+ const stagesRun = [];
219
518
  let manifest = null;
220
519
  let contexts = [];
221
520
  let snapshot = { saved: false };
521
+ let critiquePool;
522
+ let reviewPack;
222
523
  const baseEnv = { ...process.env, ...options.childEnv };
223
524
  const timedExec = async (argv, env) => {
224
525
  const started = Date.now();
@@ -238,15 +539,45 @@ export async function runQaMatrix(options) {
238
539
  };
239
540
  const finalize = () => {
240
541
  wall.total = Date.now() - startedAt;
542
+ // Prefix semantics: the last stage the run progressed THROUGH cleanly.
543
+ // Stages are pushed in execution order; the first blocked stage ends the
544
+ // clean prefix, so a gate-blocked run reports "plan" even though the
545
+ // (empty) interactions stage technically executed afterwards.
546
+ const blockedStages = new Set(blockers.map((blocker) => blocker.stage));
547
+ let lastCompletedStage = null;
548
+ for (const stage of stagesRun) {
549
+ if (blockedStages.has(stage))
550
+ break;
551
+ lastCompletedStage = stage;
552
+ }
241
553
  const result = {
242
554
  schema_version: QA_RUN_RESULT_SCHEMA_VERSION,
555
+ evidence_source: "runner",
556
+ run: {
557
+ run_id: runId,
558
+ started_at: startedAtIso,
559
+ completed_at: new Date().toISOString(),
560
+ ...revision,
561
+ job_digest: jobDigest,
562
+ out_dir: outDir,
563
+ },
564
+ host: {
565
+ start: hostStart,
566
+ finish: hostSample(),
567
+ ...(Object.keys(stageHostSamples).length > 0 ? { stages: stageHostSamples } : {}),
568
+ },
569
+ last_completed_stage: lastCompletedStage,
243
570
  target: job.target,
244
- ...(job.tested_revision !== undefined ? { tested_revision: job.tested_revision } : {}),
571
+ ...(revision.tested_revision !== undefined
572
+ ? { tested_revision: revision.tested_revision }
573
+ : {}),
245
574
  mode: job.mode,
246
575
  qa_plan: manifest,
247
576
  contexts,
248
577
  commands,
249
578
  critique,
579
+ ...(critiquePool ? { critique_pool: critiquePool } : {}),
580
+ ...(reviewPack ? { review_pack: reviewPack } : {}),
250
581
  snapshot,
251
582
  wall_time_ms: { ...wall },
252
583
  blockers,
@@ -259,8 +590,27 @@ export async function runQaMatrix(options) {
259
590
  }),
260
591
  };
261
592
  writeFileSync(join(outDir, QA_RUN_RESULT_FILENAME), `${JSON.stringify(result, null, 2)}\n`);
593
+ writeLatestPointer(outParent, {
594
+ run_id: runId,
595
+ dir: basename(outDir),
596
+ completed_at: result.run.completed_at,
597
+ verdict: result.verdict,
598
+ });
599
+ statusState = "completed";
600
+ statusStage = null;
601
+ statusVerdict = result.verdict;
602
+ writeStatus();
603
+ clearInterval(heartbeat);
604
+ releaseAdmission?.();
262
605
  return result;
263
606
  };
607
+ if (admissionFailure !== undefined) {
608
+ blockers.push({
609
+ stage: "admission",
610
+ reason: `no admission slot for browser work: ${admissionFailure}`,
611
+ });
612
+ return finalize();
613
+ }
264
614
  // Base render arguments shared by every per-context invocation.
265
615
  const contextRenderArgs = (ctx) => [
266
616
  "--viewport",
@@ -268,6 +618,11 @@ export async function runQaMatrix(options) {
268
618
  ...(ctx.theme === "dark" ? ["--color-scheme", "dark"] : []),
269
619
  ...(ctx.args ?? []),
270
620
  ];
621
+ // The planner's tile ceiling and every critique child must agree on the
622
+ // per-context band cap, or the predicted cost and the real coverage drift.
623
+ const critiqueMaxTilesArgs = job.policy?.critique_max_tiles !== undefined
624
+ ? ["--check-critique-max-tiles", String(job.policy.critique_max_tiles)]
625
+ : [];
271
626
  // ------------------------------------------------------------------ plan
272
627
  const planArgv = [
273
628
  ...browseArgv,
@@ -275,13 +630,16 @@ export async function runQaMatrix(options) {
275
630
  "--qa-plan",
276
631
  "--json",
277
632
  "--no-screenshot",
633
+ ...critiqueMaxTilesArgs,
278
634
  ...(job.qa_hints?.scopes ?? []).flatMap((selector) => ["--qa-scope", selector]),
279
635
  ...(job.qa_hints?.states?.length ? ["--qa-states", job.qa_hints.states.join(",")] : []),
280
636
  ];
281
637
  log(`plan: ${job.target}`);
638
+ enterStage("plan");
282
639
  const planStart = Date.now();
283
640
  const plan = await timedExec(planArgv, baseEnv);
284
641
  wall.plan = Date.now() - planStart;
642
+ stagesRun.push("plan");
285
643
  let planEnvelope;
286
644
  if (!plan.res.error && plan.res.exitCode === 0) {
287
645
  try {
@@ -353,8 +711,11 @@ export async function runQaMatrix(options) {
353
711
  const enforceConsole = manifest.checks.deterministic.includes("console");
354
712
  const checks = job.checks ?? [];
355
713
  const gateOutcomes = new Array(contexts.length);
714
+ enterStage("gates");
356
715
  const gatesStart = Date.now();
357
716
  await runPool(contexts, concurrency, async (ctx, index) => {
717
+ if (pastDeadline())
718
+ return;
358
719
  const applicable = checks.filter((check) => check.contexts === undefined || check.contexts.includes(ctx.id));
359
720
  const checkId = [
360
721
  ...manifest.checks.deterministic.map((check) => `manifest:${check}`),
@@ -367,6 +728,10 @@ export async function runQaMatrix(options) {
367
728
  ...contextRenderArgs(ctx),
368
729
  "--out",
369
730
  outPrefix,
731
+ // When the manifest requires a visual pass, the capture stage writes the
732
+ // full-page PNG into the pack, so the gate trio carries no screenshot;
733
+ // nothing reads the gate PNG (the envelope JSON is the evidence).
734
+ ...(manifest.checks.visual !== "none" ? ["--no-screenshot"] : []),
370
735
  ...manifestGateArgs,
371
736
  ...applicable.flatMap((check) => check.args),
372
737
  ];
@@ -419,10 +784,13 @@ export async function runQaMatrix(options) {
419
784
  };
420
785
  });
421
786
  wall.gates = Date.now() - gatesStart;
787
+ stagesRun.push("gates");
422
788
  // Manifest order regardless of completion order: outcomes were written by
423
- // context index, so a straight push preserves it.
424
- commands.push(...gateOutcomes);
789
+ // context index, so a straight push preserves it. Deadline-skipped slots
790
+ // are empty: the single deadline blocker is their record.
791
+ commands.push(...gateOutcomes.filter((outcome) => outcome !== undefined));
425
792
  // ---------------------------------------------------------- interactions
793
+ enterStage("interactions");
426
794
  const interactionsStart = Date.now();
427
795
  const declaredStates = new Set((job.interaction_states ?? []).map((state) => state.name));
428
796
  const manifestStates = [
@@ -438,6 +806,8 @@ export async function runQaMatrix(options) {
438
806
  }
439
807
  }
440
808
  for (const state of job.interaction_states ?? []) {
809
+ if (pastDeadline())
810
+ break;
441
811
  const outPrefix = join(outDir, `interaction-${state.name}`);
442
812
  const argv = [
443
813
  ...browseArgv,
@@ -489,150 +859,377 @@ export async function runQaMatrix(options) {
489
859
  });
490
860
  }
491
861
  wall.interactions = Date.now() - interactionsStart;
492
- // -------------------------------------------------------------- critique
862
+ stagesRun.push("interactions");
863
+ // -------------------------------------------------------------- capture
864
+ // Each context is rendered ONCE into the run's page review pack (full-page
865
+ // screenshot, tiles as PNG files, DOM, signature) through the same bounded
866
+ // pool the gates used, and its browser closes before any vision call.
493
867
  const visual = manifest.checks.visual;
494
868
  const cleanSoFar = blockers.length === 0 && commands.every((command) => command.outcome === "passed");
495
- const critiqueStart = Date.now();
496
- const savedSnapshots = new Map();
497
- if (cleanSoFar && visual !== "none") {
498
- const critiqueEnv = job.policy?.allow_metered_critique
499
- ? { ...baseEnv }
500
- : { ...baseEnv, [QA_RUN_HEADLESS_ONLY_ENV]: "1" };
501
- const scopeSelectors = visual === "full-page" ? [undefined] : manifest.scopes.map((s) => s.selector);
502
- for (const ctx of contexts) {
503
- let tilesTotal = 0;
504
- let tilesReviewed = 0;
505
- let tilesReused = 0;
506
- let provider = "none";
507
- let contextOutcome = "passed";
508
- const findings = [];
509
- for (const [scopeIndex, selector] of scopeSelectors.entries()) {
510
- const suffix = scopeSelectors.length > 1 ? `-scope${scopeIndex}` : "";
511
- const outPrefix = join(outDir, `${ctx.id}-critique${suffix}`);
512
- const argv = [
513
- ...browseArgv,
514
- job.target,
515
- ...contextRenderArgs(ctx),
516
- "--out",
517
- outPrefix,
518
- ...(selector !== undefined ? ["--check-critique", selector] : ["--check-critique"]),
519
- "--check-critique-fail",
520
- ...(manifest.baseline_source !== "none" ? ["--qa-reuse"] : []),
521
- ...(job.mode === "signoff"
522
- ? ["--qa-snapshot", "--qa-theme", ctx.theme, "--qa-state", ctx.state]
523
- : []),
524
- ];
525
- log(`critique ${ctx.id}${selector !== undefined ? ` [${selector}]` : ""}`);
526
- const { res, wallTimeMs } = await timedExec(argv, critiqueEnv);
527
- const jsonPath = `${outPrefix}.json`;
528
- const envelope = readEnvelope(jsonPath);
529
- const envelopeCritique = envelope?.critique;
530
- const failures = [];
531
- let commandOutcome;
532
- if (res.error || (res.exitCode !== 0 && res.exitCode !== 2) || !envelope) {
533
- commandOutcome = "unknown";
534
- const excerpt = execErrorExcerpt(res);
535
- const base = res.error
536
- ? res.error
537
- : !envelope
538
- ? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
539
- : `exit code ${res.exitCode ?? "null"}`;
540
- const reason = excerpt ? `${base}: ${excerpt}` : base;
541
- failures.push(reason);
542
- blockers.push({
543
- stage: "critique",
544
- context_id: ctx.id,
545
- reason: `critique command did not complete: ${reason}`,
546
- });
547
- contextOutcome = "unknown";
869
+ const packDir = join(outDir, PAGE_REVIEW_PACK_DIRNAME);
870
+ const capturedRecords = [];
871
+ const judgeCommand = options.reviewPackJudgeCommand?.(packDir);
872
+ // Gate rectangles ride along from each gate's JSON artifact so the pack's
873
+ // inspection plan can point a runt, a contrast miss, or a clipped element
874
+ // at the tiles that show it. A missing or unparseable artifact simply
875
+ // contributes no hits; the gate's `failures` still carry the text.
876
+ const gateRecords = () => commands
877
+ .filter((command) => command.check_id !== "plan" && command.check_id !== "review-pack")
878
+ .map((command) => {
879
+ const hits = command.artifacts.json
880
+ ? gateHitsFromEnvelope(readEnvelope(command.artifacts.json))
881
+ : [];
882
+ return {
883
+ context_id: command.context_id,
884
+ check_id: command.check_id,
885
+ outcome: command.outcome,
886
+ failures: command.failures,
887
+ ...(hits.length > 0 ? { hits } : {}),
888
+ };
889
+ });
890
+ const finalizePack = (stage, critiqueRecords, pool) => {
891
+ try {
892
+ finalizePageReviewPack({
893
+ packDir,
894
+ target: job.target,
895
+ ...(revision.tested_revision !== undefined
896
+ ? { tested_revision: revision.tested_revision }
897
+ : {}),
898
+ contexts: capturedRecords,
899
+ gates: gateRecords(),
900
+ critique: critiqueRecords,
901
+ ...(pool ? { pool } : {}),
902
+ ...(judgeCommand ? { judgeCommand } : {}),
903
+ createdAt: startedAtIso,
904
+ // The retention clock restarts at each finalize, so it ends
905
+ // `retention_minutes` after the judge (or after capture when the
906
+ // judge never runs).
907
+ retention: {
908
+ expires_at: new Date(Date.now() +
909
+ (job.policy?.review_pack_retention_minutes ?? PAGE_REVIEW_DEFAULT_RETENTION_MINUTES) *
910
+ 60_000).toISOString(),
911
+ managed: options.reviewPackManaged === true,
912
+ },
913
+ });
914
+ const written = readPackManifest(packDir);
915
+ reviewPack = {
916
+ schema: PAGE_REVIEW_PACK_SCHEMA,
917
+ dir: packDir,
918
+ review: join(packDir, PAGE_REVIEW_REVIEW_FILENAME),
919
+ findings: join(packDir, PAGE_REVIEW_FINDINGS_FILENAME),
920
+ ...(written.retention ? { expires_at: written.retention.expires_at } : {}),
921
+ ...(written.size_bytes !== undefined ? { size_bytes: written.size_bytes } : {}),
922
+ };
923
+ }
924
+ catch (err) {
925
+ blockers.push({
926
+ stage,
927
+ reason: `page review pack could not be finalized: ${err instanceof Error ? err.message : String(err)}`,
928
+ });
929
+ }
930
+ };
931
+ // The provider is loaded once, before capture, so the capture children can
932
+ // clamp band height to the routed model's vision budget (what browse does
933
+ // for --check-critique) and the judge reuses the same instance.
934
+ const priorHeadlessOnly = process.env[QA_RUN_HEADLESS_ONLY_ENV];
935
+ const restoreHeadlessOnly = () => {
936
+ if (priorHeadlessOnly === undefined)
937
+ delete process.env[QA_RUN_HEADLESS_ONLY_ENV];
938
+ else
939
+ process.env[QA_RUN_HEADLESS_ONLY_ENV] = priorHeadlessOnly;
940
+ };
941
+ let provider;
942
+ const runsVisual = cleanSoFar && visual !== "none";
943
+ if (runsVisual) {
944
+ if (!job.policy?.allow_metered_critique)
945
+ process.env[QA_RUN_HEADLESS_ONLY_ENV] = "1";
946
+ try {
947
+ provider =
948
+ options.critiqueProvider ??
949
+ (options.critiqueProviderLoader ? await options.critiqueProviderLoader() : undefined);
950
+ }
951
+ catch (err) {
952
+ provider = undefined;
953
+ log(`critique provider failed to load: ${err instanceof Error ? err.message : String(err)}`);
954
+ }
955
+ }
956
+ const bandArgs = provider?.tileBudgetPx !== undefined
957
+ ? ["--check-critique-band", String(Math.max(200, Math.min(1400, provider.tileBudgetPx)))]
958
+ : [];
959
+ const scopeArgs = visual === "full-page"
960
+ ? []
961
+ : manifest.scopes.flatMap((s) => ["--review-pack-scope", s.selector]);
962
+ // Gate-hit rectangles ride into each context's capture child so the bands
963
+ // they land in are cut even past the tile cap (`hit band N` tiles). The
964
+ // gates ran first, so the rects are known before the page is captured.
965
+ const hitRectsByContext = new Map();
966
+ for (const gate of gateRecords()) {
967
+ for (const hit of gate.hits ?? []) {
968
+ const list = hitRectsByContext.get(gate.context_id) ?? [];
969
+ if (list.length >= REVIEW_PACK_HIT_RECTS_PER_CONTEXT)
970
+ break;
971
+ const r = hit.rect;
972
+ list.push([r.x, r.y, Math.max(0, r.width), Math.max(0, r.height)].map((n) => Math.round(n)).join(","));
973
+ hitRectsByContext.set(gate.context_id, list);
974
+ }
975
+ }
976
+ const hitRectArgs = (contextId) => (hitRectsByContext.get(contextId) ?? []).flatMap((spec) => ["--review-pack-hit-rect", spec]);
977
+ enterStage("capture");
978
+ const captureStart = Date.now();
979
+ if (runsVisual) {
980
+ const captureOutcomes = new Array(contexts.length);
981
+ const captureRecords = new Array(contexts.length);
982
+ await runPool(contexts, concurrency, async (ctx, index) => {
983
+ if (pastDeadline())
984
+ return;
985
+ const outPrefix = join(outDir, `${ctx.id}-capture`);
986
+ const argv = [
987
+ ...browseArgv,
988
+ job.target,
989
+ ...contextRenderArgs(ctx),
990
+ "--out",
991
+ outPrefix,
992
+ "--no-screenshot",
993
+ "--review-pack",
994
+ packDir,
995
+ "--review-pack-context",
996
+ ctx.id,
997
+ "--qa-theme",
998
+ ctx.theme,
999
+ "--qa-state",
1000
+ ctx.state,
1001
+ ...(visual === "scoped" ? ["--no-review-pack-bands"] : []),
1002
+ ...scopeArgs,
1003
+ ...critiqueMaxTilesArgs,
1004
+ ...bandArgs,
1005
+ ...hitRectArgs(ctx.id),
1006
+ ];
1007
+ log(`capture ${ctx.id}${scopeArgs.length > 0 ? ` [${manifest.scopes.map((s) => s.selector).join(",")}]` : ""}`);
1008
+ const { res, wallTimeMs } = await timedExec(argv, baseEnv);
1009
+ const jsonPath = `${outPrefix}.json`;
1010
+ const envelope = readEnvelope(jsonPath);
1011
+ const report = envelope?.reviewPack;
1012
+ const failures = [];
1013
+ let outcome;
1014
+ let record;
1015
+ if (!res.error && res.exitCode === 0 && envelope && report?.context_id) {
1016
+ try {
1017
+ record = readPackContext(packDir, report.context_id);
1018
+ outcome = "passed";
548
1019
  }
549
- else {
550
- tilesTotal += envelopeCritique?.tiles ?? 0;
551
- const reuse = envelope.qaReuse;
552
- tilesReused += reuse?.tiles_reused ?? 0;
553
- tilesReviewed += reuse?.tiles_reviewed ?? envelopeCritique?.tiles ?? 0;
554
- provider = providerLabel(envelopeCritique);
555
- for (const finding of envelopeCritique?.findings ?? []) {
556
- findings.push({
557
- severity: finding.severity ?? "unknown",
558
- summary: finding.category
559
- ? `${finding.category}: ${finding.description ?? ""}`
560
- : (finding.description ?? ""),
561
- ...(selector !== undefined ? { selector } : {}),
562
- });
563
- }
564
- if (envelopeCritique?.outcome === "pass" && res.exitCode === 0) {
565
- commandOutcome = "passed";
566
- }
567
- else if (envelopeCritique?.outcome === "fail") {
568
- commandOutcome = "failed";
569
- failures.push(`critique found ${envelopeCritique.findings?.length ?? 0} defect(s)`);
570
- contextOutcome = "failed";
571
- }
572
- else {
573
- // "skipped" (no provider / exhausted headless list) or anything
574
- // unrecognized: the review did not happen, so nothing is proven.
575
- commandOutcome = "unknown";
576
- const detail = envelopeCritique?.error ?? "critique reported no conclusive outcome";
577
- failures.push(detail);
578
- blockers.push({
579
- stage: "critique",
580
- context_id: ctx.id,
581
- reason: job.policy?.allow_metered_critique
582
- ? `critique did not complete: ${detail}`
583
- : `critique did not complete under the headless-only policy ` +
584
- `(${QA_RUN_HEADLESS_ONLY_ENV}=1; permit metered fallback with ` +
585
- `policy.allow_metered_critique / --allow-metered): ${detail}`,
586
- });
587
- if (contextOutcome !== "failed")
588
- contextOutcome = "unknown";
589
- }
590
- if (job.mode === "signoff") {
591
- const saved = envelope.qaPlan
592
- ?.snapshotSaved;
593
- if (saved?.path)
594
- savedSnapshots.set(ctx.id, saved.path);
595
- }
1020
+ catch (err) {
1021
+ outcome = "unknown";
1022
+ const reason = `pack context unreadable: ${err instanceof Error ? err.message : String(err)}`;
1023
+ failures.push(reason);
1024
+ blockers.push({ stage: "capture", context_id: ctx.id, reason });
596
1025
  }
597
- commands.push({
1026
+ }
1027
+ else {
1028
+ outcome = "unknown";
1029
+ const excerpt = execErrorExcerpt(res);
1030
+ const base = res.error
1031
+ ? res.error
1032
+ : !envelope
1033
+ ? `exit code ${res.exitCode ?? "null"}, missing JSON artifact ${jsonPath}`
1034
+ : !report?.context_id
1035
+ ? `exit code ${res.exitCode ?? "null"}, envelope carries no reviewPack record`
1036
+ : `exit code ${res.exitCode ?? "null"}`;
1037
+ const reason = excerpt ? `${base}: ${excerpt}` : base;
1038
+ failures.push(reason);
1039
+ blockers.push({
1040
+ stage: "capture",
598
1041
  context_id: ctx.id,
599
- check_id: "critique",
600
- argv,
601
- exit_code: res.exitCode,
602
- outcome: commandOutcome,
603
- failures,
604
- artifacts: gatherArtifacts(outPrefix),
605
- wall_time_ms: wallTimeMs,
1042
+ reason: `capture command did not complete: ${reason}`,
606
1043
  });
607
1044
  }
608
- critique.push({
1045
+ captureOutcomes[index] = {
609
1046
  context_id: ctx.id,
610
- provider,
611
- tiles_total: tilesTotal,
612
- tiles_reviewed: tilesReviewed,
613
- tiles_reused: tilesReused,
1047
+ check_id: "review-pack",
1048
+ argv,
1049
+ exit_code: res.exitCode,
1050
+ outcome,
1051
+ failures,
1052
+ artifacts: gatherArtifacts(outPrefix),
1053
+ wall_time_ms: wallTimeMs,
1054
+ };
1055
+ captureRecords[index] = record;
1056
+ });
1057
+ commands.push(...captureOutcomes.filter((outcome) => outcome !== undefined));
1058
+ capturedRecords.push(...captureRecords.filter((record) => Boolean(record)));
1059
+ // The pack is readable from here on even if the judge never runs.
1060
+ if (capturedRecords.length > 0)
1061
+ finalizePack("capture", null);
1062
+ }
1063
+ wall.capture = Date.now() - captureStart;
1064
+ if (runsVisual)
1065
+ stagesRun.push("capture");
1066
+ // -------------------------------------------------------------- critique
1067
+ // One pool of vision calls across every captured context, from disk. No
1068
+ // browser is open during this stage.
1069
+ enterStage("critique");
1070
+ const critiqueStart = Date.now();
1071
+ const judgedById = new Map();
1072
+ if (runsVisual && capturedRecords.length > 0 && !pastDeadline()) {
1073
+ const judged = await judgePageReviewPack({
1074
+ packDir,
1075
+ provider,
1076
+ rubric: DEFAULT_CRITIQUE_RUBRIC,
1077
+ ...(job.policy?.critique_pool !== undefined ? { concurrency: job.policy.critique_pool } : {}),
1078
+ contextIds: capturedRecords.map((record) => record.id),
1079
+ ...(manifest.baseline_source !== "none"
1080
+ ? {
1081
+ reuse: {
1082
+ target: job.target,
1083
+ ...(options.snapshotStore ? { store: options.snapshotStore } : {}),
1084
+ },
1085
+ }
1086
+ : {}),
1087
+ deadlineAt: startedAt + runDeadlineMs,
1088
+ onLog: log,
1089
+ });
1090
+ const latency = critiqueLatency(judged.provider_meta);
1091
+ critiquePool = {
1092
+ concurrency: judged.pool.concurrency,
1093
+ tiles_total: judged.tiles_total,
1094
+ tiles_reviewed: judged.tiles_reviewed,
1095
+ tiles_reused: judged.tiles_reused,
1096
+ wall_time_ms: judged.pool.wall_time_ms,
1097
+ provider: judged.pool.provider,
1098
+ ...(latency ? { latency_ms: latency } : {}),
1099
+ };
1100
+ for (const row of judged.contexts) {
1101
+ judgedById.set(row.context_id, row);
1102
+ let contextOutcome = row.outcome === "pass" ? "passed" : row.outcome === "fail" ? "failed" : "unknown";
1103
+ if (row.outcome === "skipped") {
1104
+ const detail = row.error ?? "critique reported no conclusive outcome";
1105
+ blockers.push({
1106
+ stage: "critique",
1107
+ context_id: row.context_id,
1108
+ reason: job.policy?.allow_metered_critique
1109
+ ? `critique did not complete: ${detail}`
1110
+ : `critique did not complete under the headless-only policy ` +
1111
+ `(${QA_RUN_HEADLESS_ONLY_ENV}=1; permit metered fallback with ` +
1112
+ `policy.allow_metered_critique / --allow-metered): ${detail}`,
1113
+ });
1114
+ }
1115
+ else if (row.outcome === "incomplete") {
1116
+ blockers.push({
1117
+ stage: "critique",
1118
+ context_id: row.context_id,
1119
+ reason: `judge did not review ${row.tiles_unjudged} of ${row.tiles_total} tile(s) before the ` +
1120
+ "run deadline; nothing is proven for them",
1121
+ });
1122
+ }
1123
+ // A capped capture holds the top of the page and nothing below it.
1124
+ // Signoff cannot rest on that; review mode keeps the row honest via
1125
+ // `coverage` and leaves the verdict to the tiles that were seen.
1126
+ if (job.mode === "signoff" && row.coverage.capped && contextOutcome !== "failed") {
1127
+ contextOutcome = "unknown";
1128
+ blockers.push({
1129
+ stage: "critique",
1130
+ context_id: row.context_id,
1131
+ reason: `critique coverage capped: ${row.coverage.bands_reviewed} of ${row.coverage.bands_total} bands ` +
1132
+ `reviewed (${row.coverage.reviewed_height_px} of ${row.coverage.page_height_px} px); raise ` +
1133
+ "policy.critique_max_tiles to review the whole page in signoff mode",
1134
+ });
1135
+ }
1136
+ const scopeById = new Map(row.record.tiles.map((tile) => [tile.id, tile.scope]));
1137
+ critique.push({
1138
+ context_id: row.context_id,
1139
+ provider: row.provider,
1140
+ tiles_total: row.tiles_total,
1141
+ tiles_reviewed: row.tiles_reviewed,
1142
+ tiles_reused: row.tiles_reused,
614
1143
  outcome: contextOutcome,
615
- findings,
1144
+ findings: row.findings.map((finding) => {
1145
+ const selector = scopeById.get(finding.tile_id);
1146
+ return {
1147
+ severity: finding.severity,
1148
+ summary: `${finding.category}: ${finding.description}`,
1149
+ tile: `${row.context_id}/${finding.tile_id}`,
1150
+ ...(selector !== undefined ? { selector } : {}),
1151
+ };
1152
+ }),
1153
+ coverage: row.coverage,
616
1154
  });
617
- if (job.mode === "signoff" && contextOutcome === "passed" && !savedSnapshots.has(ctx.id)) {
1155
+ }
1156
+ finalizePack("critique", toCritiqueRecords(judged), judged.pool);
1157
+ }
1158
+ if (runsVisual)
1159
+ restoreHeadlessOnly();
1160
+ wall.critique = Date.now() - critiqueStart;
1161
+ if (runsVisual)
1162
+ stagesRun.push("critique");
1163
+ // -------------------------------------------------------------- snapshot
1164
+ // Signoff persists each context's baseline from the pack's own files (no
1165
+ // browser). The critique rides along only when the whole tile set was
1166
+ // freshly judged, uncapped, and unscoped — a partial or scoped review must
1167
+ // never become the next baseline's finding record. When the manifest
1168
+ // required no visual pass, a dedicated browse --qa-snapshot pass still runs.
1169
+ enterStage("snapshot");
1170
+ const snapshotStart = Date.now();
1171
+ const savedSnapshots = new Map();
1172
+ if (job.mode === "signoff" && runsVisual) {
1173
+ for (const record of capturedRecords) {
1174
+ if (pastDeadline())
1175
+ break;
1176
+ const row = judgedById.get(record.id);
1177
+ const persistCritique = row !== undefined &&
1178
+ (row.outcome === "pass" || row.outcome === "fail") &&
1179
+ !record.coverage.capped &&
1180
+ row.tiles_reused === 0 &&
1181
+ row.tiles_unjudged === 0 &&
1182
+ record.scopes.length === 0;
1183
+ const persisted = persistCritique && row
1184
+ ? {
1185
+ contract_version: QA_CRITIQUE_CONTRACT_VERSION,
1186
+ rubric_digest: rubricDigest(DEFAULT_CRITIQUE_RUBRIC),
1187
+ outcome: row.outcome,
1188
+ findings: row.findings.map(({ tile_id: _tileId, ...finding }) => finding),
1189
+ tiles: record.tiles.map((tile) => ({
1190
+ index: tile.index,
1191
+ label: tile.label,
1192
+ x: tile.x,
1193
+ scrollY: tile.scrollY,
1194
+ width: tile.width,
1195
+ height: tile.height,
1196
+ })),
1197
+ }
1198
+ : undefined;
1199
+ try {
1200
+ const saved = saveQaSnapshot(job.target, { viewport: record.viewport, theme: record.theme, state: record.state }, {
1201
+ signature: readPackSignature(packDir, record),
1202
+ domHtml: readPackDom(packDir, record),
1203
+ screenshotPng: readPackFullPage(packDir, record),
1204
+ ...(persisted ? { critique: persisted } : {}),
1205
+ }, options.snapshotStore ?? {});
1206
+ savedSnapshots.set(record.id, saved.path);
1207
+ log(`snapshot ${record.id}: saved${persisted ? " with critique" : ""}`);
1208
+ }
1209
+ catch (err) {
618
1210
  blockers.push({
619
1211
  stage: "snapshot",
620
- context_id: ctx.id,
1212
+ context_id: record.id,
1213
+ reason: `snapshot could not be persisted from the pack: ${err instanceof Error ? err.message : String(err)}`,
1214
+ });
1215
+ }
1216
+ }
1217
+ for (const row of critique) {
1218
+ if (row.outcome === "passed" && !savedSnapshots.has(row.context_id)) {
1219
+ blockers.push({
1220
+ stage: "snapshot",
1221
+ context_id: row.context_id,
621
1222
  reason: "signoff critique passed but no QA snapshot was persisted for the context",
622
1223
  });
623
1224
  }
624
1225
  }
625
1226
  }
626
- wall.critique = Date.now() - critiqueStart;
627
- // -------------------------------------------------------------- snapshot
628
- // Critique invocations carry --qa-snapshot in signoff mode. When the
629
- // manifest requires no visual pass (visual === "none"), a passing signoff
630
- // still needs its baseline persisted, so a dedicated snapshot pass runs.
631
- const snapshotStart = Date.now();
632
- if (job.mode === "signoff" && visual === "none" && blockers.length === 0) {
1227
+ else if (job.mode === "signoff" && visual === "none" && blockers.length === 0) {
633
1228
  const stillClean = commands.every((command) => command.outcome === "passed");
634
1229
  if (stillClean) {
635
1230
  for (const ctx of contexts) {
1231
+ if (pastDeadline())
1232
+ break;
636
1233
  const outPrefix = join(outDir, `${ctx.id}-snapshot`);
637
1234
  const argv = [
638
1235
  ...browseArgv,
@@ -675,6 +1272,8 @@ export async function runQaMatrix(options) {
675
1272
  }
676
1273
  }
677
1274
  wall.snapshot = Date.now() - snapshotStart;
1275
+ if (job.mode === "signoff")
1276
+ stagesRun.push("snapshot");
678
1277
  if (job.mode === "signoff") {
679
1278
  const allSaved = contexts.length > 0 && contexts.every((ctx) => savedSnapshots.has(ctx.id));
680
1279
  const lastPath = [...savedSnapshots.values()].pop();