agent-dealer 1.3.1 → 1.3.3

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 (44) hide show
  1. package/bundle/server/dist/adapters/agent-health.test.js +52 -0
  2. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  3. package/bundle/server/dist/adapters/github.js +50 -0
  4. package/bundle/server/dist/adapters/github.test.js +89 -1
  5. package/bundle/server/dist/capacity/service.js +66 -6
  6. package/bundle/server/dist/capacity/service.test.js +220 -0
  7. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  8. package/bundle/server/dist/coordinator/commands.js +89 -9
  9. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  10. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  11. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  12. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  13. package/bundle/server/dist/coordinator/prompts.js +57 -2
  14. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  15. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
  16. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
  17. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  18. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  19. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  20. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  21. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  22. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  23. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  24. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  25. package/bundle/server/dist/db/index.js +8 -0
  26. package/bundle/server/dist/db/schema.sql +5 -1
  27. package/bundle/server/dist/paths.js +24 -0
  28. package/bundle/server/dist/repository/issues.js +8 -1
  29. package/bundle/server/dist/repository/issues.test.js +34 -0
  30. package/bundle/server/dist/repository/runtime-availability.js +29 -11
  31. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  32. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  33. package/bundle/server/dist/routes/issues.js +23 -8
  34. package/bundle/server/dist/routes/issues.test.js +80 -0
  35. package/bundle/server/dist/runners/usage-cap.js +66 -15
  36. package/bundle/server/dist/runners/usage-cap.test.js +96 -2
  37. package/bundle/server/package.json +2 -2
  38. package/bundle/server/static-ui/assets/index-B_pNUDRY.js +64 -0
  39. package/bundle/server/static-ui/index.html +1 -1
  40. package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
  41. package/bundle/shared/dist/runtime-capacity.js +19 -0
  42. package/bundle/shared/package.json +1 -1
  43. package/package.json +1 -1
  44. package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
@@ -288,9 +288,64 @@ test("NOT-303: museDeveloper prompt names the pre-installed headless shell when
288
288
  assert.match(prompt, /--screenshot=<png>/);
289
289
  assert.match(prompt, /Google Chrome\.app/);
290
290
  });
291
- test("NOT-303: a non-Muse developer prompt has no visual-QA section", () => {
291
+ // NOT-381: non-Muse runtimes attempt in-session browser verification instead of
292
+ // the old blanket no-browser assumption — the prompt carries the bounded probe,
293
+ // the real-app requirement, the default viewports, and the receipt format.
294
+ test("NOT-381: a non-Muse developer prompt carries the capable-runtime visual-QA section", () => {
292
295
  const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt" });
293
- assert.doesNotMatch(prompt, /## Visual QA/);
296
+ assert.match(prompt, /## Visual QA \(in-session browser verification\)/);
297
+ assert.match(prompt, /ONE bounded capability probe/);
298
+ assert.match(prompt, /REAL backend and frontend/);
299
+ assert.match(prompt, /1440x900/);
300
+ assert.match(prompt, /390x800/);
301
+ assert.match(prompt, /do not install browsers or packages/);
302
+ assert.match(prompt, /do not retry the failed capability/);
303
+ assert.match(prompt, /do not weaken any sandbox flag/);
304
+ assert.match(prompt, /never commit it/);
305
+ assert.doesNotMatch(prompt, /RegisterApplication/);
306
+ });
307
+ // NOT-381: the policy is runtime-aware — Muse (boolean or runtime id) keeps the
308
+ // NOT-303 preflight, Claude Code / Codex Local / Cursor Local get the attempt.
309
+ test("NOT-381: runtime policy keeps Muse behavior and gives capable runtimes the attempt", () => {
310
+ const saved = process.env.MUSE_HEADLESS_SHELL_BIN;
311
+ delete process.env.MUSE_HEADLESS_SHELL_BIN;
312
+ try {
313
+ for (const runtime of ["claude_code", "codex_local", "cursor_local"]) {
314
+ const prompt = buildDeveloperPrompt({
315
+ taskSnapshot,
316
+ round: 1,
317
+ deckId: "deck-1",
318
+ worktreePath: "/wt",
319
+ runtime,
320
+ });
321
+ assert.match(prompt, /## Visual QA \(in-session browser verification\)/, runtime);
322
+ assert.match(prompt, /ONE bounded capability probe/, runtime);
323
+ assert.doesNotMatch(prompt, /RegisterApplication/);
324
+ }
325
+ const museByRuntime = buildDeveloperPrompt({
326
+ taskSnapshot,
327
+ round: 1,
328
+ deckId: "deck-1",
329
+ worktreePath: "/wt",
330
+ runtime: "muse_code",
331
+ });
332
+ assert.match(museByRuntime, /## Visual QA \(screenshots\)/);
333
+ assert.match(museByRuntime, /RegisterApplication/);
334
+ assert.match(museByRuntime, /Visual QA: not run/);
335
+ const museByFlag = buildDeveloperPrompt({
336
+ taskSnapshot,
337
+ round: 1,
338
+ deckId: "deck-1",
339
+ worktreePath: "/wt",
340
+ museDeveloper: true,
341
+ runtime: "claude_code",
342
+ });
343
+ assert.match(museByFlag, /RegisterApplication/, "museDeveloper flag keeps the Muse preflight");
344
+ }
345
+ finally {
346
+ if (saved !== undefined)
347
+ process.env.MUSE_HEADLESS_SHELL_BIN = saved;
348
+ }
294
349
  });
295
350
  test("NOT-303: reviewer prompt states a missing screenshot is never a pass", () => {
296
351
  const prompt = buildReviewerPrompt(reviewerBase);
@@ -542,6 +597,96 @@ test("NOT-364: attachment-free developer prompts render no source-attachment sec
542
597
  assert.doesNotMatch(without, /## Source attachments/);
543
598
  assert.equal(buildDeveloperPrompt({ taskSnapshot, round: 1 }), without);
544
599
  });
600
+ // NOT-381: the reviewer input carries the coordinator-validated visual receipt
601
+ // SHA-bound to the pinned head; unavailable is a routing state, never a pass.
602
+ test("NOT-381: reviewer prompt includes the validated visual receipt bound to the pinned head", () => {
603
+ const headSha = "b".repeat(40);
604
+ const prompt = buildReviewerPrompt({
605
+ ...reviewerBase,
606
+ visualQa: {
607
+ pinnedHeadSha: headSha,
608
+ record: {
609
+ kind: "receipt",
610
+ createdAt: "2026-10-08T00:00:00.000Z",
611
+ content: {
612
+ status: "verified",
613
+ headSha,
614
+ realApp: "real (no mocks)",
615
+ scenario: "/widgets/new — fill the form and submit",
616
+ viewports: ["1440x900", "390x800"],
617
+ commands: "npm run dev; capture",
618
+ screenshots: [
619
+ { fileName: "0-desktop.png", blobPath: "/blobs/0-desktop.png", sizeBytes: 10, sha256: "ab".repeat(32) },
620
+ ],
621
+ capability: null,
622
+ note: null,
623
+ recordedAt: "2026-10-08T00:00:00.000Z",
624
+ },
625
+ },
626
+ },
627
+ });
628
+ assert.match(prompt, /## Visual QA \(developer receipt — SHA-bound to this head\)/);
629
+ assert.match(prompt, new RegExp(headSha));
630
+ assert.match(prompt, /real \(no mocks\)/);
631
+ assert.match(prompt, /0-desktop\.png/);
632
+ });
633
+ test("NOT-381: reviewer prompt presents unavailable and not-required receipts without normalizing to a pass", () => {
634
+ const unavailable = buildReviewerPrompt({
635
+ ...reviewerBase,
636
+ visualQa: {
637
+ pinnedHeadSha: reviewerBase.headSha,
638
+ record: {
639
+ kind: "receipt",
640
+ createdAt: "2026-10-08T00:00:00.000Z",
641
+ content: {
642
+ status: "unavailable",
643
+ headSha: reviewerBase.headSha,
644
+ realApp: null,
645
+ scenario: null,
646
+ viewports: [],
647
+ commands: null,
648
+ screenshots: [],
649
+ capability: "loopback listen: EPERM",
650
+ note: null,
651
+ recordedAt: "2026-10-08T00:00:00.000Z",
652
+ },
653
+ },
654
+ },
655
+ });
656
+ assert.match(unavailable, /## Visual QA \(UNAVAILABLE — not verified\)/);
657
+ assert.match(unavailable, /loopback listen: EPERM/);
658
+ assert.match(unavailable, /never a pass/);
659
+ assert.doesNotMatch(unavailable, /SHA-bound to this head/);
660
+ const notRequired = buildReviewerPrompt({
661
+ ...reviewerBase,
662
+ visualQa: {
663
+ pinnedHeadSha: reviewerBase.headSha,
664
+ record: {
665
+ kind: "receipt",
666
+ createdAt: "2026-10-08T00:00:00.000Z",
667
+ content: {
668
+ status: "not_required",
669
+ headSha: null,
670
+ realApp: null,
671
+ scenario: null,
672
+ viewports: [],
673
+ commands: null,
674
+ screenshots: [],
675
+ capability: null,
676
+ note: null,
677
+ recordedAt: "2026-10-08T00:00:00.000Z",
678
+ },
679
+ },
680
+ },
681
+ });
682
+ assert.match(notRequired, /## Visual QA \(not required/);
683
+ assert.doesNotMatch(notRequired, /SHA-bound to this head/);
684
+ const without = buildReviewerPrompt(reviewerBase);
685
+ assert.doesNotMatch(without, /## Visual QA \(developer receipt/);
686
+ assert.doesNotMatch(without, /## Visual QA \(UNAVAILABLE/);
687
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, visualQa: undefined }), without);
688
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, visualQa: null }), without);
689
+ });
545
690
  // NOT-364: reviewers get the immutable manifest as metadata only — no
546
691
  // worktree path, no server blob path, no file contents.
547
692
  test("NOT-364: reviewer prompt carries manifest metadata without file paths", () => {
@@ -576,3 +721,92 @@ test("NOT-364: reviewer prompt carries manifest metadata without file paths", ()
576
721
  const without = buildReviewerPrompt(reviewerBase);
577
722
  assert.doesNotMatch(without, /## Source attachments/);
578
723
  });
724
+ // NOT-384: available visual evidence cites the staged paths, both SHAs, and
725
+ // the diff summary, and instructs the reviewer to judge both viewports.
726
+ test("NOT-384: reviewer prompt cites staged SHA-bound screenshots and diff for available evidence", () => {
727
+ const prompt = buildReviewerPrompt({
728
+ ...reviewerBase,
729
+ visualEvidence: {
730
+ state: "available",
731
+ headSha: "b".repeat(40),
732
+ baseSha: "a".repeat(40),
733
+ runId: 42,
734
+ staged: {
735
+ stagedDir: "/wt/.agent-dealer-visual",
736
+ screenshotsDir: "/wt/.agent-dealer-visual/ui-screenshots",
737
+ baselineDir: "/wt/.agent-dealer-visual/ui-baseline",
738
+ diffDir: "/wt/.agent-dealer-visual/ui-diff",
739
+ summaryPath: "/wt/.agent-dealer-visual/ui-diff/SUMMARY.md",
740
+ },
741
+ summary: "## UI diff (base vs head, report-only)\n\n| `issues-home` | 1440x900 | 12 |",
742
+ files: {
743
+ "ui-screenshots": { files: ["issues-home-1440x900.png"], total: 8 },
744
+ "ui-baseline": { files: ["issues-home-1440x900.png"], total: 8 },
745
+ "ui-diff": { files: ["SUMMARY.md", "diff-issues-home-1440x900.png"], total: 9 },
746
+ },
747
+ reason: null,
748
+ },
749
+ });
750
+ assert.match(prompt, /## Visual evidence \(CI-captured at b{8}\)/);
751
+ assert.ok(prompt.includes("/wt/.agent-dealer-visual/ui-screenshots/"), "staged head captures must be named");
752
+ assert.ok(prompt.includes("/wt/.agent-dealer-visual/ui-baseline/"), "staged baseline must be named");
753
+ assert.ok(prompt.includes("/wt/.agent-dealer-visual/ui-diff/"), "staged diff must be named");
754
+ assert.ok(prompt.includes("`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`"), "head SHA must be cited");
755
+ assert.ok(prompt.includes("`aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`"), "base SHA must be cited");
756
+ assert.match(prompt, /run 42/);
757
+ assert.match(prompt, /1440x900 and 390x800/);
758
+ assert.match(prompt, /Judge the screenshots and the base-vs-head diff at both viewports/);
759
+ assert.match(prompt, /nonzero pixel diff is evidence, never an automatic failure/);
760
+ assert.match(prompt, /## UI diff \(base vs head, report-only\)/);
761
+ assert.match(prompt, /visual QA: verified/);
762
+ });
763
+ // NOT-384: missing and failed evidence name the state and forbid a visual
764
+ // pass — and forbid a blocking finding about the evidence itself, since Dealer
765
+ // holds the head for an operator instead of queuing a repair round.
766
+ test("NOT-384: reviewer prompt forbids a visual pass for missing evidence", () => {
767
+ const prompt = buildReviewerPrompt({
768
+ ...reviewerBase,
769
+ visualEvidence: {
770
+ state: "missing",
771
+ headSha: "b".repeat(40),
772
+ baseSha: "a".repeat(40),
773
+ runId: null,
774
+ staged: null,
775
+ summary: null,
776
+ files: null,
777
+ reason: "no Visual workflow run found for head bbbbbbbb",
778
+ },
779
+ });
780
+ assert.match(prompt, /## Visual evidence: missing/);
781
+ assert.ok(prompt.includes("`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`"), "head SHA must be named");
782
+ assert.match(prompt, /no Visual workflow run found/);
783
+ assert.match(prompt, /Do NOT report a visual pass/);
784
+ assert.match(prompt, /visual QA: not run \(missing\)/);
785
+ assert.match(prompt, /Do not raise a blocking finding about the missing evidence itself/);
786
+ assert.match(prompt, /never start solely for missing visual evidence/);
787
+ });
788
+ test("NOT-384: reviewer prompt forbids a visual pass for failed evidence", () => {
789
+ const prompt = buildReviewerPrompt({
790
+ ...reviewerBase,
791
+ visualEvidence: {
792
+ state: "failed",
793
+ headSha: "b".repeat(40),
794
+ baseSha: "a".repeat(40),
795
+ runId: 43,
796
+ staged: null,
797
+ summary: null,
798
+ files: null,
799
+ reason: "Visual run 43 for this head concluded failure — its captures are unusable",
800
+ },
801
+ });
802
+ assert.match(prompt, /## Visual evidence: failed/);
803
+ assert.match(prompt, /concluded failure/);
804
+ assert.match(prompt, /Do NOT report a visual pass/);
805
+ assert.match(prompt, /visual QA: not run \(failed\)/);
806
+ assert.match(prompt, /Do not raise a blocking finding about the missing evidence itself/);
807
+ });
808
+ test("NOT-384: no visual evidence produces byte-for-byte the same reviewer prompt as before", () => {
809
+ const without = buildReviewerPrompt(reviewerBase);
810
+ assert.doesNotMatch(without, /## Visual evidence/);
811
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, visualEvidence: undefined }), without);
812
+ });
@@ -492,6 +492,26 @@ const defaultDeps = {
492
492
  now: () => new Date(),
493
493
  randomId: () => randomUUID(),
494
494
  };
495
+ /**
496
+ * NOT-382: wait for an already-started spawn race to settle, up to `graceMs`.
497
+ * Returns null on grace expiry — the caller proceeds to cleanup regardless so
498
+ * a spawn that never settles cannot wedge the probe past its own deadline.
499
+ */
500
+ async function awaitSpawnSettled(outcome, graceMs) {
501
+ let graceTimer;
502
+ try {
503
+ return await Promise.race([
504
+ outcome,
505
+ new Promise((resolve) => {
506
+ graceTimer = setTimeout(() => resolve(null), graceMs);
507
+ }),
508
+ ]);
509
+ }
510
+ finally {
511
+ if (graceTimer)
512
+ clearTimeout(graceTimer);
513
+ }
514
+ }
495
515
  /**
496
516
  * Run one probe through Dealer's real reviewer spawn path and return the
497
517
  * deterministic manifest. Every outcome — success, timeout, cancellation,
@@ -506,6 +526,7 @@ export async function runReviewerBrowserProbe(opts, deps = {}) {
506
526
  const appRoute = opts.appRoute ?? "/issues";
507
527
  const appInteraction = opts.appInteraction ?? "open the first issue and expand its timeline";
508
528
  const timeoutMs = opts.timeoutMs ?? reviewerSessionTimeoutMs();
529
+ const spawnSettleGraceMs = opts.spawnSettleGraceMs ?? 10_000;
509
530
  if (!SHA_HEX_RE.test(opts.headSha)) {
510
531
  throw new Error(`probe headSha must be a 40-char hex SHA (got ${JSON.stringify(opts.headSha)})`);
511
532
  }
@@ -687,7 +708,7 @@ export async function runReviewerBrowserProbe(opts, deps = {}) {
687
708
  startedAt,
688
709
  endedAt,
689
710
  timedOut: false,
690
- cancelled: false,
711
+ cancelled: Boolean(opts.signal?.aborted),
691
712
  exitCode: null,
692
713
  launch: { bin, argv, cwd: worktreePath ?? opts.repoPath, mcpConfigPath: null, mcpEnvKeys: [], policy },
693
714
  cleanup,
@@ -711,7 +732,7 @@ export async function runReviewerBrowserProbe(opts, deps = {}) {
711
732
  startedAt,
712
733
  endedAt: d.now().toISOString(),
713
734
  timedOut: false,
714
- cancelled: false,
735
+ cancelled: Boolean(opts.signal?.aborted),
715
736
  exitCode: null,
716
737
  launch: {
717
738
  bin,
@@ -745,7 +766,7 @@ export async function runReviewerBrowserProbe(opts, deps = {}) {
745
766
  startedAt,
746
767
  endedAt: d.now().toISOString(),
747
768
  timedOut: false,
748
- cancelled: false,
769
+ cancelled: Boolean(opts.signal?.aborted),
749
770
  exitCode: null,
750
771
  launch: {
751
772
  bin,
@@ -800,10 +821,61 @@ export async function runReviewerBrowserProbe(opts, deps = {}) {
800
821
  childPids.push(pid);
801
822
  },
802
823
  };
803
- const spawned = await d.spawn(spawnInput);
804
- exitCode = spawned.exitCode;
805
- timedOut = spawned.timedOut;
806
- transcript = spawned.transcript;
824
+ // NOT-382: enforce the deadline HERE, not only inside the spawn. The probe
825
+ // used to `await d.spawn(...)` bare: a spawn that never settles (wedged
826
+ // CLI, or a fake that ignores timeoutMs/signal) hung the probe forever
827
+ // with neither timedOut nor cancelled recorded. The spawn's own
828
+ // timeout/signal handling still does the actual killing; the probe owns
829
+ // the deadline so the manifest always records which bound fired.
830
+ const spawnOutcome = d.spawn(spawnInput).then((result) => ({ kind: "spawned", result }), (error) => ({ kind: "spawn-error", error }));
831
+ let probeTimer;
832
+ const probeTimeout = new Promise((resolve) => {
833
+ // Deliberately NOT unref'd: the deadline must hold the event loop even
834
+ // when the spawn left no live handles behind.
835
+ probeTimer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
836
+ });
837
+ let onProbeAbort;
838
+ const probeAborted = new Promise((resolve) => {
839
+ if (!opts.signal)
840
+ return; // no signal: stays pending, the race ignores it
841
+ if (opts.signal.aborted) {
842
+ resolve({ kind: "aborted" });
843
+ return;
844
+ }
845
+ onProbeAbort = () => resolve({ kind: "aborted" });
846
+ opts.signal.addEventListener("abort", onProbeAbort, { once: true });
847
+ });
848
+ try {
849
+ const winner = await Promise.race([spawnOutcome, probeTimeout, probeAborted]);
850
+ if (winner.kind === "spawn-error")
851
+ throw winner.error;
852
+ if (winner.kind === "spawned") {
853
+ exitCode = winner.result.exitCode;
854
+ timedOut = winner.result.timedOut;
855
+ transcript = winner.result.transcript;
856
+ }
857
+ else {
858
+ // The probe deadline fired first (timeout) or the caller aborted: stop
859
+ // waiting, but give the real spawn's kill path a bounded grace to
860
+ // settle so cleanup observes the reaped child instead of racing it. A
861
+ // spawn that never settles still proceeds after the grace.
862
+ if (winner.kind === "timeout")
863
+ timedOut = true;
864
+ const late = await awaitSpawnSettled(spawnOutcome, spawnSettleGraceMs);
865
+ if (late?.kind === "spawned") {
866
+ exitCode = late.result.exitCode;
867
+ transcript = late.result.transcript;
868
+ }
869
+ // A late spawn-error changes nothing: the deadline outcome already won.
870
+ // `cancelled` is recorded from opts.signal.aborted at manifest time.
871
+ }
872
+ }
873
+ finally {
874
+ if (probeTimer)
875
+ clearTimeout(probeTimer);
876
+ if (onProbeAbort)
877
+ opts.signal?.removeEventListener("abort", onProbeAbort);
878
+ }
807
879
  const report = parseReviewerBrowserProbeReport(transcript);
808
880
  let headShaVerified = false;
809
881
  try {
@@ -998,7 +1070,14 @@ export function overlayPlaywrightMcpConfig(opts) {
998
1070
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
999
1071
  }
1000
1072
  }
1001
- /** Manifest filename for one (runtime, contract) probe. */
1002
- export function probeManifestFilename(runtime, contract) {
1003
- return `${runtime}.${contract}.probe.json`;
1073
+ /**
1074
+ * Manifest filename for one (runtime, contract) probe. Control runs take a
1075
+ * `.timeout` / `.cancel` suffix when the matching CLI flag is set, so the four
1076
+ * NOT-382 operator controls never overwrite the positive manifest or each
1077
+ * other when they share one --out-dir.
1078
+ */
1079
+ export function probeManifestFilename(runtime, contract, opts) {
1080
+ const timeoutSuffix = opts?.timeout ? ".timeout" : "";
1081
+ const cancelSuffix = opts?.cancel ? ".cancel" : "";
1082
+ return `${runtime}.${contract}${timeoutSuffix}${cancelSuffix}.probe.json`;
1004
1083
  }
@@ -4,6 +4,7 @@
4
4
  // behavior for missing fields or an unexecuted probe.
5
5
  import { test } from "node:test";
6
6
  import assert from "node:assert/strict";
7
+ import { spawn, spawnSync } from "node:child_process";
7
8
  import { createHash } from "node:crypto";
8
9
  import fs from "node:fs";
9
10
  import os from "node:os";
@@ -646,9 +647,108 @@ test("manifest filenames are unique per (runtime, contract)", () => {
646
647
  }
647
648
  assert.equal(probeManifestFilename("claude_code", "direct"), "claude_code.direct.probe.json");
648
649
  });
650
+ test("control-run manifest filenames never overwrite the positive manifest (NOT-382)", () => {
651
+ assert.equal(probeManifestFilename("claude_code", "direct", { timeout: true }), "claude_code.direct.timeout.probe.json");
652
+ assert.equal(probeManifestFilename("claude_code", "direct", { cancel: true }), "claude_code.direct.cancel.probe.json");
653
+ assert.equal(probeManifestFilename("codex_local", "direct", { timeout: true, cancel: true }), "codex_local.direct.timeout.cancel.probe.json");
654
+ // All four operator controls + the positive manifest are distinct files.
655
+ const names = new Set([
656
+ probeManifestFilename("claude_code", "direct"),
657
+ probeManifestFilename("claude_code", "direct", { timeout: true }),
658
+ probeManifestFilename("claude_code", "direct", { cancel: true }),
659
+ probeManifestFilename("codex_local", "direct"),
660
+ probeManifestFilename("codex_local", "direct", { timeout: true }),
661
+ probeManifestFilename("codex_local", "direct", { cancel: true }),
662
+ ]);
663
+ assert.equal(names.size, 6);
664
+ });
649
665
  test("canonical JSON sorts keys deterministically", () => {
650
666
  assert.equal(canonicalProbeJson({ b: 1, a: { d: 4, c: 3 } }), `{\n "a": {\n "c": 3,\n "d": 4\n },\n "b": 1\n}\n`);
651
667
  });
668
+ test("runner enforces its own timeout when the spawn never settles (NOT-382)", async () => {
669
+ // A real but already-reaped pid: proves the child-process accounting runs on
670
+ // the timeout path (reaped → zero remaining).
671
+ const reapedPid = spawnSync("true").pid;
672
+ assert.ok(typeof reapedPid === "number" && reapedPid > 0, "expected a reaped pid from spawnSync(true)");
673
+ const manifest = await runReviewerBrowserProbe({
674
+ runtime: "claude_code",
675
+ contract: "direct",
676
+ repoPath: "/tmp/fake-repo",
677
+ headSha: HEAD,
678
+ deckId: "deck-1",
679
+ probeId: "probe-timeout-382",
680
+ timeoutMs: 50,
681
+ spawnSettleGraceMs: 25,
682
+ }, {
683
+ createWorktree: (async () => ({ path: "/tmp/wt", role: "reviewer", ref: HEAD, detached: true })),
684
+ removeWorktree: (async () => ({ removed: true })),
685
+ prepareDeck: async () => ({ ok: true, mcpConfigPath: "/tmp/fake-deck.json" }),
686
+ releaseDeck: async () => { },
687
+ readHead: (async () => HEAD),
688
+ now: () => new Date("2026-10-08T00:00:00.000Z"),
689
+ // Never settles and ignores timeoutMs/signal — pre-fix the probe hung on
690
+ // this await forever with neither timedOut nor cancelled recorded.
691
+ spawn: (async (input) => {
692
+ input.onSpawn?.(reapedPid);
693
+ await new Promise(() => { });
694
+ throw new Error("unreachable");
695
+ }),
696
+ });
697
+ assert.equal(manifest.timedOut, true);
698
+ assert.equal(manifest.cancelled, false);
699
+ assert.equal(manifest.status, "not_run");
700
+ assert.match(manifest.statusReason, /timed out/);
701
+ assert.equal(manifest.cleanup.childProcessesRemaining, 0);
702
+ assert.equal(manifest.cleanup.tempDirRemoved, true);
703
+ });
704
+ test("runner records cancellation when the signal aborts mid-session (NOT-382)", async () => {
705
+ const controller = new AbortController();
706
+ const manifest = await runReviewerBrowserProbe({
707
+ runtime: "claude_code",
708
+ contract: "direct",
709
+ repoPath: "/tmp/fake-repo",
710
+ headSha: HEAD,
711
+ deckId: "deck-1",
712
+ probeId: "probe-cancel-382",
713
+ timeoutMs: 60_000,
714
+ spawnSettleGraceMs: 500,
715
+ signal: controller.signal,
716
+ }, {
717
+ createWorktree: (async () => ({ path: "/tmp/wt", role: "reviewer", ref: HEAD, detached: true })),
718
+ removeWorktree: (async () => ({ removed: true })),
719
+ prepareDeck: async () => ({ ok: true, mcpConfigPath: "/tmp/fake-deck.json" }),
720
+ releaseDeck: async () => { },
721
+ readHead: (async () => HEAD),
722
+ now: () => new Date("2026-10-08T00:00:00.000Z"),
723
+ // Never settles on its own; kills its real child on abort exactly like
724
+ // spawnCli does, so the probe must observe zero survivors. The abort is
725
+ // scheduled from inside the fake so it lands mid-session.
726
+ spawn: (async (input) => {
727
+ const child = spawn("sleep", ["30"]);
728
+ assert.ok(child.pid, "expected sleep to spawn");
729
+ child.unref();
730
+ child.on("error", () => { });
731
+ input.onSpawn?.(child.pid);
732
+ input.signal?.addEventListener("abort", () => {
733
+ try {
734
+ child.kill("SIGKILL");
735
+ }
736
+ catch {
737
+ // already gone — the probe's kill(0) check confirms it
738
+ }
739
+ }, { once: true });
740
+ setTimeout(() => controller.abort(new Error("--cancel-after-ms elapsed (test)")), 20);
741
+ await new Promise(() => { });
742
+ throw new Error("unreachable");
743
+ }),
744
+ });
745
+ assert.equal(manifest.cancelled, true);
746
+ assert.equal(manifest.timedOut, false);
747
+ assert.equal(manifest.status, "not_run");
748
+ assert.match(manifest.statusReason, /cancelled/);
749
+ assert.equal(manifest.cleanup.childProcessesRemaining, 0);
750
+ assert.equal(manifest.cleanup.tempDirRemoved, true);
751
+ });
652
752
  test("withManifestHash is stable for identical manifests", () => {
653
753
  const launch = {
654
754
  bin: "b",
@@ -28,10 +28,12 @@ import { parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
28
28
  import { getTaskSnapshot } from "./commands.js";
29
29
  import { extractOperatorCriteria } from "./operator-criteria.js";
30
30
  import { buildReviewerPrompt, formatDiffForPrompt, TOTAL_DIFF_LIMIT } from "./prompts.js";
31
+ import { cleanupStagedVisualArtifacts, resolveReviewerVisualEvidence, toReviewerVisualEvidenceInput, } from "./reviewer-visual-evidence.js";
32
+ import { readLatestVisualQa } from "./visual-qa.js";
31
33
  import { guidanceForNextSession } from "./guidance.js";
32
34
  import { realReviewerSpawn, reviewerSessionLogPath } from "./spawn.js";
33
35
  import { parseReviewerResult, normalizeReviewerResult, INCOMPLETE_REVIEW_FINGERPRINT, ReviewerResult as ReviewerResultSchema, } from "./reviewer-result.js";
34
- import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, mergeBase, fetchRef, diffShas, } from "../adapters/git-worktree.js";
36
+ import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, listChangedFiles, mergeBase, fetchRef, diffShas, } from "../adapters/git-worktree.js";
35
37
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
36
38
  import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
37
39
  import { realGithubAdapter } from "../adapters/github.js";
@@ -104,6 +106,14 @@ const EVENT_FOR_VERDICT = {
104
106
  };
105
107
  /** Best-effort: the worker never writes, so a non-clean checkout here is unexpected, not valuable work. */
106
108
  async function bestEffortRemove(repo, worktreePath) {
109
+ try {
110
+ // NOT-384: staged visual evidence goes first, so no residue survives the
111
+ // attempt even when the worktree removal below fails.
112
+ cleanupStagedVisualArtifacts(worktreePath);
113
+ }
114
+ catch {
115
+ // same courtesy as below — staging cleanup must never fail the attempt
116
+ }
107
117
  try {
108
118
  await safeRemoveWorktree({ repo, path: worktreePath, role: "reviewer" });
109
119
  }
@@ -335,12 +345,29 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
335
345
  const baseSha = await mergeBase({ repo: worktreePath, base: `origin/${baseBranch}`, head: headSha });
336
346
  const diff = await diffShas({ worktreePath, baseSha, headSha });
337
347
  const { truncated: diffTruncated, omittedPaths } = formatDiffForPrompt(diff);
348
+ // NOT-384: SHA-bound CI visual evidence for this head (never throws —
349
+ // every failure mode resolves to `missing` with a reason, and a missing
350
+ // or failed capture never counts as a visual pass).
351
+ const changedFiles = await listChangedFiles({ worktreePath, baseSha, headSha }).catch(() => null);
352
+ const visualEvidence = toReviewerVisualEvidenceInput(await resolveReviewerVisualEvidence({
353
+ issueId: issue.id,
354
+ workerSessionId: sessionId,
355
+ worktreePath,
356
+ baseSha,
357
+ headSha,
358
+ changedFiles,
359
+ github: deps.github,
360
+ fetcher: deps.visualEvidenceFetcher ?? null,
361
+ }));
338
362
  const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
339
363
  const guidance = guidanceForNextSession(issue.id, sessionId);
340
364
  // NOT-314: the frozen snapshot's operator criteria — the reviewer must not
341
365
  // treat missing operator evidence as a defect (Dealer gates the merge), but
342
366
  // the probe and doc must exist. Rendered only when non-empty.
343
367
  const operatorCriteria = extractOperatorCriteria(taskSnapshot.acceptanceCriteria);
368
+ // NOT-381: the latest coordinator-validated visual receipt (or loud
369
+ // rejection), SHA-checked against the pinned head inside the prompt section.
370
+ const visualQaRecord = readLatestVisualQa(issue.id);
344
371
  const prompt = buildReviewerPrompt({
345
372
  taskSnapshot,
346
373
  round: workItem.round,
@@ -354,6 +381,8 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
354
381
  deckId: snapshot?.deckId ?? null,
355
382
  guidance: guidance.length ? guidance : undefined,
356
383
  operatorCriteria: operatorCriteria.length > 0 ? operatorCriteria : undefined,
384
+ visualEvidence,
385
+ visualQa: visualQaRecord ? { record: visualQaRecord, pinnedHeadSha: headSha } : undefined,
357
386
  });
358
387
  // NOT-83 review finding — see developer-effect.ts's identical check for the full
359
388
  // rationale: re-verify this item is still leased right before the real spawn, since an
@@ -728,3 +728,57 @@ test("NOT-225 regression: a non-zero reviewer exit keeps its session_failed beha
728
728
  const crashReason = outcome.reason ?? "";
729
729
  assert.ok(!crashReason.includes("could not start"), "a spawned-then-crashed session is not a setup failure");
730
730
  });
731
+ test("NOT-384: available visual evidence stages CI artifacts under the reviewer worktree and cites them", async () => {
732
+ const { isWorktreeClean } = await import("../adapters/git-worktree.js");
733
+ const { reviewerVisualStagingDir } = await import("./reviewer-visual-evidence.js");
734
+ const issueId = await makeIssue();
735
+ const github = fakeGithub();
736
+ const uiSpawn = async (input) => {
737
+ fs.mkdirSync(path.join(input.cwd, "apps", "web", "src"), { recursive: true });
738
+ fs.writeFileSync(path.join(input.cwd, "apps", "web", "src", "app.tsx"), "export const App = () => null;\n");
739
+ git(input.cwd, "add", ".");
740
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "ui change");
741
+ return { exitCode: 0, transcript: "Implementation conclusion: changed the web app.", logPath: "/dev/null", timedOut: false };
742
+ };
743
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: uiSpawn, github }));
744
+ startWorkflow(issueId);
745
+ await pump(1);
746
+ assert.equal(getIssue(issueId).status, "reviewing", "test setup: UI change did not reach reviewing");
747
+ let seenPrompt = "";
748
+ let seenCwd = "";
749
+ let cleanDuringSpawn = null;
750
+ const fetcher = {
751
+ async findRunForHead({ headSha }) {
752
+ return { databaseId: 4242, headSha, conclusion: "success", status: "completed" };
753
+ },
754
+ async downloadArtifact({ name, destDir }) {
755
+ fs.mkdirSync(destDir, { recursive: true });
756
+ fs.writeFileSync(path.join(destDir, "issues-home-1440x900.png"), `png-${name}`);
757
+ fs.writeFileSync(path.join(destDir, "issues-home-390x800.png"), `png-${name}`);
758
+ if (name === "ui-diff")
759
+ fs.writeFileSync(path.join(destDir, "SUMMARY.md"), "## UI diff\n\nhead: `h`\n");
760
+ },
761
+ };
762
+ const capturingSpawn = async (input) => {
763
+ seenPrompt = input.prompt;
764
+ seenCwd = input.cwd;
765
+ cleanDuringSpawn = await isWorktreeClean(input.cwd);
766
+ return verdictSpawn({ verdict: "approved" })(input);
767
+ };
768
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: capturingSpawn, github, visualEvidenceFetcher: fetcher }));
769
+ await pump(1);
770
+ const issue = getIssue(issueId);
771
+ assert.equal(issue.status, "final_review");
772
+ const stagingDir = reviewerVisualStagingDir(seenCwd);
773
+ assert.ok(stagingDir.startsWith(seenCwd + path.sep), "staging dir is inside the reviewer worktree");
774
+ assert.ok(seenPrompt.includes(stagingDir), "reviewer input must name the staged directory");
775
+ assert.ok(seenPrompt.includes(issue.headSha), "reviewer input must cite the head SHA");
776
+ assert.ok(seenPrompt.includes("## UI diff"), "reviewer input must embed the diff summary");
777
+ assert.match(seenPrompt, /1440x900 and 390x800/);
778
+ assert.equal(cleanDuringSpawn, true, "the worktree reports clean with staged evidence present");
779
+ const visual = listArtifactsForIssue(issueId).find((a) => a.kind === "visual_evidence");
780
+ assert.ok(visual, "expected a recorded visual_evidence artifact");
781
+ assert.equal(JSON.parse(visual.contentJson).state, "available");
782
+ assert.equal(fs.existsSync(stagingDir), false, "the staging directory is gone after the attempt");
783
+ assert.equal(fs.existsSync(seenCwd), false, "the reviewer worktree is removed after the attempt");
784
+ });