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
@@ -2,34 +2,73 @@
2
2
  // gates, interactions, critique, snapshot) in one command. The agent's loop
3
3
  // collapses to: build the job, run this, read page-qa-result.json.
4
4
 
5
- import { readFileSync } from "node:fs";
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
6
8
  import { join, resolve } from "node:path";
7
- import type { Command } from "commander";
8
- import type { EmitContext } from "../commander.ts";
9
- import { QA_RUN_RESULT_FILENAME, runQaMatrix } from "../lib/browser/qa-run.ts";
9
+ import { type Command, Option } from "commander";
10
+ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
11
+ import { recordQaSignal } from "../core/agents/qa-signal.ts";
12
+ import { artifactsRoot } from "../core/artifacts/index.ts";
13
+ import { resolveBinName, reviewPackAutoCleanEnabled } from "../core/config.ts";
14
+ import { createManagedQaOutParent, resolveQaRepoRoot } from "../core/qa-artifacts.ts";
15
+ import { acquireAdmission, admissionBaseDir, admissionStatus } from "../lib/admission.ts";
16
+ import {
17
+ deleteExpiredPacks,
18
+ PAGE_REVIEW_DEFAULT_RETENTION_MINUTES,
19
+ } from "../lib/browser/page-review-pack.ts";
20
+ import {
21
+ QA_RUN_JOB_FILENAME,
22
+ QA_RUN_RESULT_FILENAME,
23
+ QA_RUN_STATUS_FILENAME,
24
+ type QaRunMatrixOptions,
25
+ runQaMatrix,
26
+ } from "../lib/browser/qa-run.ts";
10
27
  import {
11
28
  QA_RUN_JOB_SCHEMA_VERSION,
29
+ QA_RUN_STATUS_SCHEMA_VERSION,
12
30
  type QaRunJob,
31
+ type QaRunStatusDocument,
13
32
  validateQaRunJob,
14
33
  } from "../lib/browser/qa-run-contracts.ts";
34
+ import { coordEnv } from "../lib/env.ts";
35
+
36
+ /** Machine-wide admission resource that qa-run queues on by default. */
37
+ export const QA_ADMISSION_RESOURCE = "browser-qa";
38
+
39
+ /** Default concurrent browser-QA runs per machine. */
40
+ export const QA_ADMISSION_DEFAULT_CAPACITY = 2;
15
41
 
16
42
  interface QaRunOpts {
17
43
  job?: string;
18
44
  mode: string;
19
45
  concurrency?: string;
46
+ pool?: string;
20
47
  allowMetered?: boolean;
48
+ retain?: string;
21
49
  outDir?: string;
22
50
  json?: boolean;
51
+ detach?: boolean;
52
+ /** Commander --no-queue: true by default, false when the flag is passed. */
53
+ queue: boolean;
54
+ queueTimeout?: string;
55
+ queueCapacity?: string;
56
+ runId?: string;
23
57
  }
24
58
 
25
- export function registerQaRunCommand(program: Command, emit: EmitContext): void {
59
+ export function registerQaRunCommand(
60
+ program: Command,
61
+ emit: EmitContext,
62
+ context?: HarneryProgramContext,
63
+ ): void {
26
64
  program
27
65
  .command("qa-run <target>")
28
66
  .description(
29
67
  "Run the whole page-QA matrix in one command: QA planner, deterministic " +
30
68
  "gates per viewport/theme/state through a bounded process pool, serial " +
31
- "interaction assertions, manifest-required vision critique, and the QA " +
32
- "snapshot. Writes page-qa-result.json with a fail-closed verdict.",
69
+ "interaction assertions, capture of every context into a page review pack " +
70
+ "(browsers closed), one in-process pool of vision calls over the pack, and the " +
71
+ "QA snapshot. Writes page-qa-result.json with a fail-closed verdict.",
33
72
  )
34
73
  .option(
35
74
  "--job <file>",
@@ -44,19 +83,62 @@ export function registerQaRunCommand(program: Command, emit: EmitContext): void
44
83
  )
45
84
  .option(
46
85
  "--concurrency <n>",
47
- "Concurrent deterministic captures (1-8; default 2). Interactions and critique " +
48
- "always run serially.",
86
+ "Concurrent deterministic gates and pack captures (1-8; default 2). Interactions " +
87
+ "always run serially; the judge pool is policy.critique_pool / --pool.",
88
+ )
89
+ .option(
90
+ "--pool <n>",
91
+ "Vision calls in flight during the judge stage, across every context (1-16; default: " +
92
+ "the provider's own concurrency). Sets policy.critique_pool.",
49
93
  )
50
94
  .option(
51
95
  "--allow-metered",
52
96
  "Permit the critique provider's metered-API fallback. Default: subscription-backed " +
53
97
  "headless harnesses only — exhaustion becomes an incomplete blocker, never a metered call.",
54
98
  )
99
+ .option(
100
+ "--retain <minutes>",
101
+ `Minutes the run's page review pack lives after the judge finishes before the whole ` +
102
+ `pack is deleted (1-43200; default ${PAGE_REVIEW_DEFAULT_RETENTION_MINUTES}). Sets ` +
103
+ "policy.review_pack_retention_minutes. The result document keeps every finding inline.",
104
+ )
55
105
  .option(
56
106
  "--out-dir <dir>",
57
- "Artifact + result directory (default: .qa-run/<timestamp> under the current directory).",
107
+ "PARENT output directory (default: a managed .harnery/artifacts workspace). Every " +
108
+ "invocation writes into its own run-<run_id>/ beneath it and updates the parent's " +
109
+ "latest.json pointer — prior runs are never overwritten and never pass as current.",
58
110
  )
59
111
  .option("--json", "Print the full QaRunResult JSON to stdout.")
112
+ .option(
113
+ "--detach",
114
+ "Launch the run as a detached background process whose authoritative state " +
115
+ "lives on disk (job.json, run-status.json, runner.log, the result document). " +
116
+ "The command prints the run directory and returns immediately; a client " +
117
+ "disconnect no longer touches the job. Reconnect with qa-status <run-dir> --wait.",
118
+ )
119
+ .option(
120
+ "--no-queue",
121
+ "Skip machine-wide admission control. Default: the run waits for one of the " +
122
+ "browser-qa slots so simultaneous heavy runs take turns instead of thrashing " +
123
+ "the host; the wait is recorded as wall_time_ms.queue, never in total.",
124
+ )
125
+ .option(
126
+ "--queue-timeout <minutes>",
127
+ "Maximum admission wait before the run finalizes incomplete with an " +
128
+ '"admission" blocker (default 20).',
129
+ )
130
+ .option(
131
+ "--queue-capacity <n>",
132
+ "Concurrent browser-qa runs this machine should allow (1-8; default " +
133
+ "HARNERY_QA_ADMISSION_CAPACITY or 2). Advisory — the smallest capacity among " +
134
+ "concurrent waiters effectively governs.",
135
+ )
136
+ .addOption(
137
+ new Option(
138
+ "--run-id <uuid>",
139
+ "Internal: pre-minted run ID (detach child plumbing).",
140
+ ).hideHelp(),
141
+ )
60
142
  .addHelpText(
61
143
  "after",
62
144
  "\nExit codes: 0 verdict passed · 1 usage or job-validation error · " +
@@ -138,12 +220,67 @@ export function registerQaRunCommand(program: Command, emit: EmitContext): void
138
220
  }
139
221
  policy.command_concurrency = n;
140
222
  }
223
+ if (opts.pool !== undefined) {
224
+ const n = Number.parseInt(opts.pool, 10);
225
+ if (!Number.isInteger(n) || n < 1 || n > 16) {
226
+ emit.error({
227
+ code: "qa_run_invalid_pool",
228
+ message: "--pool must be an integer between 1 and 16",
229
+ });
230
+ process.exitCode = 1;
231
+ return;
232
+ }
233
+ policy.critique_pool = n;
234
+ }
141
235
  if (opts.allowMetered) policy.allow_metered_critique = true;
236
+ if (opts.retain !== undefined) {
237
+ const n = Number.parseInt(opts.retain, 10);
238
+ if (!Number.isInteger(n) || n < 1 || n > 43_200) {
239
+ emit.error({
240
+ code: "qa_run_invalid_retain",
241
+ message: "--retain must be an integer between 1 and 43200 (minutes)",
242
+ });
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ policy.review_pack_retention_minutes = n;
247
+ }
142
248
  const job: QaRunJob = { ...validation.job, policy };
143
249
 
144
- const outDir = resolve(
145
- opts.outDir ?? join(".qa-run", new Date().toISOString().replace(/[:.]/g, "-")),
146
- );
250
+ const repoRoot = resolveQaRepoRoot(context);
251
+ // Expired packs in the store go before this run adds one; opt-in via
252
+ // review_pack.auto_clean. Only managed, expired packs are deleted.
253
+ if (reviewPackAutoCleanEnabled(repoRoot)) {
254
+ try {
255
+ const swept = deleteExpiredPacks({ roots: [artifactsRoot(repoRoot)], dryRun: false });
256
+ if (swept.deleted.length > 0) {
257
+ emit.log(
258
+ `removed ${swept.deleted.length} expired page review pack${swept.deleted.length === 1 ? "" : "s"}`,
259
+ "info",
260
+ );
261
+ }
262
+ } catch (err: unknown) {
263
+ emit.log(
264
+ `expired pack sweep skipped: ${err instanceof Error ? err.message : String(err)}`,
265
+ "warn",
266
+ );
267
+ }
268
+ }
269
+
270
+ let outParent: string;
271
+ try {
272
+ outParent =
273
+ opts.outDir !== undefined
274
+ ? resolve(opts.outDir)
275
+ : createManagedQaOutParent(repoRoot, "qa-run");
276
+ } catch (err: unknown) {
277
+ emit.error({
278
+ code: "qa_run_output_unavailable",
279
+ message: `cannot create managed QA output: ${err instanceof Error ? err.message : String(err)}`,
280
+ });
281
+ process.exitCode = 1;
282
+ return;
283
+ }
147
284
  const cliScript = process.argv[1];
148
285
  if (!cliScript) {
149
286
  emit.error({
@@ -157,23 +294,205 @@ export function registerQaRunCommand(program: Command, emit: EmitContext): void
157
294
  // wiring (critique provider, cookie jar, profiles) unchanged.
158
295
  const browseArgv = [process.execPath, cliScript, "browse"];
159
296
 
297
+ // ----------------------------------------------------- queue settings
298
+ let queueCapacity = QA_ADMISSION_DEFAULT_CAPACITY;
299
+ const capacityEnv = coordEnv("QA_ADMISSION_CAPACITY");
300
+ if (capacityEnv !== undefined) {
301
+ const n = Number.parseInt(capacityEnv, 10);
302
+ if (Number.isInteger(n) && n >= 1 && n <= 8) queueCapacity = n;
303
+ }
304
+ if (opts.queueCapacity !== undefined) {
305
+ const n = Number.parseInt(opts.queueCapacity, 10);
306
+ if (!Number.isInteger(n) || n < 1 || n > 8) {
307
+ emit.error({
308
+ code: "qa_run_invalid_queue_capacity",
309
+ message: "--queue-capacity must be an integer between 1 and 8",
310
+ });
311
+ process.exitCode = 1;
312
+ return;
313
+ }
314
+ queueCapacity = n;
315
+ }
316
+ let queueTimeoutMs = 20 * 60_000;
317
+ if (opts.queueTimeout !== undefined) {
318
+ const n = Number.parseInt(opts.queueTimeout, 10);
319
+ if (!Number.isInteger(n) || n < 1 || n > 240) {
320
+ emit.error({
321
+ code: "qa_run_invalid_queue_timeout",
322
+ message: "--queue-timeout must be an integer between 1 and 240 (minutes)",
323
+ });
324
+ process.exitCode = 1;
325
+ return;
326
+ }
327
+ queueTimeoutMs = n * 60_000;
328
+ }
329
+
330
+ // ------------------------------------------------------------- detach
331
+ if (opts.detach) {
332
+ const runId = randomUUID();
333
+ const runDir = join(outParent, `run-${runId}`);
334
+ mkdirSync(runDir, { recursive: true });
335
+ writeFileSync(join(runDir, QA_RUN_JOB_FILENAME), `${JSON.stringify(job, null, 2)}\n`);
336
+ const logPath = join(runDir, "runner.log");
337
+ const logFd = openSync(logPath, "a");
338
+ // The child is this same command minus --detach plus the pre-minted
339
+ // run ID, detached from the terminal with its output on disk: a
340
+ // client disconnect kills nothing and loses nothing.
341
+ const childArgv = [
342
+ cliScript,
343
+ "qa-run",
344
+ target,
345
+ ...(opts.job ? ["--job", resolve(opts.job)] : []),
346
+ "--mode",
347
+ job.mode,
348
+ ...(opts.concurrency !== undefined ? ["--concurrency", opts.concurrency] : []),
349
+ ...(opts.pool !== undefined ? ["--pool", opts.pool] : []),
350
+ ...(opts.allowMetered ? ["--allow-metered"] : []),
351
+ ...(opts.retain !== undefined ? ["--retain", opts.retain] : []),
352
+ "--out-dir",
353
+ outParent,
354
+ "--run-id",
355
+ runId,
356
+ ...(opts.queue === false ? ["--no-queue"] : []),
357
+ ...(opts.queueTimeout !== undefined ? ["--queue-timeout", opts.queueTimeout] : []),
358
+ ...(opts.queueCapacity !== undefined ? ["--queue-capacity", opts.queueCapacity] : []),
359
+ ];
360
+ const child = spawn(process.execPath, childArgv, {
361
+ detached: true,
362
+ stdio: ["ignore", logFd, logFd],
363
+ });
364
+ child.unref();
365
+ closeSync(logFd);
366
+ const launching: QaRunStatusDocument = {
367
+ schema_version: QA_RUN_STATUS_SCHEMA_VERSION,
368
+ run_id: runId,
369
+ pid: child.pid ?? 0,
370
+ state: "launching",
371
+ stage: null,
372
+ started_at: new Date().toISOString(),
373
+ updated_at: new Date().toISOString(),
374
+ };
375
+ writeFileSync(
376
+ join(runDir, QA_RUN_STATUS_FILENAME),
377
+ `${JSON.stringify(launching, null, 2)}\n`,
378
+ );
379
+ const binName = resolveBinName();
380
+ emit.log(`detached run ${runId} (pid ${child.pid ?? "unknown"})`, "info");
381
+ emit.log(`run dir: ${runDir}`, "info");
382
+ emit.log(`log: ${logPath}`, "info");
383
+ emit.log(`reconnect: ${binName} qa-status ${runDir} --wait`, "info");
384
+ if (opts.json) {
385
+ emit.data({
386
+ detached: true,
387
+ run_id: runId,
388
+ pid: child.pid ?? null,
389
+ run_dir: runDir,
390
+ log: logPath,
391
+ });
392
+ }
393
+ return;
394
+ }
395
+
396
+ // ---------------------------------------------------------- admission
397
+ let admission: QaRunMatrixOptions["admission"];
398
+ if (opts.queue !== false) {
399
+ const dir = admissionBaseDir();
400
+ admission = {
401
+ resource: QA_ADMISSION_RESOURCE,
402
+ acquire: async (onWait) => {
403
+ let lastMessage = "";
404
+ const handle = await acquireAdmission(
405
+ { dir, resource: QA_ADMISSION_RESOURCE, capacity: queueCapacity },
406
+ {
407
+ label: `qa-run ${target}`,
408
+ timeoutMs: queueTimeoutMs,
409
+ onWait: (info) => {
410
+ const holders = info.holders.map((holder) => holder.label).join(", ") || "none";
411
+ const message =
412
+ `queued for a ${QA_ADMISSION_RESOURCE} slot: position ${info.position}, ` +
413
+ `capacity ${queueCapacity}, holder(s): ${holders}`;
414
+ if (message !== lastMessage) {
415
+ lastMessage = message;
416
+ onWait(message);
417
+ }
418
+ },
419
+ },
420
+ );
421
+ return handle.release;
422
+ },
423
+ holders: () =>
424
+ admissionStatus({ dir, resource: QA_ADMISSION_RESOURCE }).holders.map((holder) => ({
425
+ label: holder.label,
426
+ pid: holder.pid,
427
+ })),
428
+ };
429
+ }
430
+
431
+ // One git probe for the identity block. Failure is not an error: a
432
+ // target outside any repository legitimately records "unknown".
433
+ let revisionProbe: { tested_revision?: string; worktree_dirty?: boolean } | undefined;
434
+ if (job.tested_revision === undefined) {
435
+ try {
436
+ const head = execFileSync("git", ["rev-parse", "HEAD"], {
437
+ encoding: "utf8",
438
+ stdio: ["ignore", "pipe", "ignore"],
439
+ }).trim();
440
+ const porcelain = execFileSync("git", ["status", "--porcelain"], {
441
+ encoding: "utf8",
442
+ stdio: ["ignore", "pipe", "ignore"],
443
+ });
444
+ revisionProbe = { tested_revision: head, worktree_dirty: porcelain.trim().length > 0 };
445
+ } catch {
446
+ revisionProbe = undefined;
447
+ }
448
+ }
449
+
450
+ const binName = resolveBinName();
160
451
  const result = await runQaMatrix({
161
452
  job,
162
- outDir,
453
+ outParent,
163
454
  browseArgv,
455
+ ...(opts.runId !== undefined ? { runId: opts.runId } : {}),
456
+ ...(revisionProbe ? { revisionProbe } : {}),
457
+ ...(admission ? { admission } : {}),
458
+ // The judge runs in this process: the host's provider is loaded here,
459
+ // after the headless-only policy has been applied to the environment.
460
+ ...(context?.critiqueProvider ? { critiqueProvider: context.critiqueProvider } : {}),
461
+ ...(context?.critiqueProviderLoader
462
+ ? { critiqueProviderLoader: context.critiqueProviderLoader }
463
+ : {}),
464
+ reviewPackJudgeCommand: (packDir) => `${binName} review-pack judge ${packDir}`,
465
+ // A pack in the managed store expires and is deleted; one under an
466
+ // explicit --out-dir is the caller's and is never swept automatically.
467
+ reviewPackManaged: opts.outDir === undefined,
164
468
  onLog: (message) => emit.log(message, "info"),
165
469
  });
166
470
 
471
+ // Publish the verdict for `agents status`, so an operator reading a
472
+ // session sees the runner clock instead of inferring QA time from
473
+ // session age. Best-effort by contract: never throws.
474
+ recordQaSignal(result);
475
+
167
476
  if (opts.json) emit.data(result);
168
- const resultPath = join(outDir, QA_RUN_RESULT_FILENAME);
477
+ const resultPath = join(result.run.out_dir, QA_RUN_RESULT_FILENAME);
169
478
  for (const blocker of result.blockers) {
170
479
  emit.log(
171
480
  `blocker [${blocker.stage}${blocker.context_id ? ` ${blocker.context_id}` : ""}]: ${blocker.reason}`,
172
481
  "warn",
173
482
  );
174
483
  }
484
+ if (result.review_pack) {
485
+ emit.log(
486
+ `review pack: ${result.review_pack.review}` +
487
+ (result.review_pack.expires_at
488
+ ? ` (deleted after ${result.review_pack.expires_at})`
489
+ : ""),
490
+ "info",
491
+ );
492
+ }
175
493
  emit.log(
176
- `verdict: ${result.verdict} — ${result.contexts.length} context${result.contexts.length === 1 ? "" : "s"}, ` +
494
+ `verdict: ${result.verdict} — run ${result.run.run_id}, ` +
495
+ `${result.contexts.length} context${result.contexts.length === 1 ? "" : "s"}, ` +
177
496
  `${result.commands.length} command${result.commands.length === 1 ? "" : "s"}, ` +
178
497
  `${result.blockers.length} blocker${result.blockers.length === 1 ? "" : "s"}; result: ${resultPath}`,
179
498
  result.verdict === "passed" ? "info" : "warn",