agent-dealer 1.3.1 → 1.3.2
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.
- package/bundle/server/dist/adapters/git-worktree.js +38 -1
- package/bundle/server/dist/adapters/github.js +50 -0
- package/bundle/server/dist/adapters/github.test.js +89 -1
- package/bundle/server/dist/coordinator/auto-merge.js +46 -9
- package/bundle/server/dist/coordinator/commands.js +89 -9
- package/bundle/server/dist/coordinator/commands.test.js +228 -2
- package/bundle/server/dist/coordinator/developer-effect.js +36 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
- package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
- package/bundle/server/dist/coordinator/prompts.js +57 -2
- package/bundle/server/dist/coordinator/prompts.test.js +236 -2
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
- package/bundle/server/dist/coordinator/source-reload.js +44 -9
- package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
- package/bundle/server/dist/coordinator/visual-qa.js +630 -0
- package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/issues.js +8 -1
- package/bundle/server/dist/repository/issues.test.js +34 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
- package/bundle/server/dist/routes/issues.js +23 -8
- package/bundle/server/dist/routes/issues.test.js +80 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
/**
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
+
});
|