agent-dealer 1.3.0 → 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.
Files changed (44) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  2. package/bundle/server/dist/adapters/github.js +50 -0
  3. package/bundle/server/dist/adapters/github.test.js +89 -1
  4. package/bundle/server/dist/coordinator/admission.test.js +115 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  6. package/bundle/server/dist/coordinator/commands.js +89 -9
  7. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  8. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  9. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  10. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  11. package/bundle/server/dist/coordinator/prompts.js +57 -2
  12. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  13. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1083 -0
  14. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +780 -0
  15. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  16. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  17. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  18. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  19. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  20. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  21. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  22. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  23. package/bundle/server/dist/coordinator/worker-loop.js +1 -1
  24. package/bundle/server/dist/paths.js +24 -0
  25. package/bundle/server/dist/repository/admission-settings.js +5 -5
  26. package/bundle/server/dist/repository/admission-settings.test.js +71 -15
  27. package/bundle/server/dist/repository/issues.js +8 -1
  28. package/bundle/server/dist/repository/issues.test.js +34 -0
  29. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  30. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  31. package/bundle/server/dist/routes/issues.js +23 -8
  32. package/bundle/server/dist/routes/issues.test.js +80 -0
  33. package/bundle/server/dist/routes/queue.js +1 -1
  34. package/bundle/server/dist/routes/queue.test.js +55 -20
  35. package/bundle/server/dist/runners/process-registry.js +1 -1
  36. package/bundle/server/package.json +2 -2
  37. package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
  38. package/bundle/server/static-ui/index.html +1 -1
  39. package/bundle/shared/dist/queue-entries.d.ts +5 -4
  40. package/bundle/shared/dist/queue-entries.js +5 -4
  41. package/bundle/shared/package.json +1 -1
  42. package/package.json +1 -1
  43. package/templates/prod.env.example +5 -2
  44. package/bundle/server/static-ui/assets/index-Bm74Vq_X.js +0 -64
@@ -12,12 +12,13 @@ const { parseProfileSnapshot } = await import("@agent-dealer/shared");
12
12
  const { randomUUID } = await import("node:crypto");
13
13
  const { createIssue, getIssue, updateIssue } = await import("../repository/issues.js");
14
14
  const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
15
- const { createHumanAction, getHumanAction, listHumanActionsForIssue } = await import("../repository/human-actions.js");
15
+ const { createHumanAction, getHumanAction, listHumanActionsForIssue, resolveHumanAction } = await import("../repository/human-actions.js");
16
16
  const { setResumeLiveHeadReaderForTests } = await import("./resume-live-head.js");
17
17
  const { listFindingsForIssue, reconcileFinding } = await import("../repository/findings.js");
18
18
  const { cancelWorkItem, claimWorkItem, listWorkItemsForIssue, getWorkItem, enqueueWorkItem } = await import("../repository/work-items.js");
19
19
  const { createWorkerSession, completeSession, startSession, listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
20
- const { startWorkflow, applyCompletion, applyParkedEdit, capWaitForIssue, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, canEditParkedIssue, parkCapWait, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
20
+ const { startWorkflow, applyCompletion, applyParkedEdit, capWaitForIssue, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, getTaskSnapshot, diffTaskSnapshotFromLive, canEditParkedIssue, parkCapWait, TASK_SNAPSHOT_ARTIFACT_KIND, checkIssueReadiness, abortIssue } = await import("./commands.js");
21
+ const { replaceSourceAttachments, listSourceAttachments } = await import("../repository/source-attachments.js");
21
22
  const { ReviewerResult } = await import("./reviewer-result.js");
22
23
  const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
23
24
  const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
@@ -397,6 +398,231 @@ test("NOT-185: closing an edited attempts_exhausted issue does not re-freeze the
397
398
  assert.equal(resolved.ok, true);
398
399
  assert.equal(snapshotArtifacts(issueId).length, 1);
399
400
  });
401
+ /** NOT-422: park at an active-workflow `product_scope_decision` the way a reviewer's escalated verdict does. */
402
+ async function parkAtProductScope() {
403
+ const issueId = newIssue();
404
+ startWorkflow(issueId);
405
+ await complete(issueId, cleanHandoff);
406
+ await complete(issueId, {
407
+ kind: "verdict",
408
+ result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
409
+ });
410
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
411
+ return { issueId, actionId: action.id };
412
+ }
413
+ test("NOT-422: canEditParkedIssue is true at an open product_scope_decision with nothing pending or leased", async () => {
414
+ const { issueId } = await parkAtProductScope();
415
+ assert.equal(canEditParkedIssue(getIssue(issueId)), true);
416
+ const instance = getActiveWorkflowInstance(issueId);
417
+ const item = enqueueWorkItem({
418
+ issueId,
419
+ workflowInstanceId: instance.id,
420
+ kind: "developer",
421
+ round: 1,
422
+ payload: {},
423
+ idempotencyKey: `${instance.id}:test:n422-pending`,
424
+ });
425
+ assert.equal(canEditParkedIssue(getIssue(issueId)), false, "a pending work item blocks the edit");
426
+ const leased = claim(issueId);
427
+ assert.equal(leased.id, item.id);
428
+ assert.equal(canEditParkedIssue(getIssue(issueId)), false, "a leased work item blocks the edit");
429
+ cancelWorkItem(item.id);
430
+ assert.equal(canEditParkedIssue(getIssue(issueId)), true);
431
+ });
432
+ test("NOT-422: other open action types keep the parked-edit surface closed", async () => {
433
+ const { issueId, actionId } = await parkAtProductScope();
434
+ // Close the scope decision without advancing (repository-level): needs_human +
435
+ // active workflow + no open action is not contract-editable.
436
+ resolveHumanAction(actionId, "test", { choice: "resume" });
437
+ assert.equal(getIssue(issueId).status, "needs_human");
438
+ assert.equal(canEditParkedIssue(getIssue(issueId)), false, "no open action");
439
+ // And a non-contract-editable open action does not open it either.
440
+ createHumanAction({
441
+ issueId,
442
+ actionType: "final_review",
443
+ reason: "r",
444
+ question: "q",
445
+ responseOptions: [{ choice: "merge", label: "Merge" }],
446
+ });
447
+ assert.equal(canEditParkedIssue(getIssue(issueId)), false, "final_review is not contract-editable");
448
+ // applyParkedEdit fails closed the same way, with no row or event change.
449
+ const before = getIssue(issueId);
450
+ const eventsBefore = listWorkflowEventsForIssue(issueId).length;
451
+ const refused = applyParkedEdit(issueId, { title: "Nope" });
452
+ assert.equal(refused.ok, false);
453
+ if (refused.ok !== false)
454
+ return;
455
+ assert.equal(refused.code, 409);
456
+ assert.deepEqual(getIssue(issueId), before);
457
+ assert.equal(listWorkflowEventsForIssue(issueId).length, eventsBefore);
458
+ });
459
+ test("NOT-422: applyParkedEdit at a scope pause accepts task text but rejects swaps, locked fields, pending items and running sessions", async () => {
460
+ const { issueId, actionId } = await parkAtProductScope();
461
+ const before = getIssue(issueId);
462
+ const edited = applyParkedEdit(issueId, {
463
+ title: "Rescoped title",
464
+ description: "d2",
465
+ acceptanceCriteria: "It works, and also does Y",
466
+ });
467
+ assert.equal(edited.ok, true);
468
+ if (edited.ok !== true)
469
+ return;
470
+ assert.equal(edited.issue.title, "Rescoped title");
471
+ assert.equal(edited.issue.description, "d2");
472
+ assert.equal(edited.issue.acceptanceCriteria, "It works, and also does Y");
473
+ // Everything else stays as the running workflow saw it, the scope action
474
+ // stays open, and the snapshot still carries the old text until Resume.
475
+ assert.equal(edited.issue.repo, before.repo);
476
+ assert.equal(edited.issue.baseBranch, before.baseBranch);
477
+ assert.equal(edited.issue.developerAgentId, before.developerAgentId);
478
+ assert.equal(edited.issue.reviewerAgentId, before.reviewerAgentId);
479
+ assert.equal(edited.issue.branch, before.branch);
480
+ assert.equal(edited.issue.headSha, before.headSha);
481
+ assert.equal(getHumanAction(actionId).status, "open");
482
+ assert.equal(snapshotArtifacts(issueId).length, 1);
483
+ assert.equal(getTaskSnapshot(getIssue(issueId)).description, "d");
484
+ // An agent swap at a scope pause is a 409 — the task-text allowlist names no roles.
485
+ const swap = applyParkedEdit(issueId, { developerAgentId: BUILTIN_AGENT_CURSOR_ID });
486
+ assert.equal(swap.ok, false);
487
+ if (swap.ok !== false)
488
+ return;
489
+ assert.equal(swap.code, 409);
490
+ assert.match(swap.error, /Only title, description and acceptanceCriteria can be edited while parked/);
491
+ assert.equal(getIssue(issueId).developerAgentId, before.developerAgentId);
492
+ for (const patch of [{ repo: "acme/other" }, { maxReviewRounds: 5 }, { autoMerge: true }]) {
493
+ const blocked = applyParkedEdit(issueId, patch);
494
+ assert.equal(blocked.ok, false, JSON.stringify(patch));
495
+ if (blocked.ok !== false)
496
+ return;
497
+ assert.equal(blocked.code, 409);
498
+ }
499
+ // A pending work item blocks the edit; cancelling it re-opens the surface.
500
+ const instance = getActiveWorkflowInstance(issueId);
501
+ const item = enqueueWorkItem({
502
+ issueId,
503
+ workflowInstanceId: instance.id,
504
+ kind: "developer",
505
+ round: 1,
506
+ payload: {},
507
+ idempotencyKey: `${instance.id}:test:n422-pending-2`,
508
+ });
509
+ const queued = applyParkedEdit(issueId, { title: "Racing edit" });
510
+ assert.equal(queued.ok, false);
511
+ if (queued.ok !== false)
512
+ return;
513
+ assert.equal(queued.code, 409);
514
+ cancelWorkItem(item.id);
515
+ assert.equal(applyParkedEdit(issueId, { title: "After cancel" }).ok, true);
516
+ // An actively running session blocks the edit even with nothing pending/leased.
517
+ const session = createWorkerSession({
518
+ issueId,
519
+ role: "developer",
520
+ round: 1,
521
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
522
+ runtime: "claude_code",
523
+ });
524
+ startSession(session.id);
525
+ const running = applyParkedEdit(issueId, { title: "Racing session" });
526
+ assert.equal(running.ok, false);
527
+ if (running.ok !== false)
528
+ return;
529
+ assert.equal(running.code, 409);
530
+ assert.match(running.error, /actively running session/);
531
+ completeSession(session.id, { status: "done" });
532
+ const afterSession = applyParkedEdit(issueId, { title: "After session" });
533
+ assert.equal(afterSession.ok, true);
534
+ });
535
+ test("NOT-422: product-scope resume after an edit freezes one new snapshot before repair.started, note still riding", async () => {
536
+ const { issueId, actionId } = await parkAtProductScope();
537
+ assert.equal(snapshotArtifacts(issueId).length, 1);
538
+ updateIssue(issueId, { description: "d2", acceptanceCriteria: "It works, and also does Y" });
539
+ const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "resume", { note: "Scope says Y" });
540
+ assert.equal(resolved.ok, true);
541
+ assert.equal(snapshotArtifacts(issueId).length, 2);
542
+ const snap = getTaskSnapshot(getIssue(issueId));
543
+ assert.equal(snap.description, "d2");
544
+ assert.equal(snap.acceptanceCriteria, "It works, and also does Y");
545
+ assert.equal(snap.title, "Coordinate me");
546
+ const events = listWorkflowEventsForIssue(issueId);
547
+ const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
548
+ assert.equal(refreshed.length, 1);
549
+ assert.deepEqual(JSON.parse(refreshed[0].payloadJson).changedFields, ["description", "acceptanceCriteria"]);
550
+ assert.equal(JSON.parse(refreshed[0].payloadJson).actionId, actionId);
551
+ assert.equal(refreshed[0].actorType, "human");
552
+ const types = events.map((e) => e.type);
553
+ assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
554
+ const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
555
+ assert.deepEqual(pending.map((i) => [i.kind, i.round]), [["developer", 1]]);
556
+ // The one-shot scope-decision note still accompanies that same work item.
557
+ assert.equal(JSON.parse(pending[0].payloadJson).scopeDecisionNote, "Scope says Y");
558
+ });
559
+ test("NOT-422: product-scope resume with no edit creates no new snapshot artifact or refresh event", async () => {
560
+ const { issueId, actionId } = await parkAtProductScope();
561
+ const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "resume");
562
+ assert.equal(resolved.ok, true);
563
+ assert.equal(snapshotArtifacts(issueId).length, 1);
564
+ assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "task_snapshot.refreshed").length, 0);
565
+ });
566
+ test("NOT-422: attachment-only row change re-freezes the manifest on product-scope resume", async () => {
567
+ const { issueId, actionId } = await parkAtProductScope();
568
+ assert.equal(snapshotArtifacts(issueId).length, 1);
569
+ // The post-reload row state with the text untouched: a new file manifest.
570
+ replaceSourceAttachments(issueId, [
571
+ {
572
+ linearAttachmentId: "att-file-1",
573
+ kind: "file",
574
+ title: "repro.tar.gz",
575
+ safeFileName: "repro.tar.gz",
576
+ blobPath: "/blobs/repro.tar.gz",
577
+ contentType: "application/gzip",
578
+ sizeBytes: 12,
579
+ sha256: "v2-sha",
580
+ url: "https://uploads.linear.app/abc/repro.tar.gz",
581
+ },
582
+ ]);
583
+ assert.deepEqual(diffTaskSnapshotFromLive(getIssue(issueId)), ["sourceAttachments"]);
584
+ const resolved = resolveHumanActionAndAdvance(actionId, "yusuke", "resume");
585
+ assert.equal(resolved.ok, true);
586
+ assert.equal(snapshotArtifacts(issueId).length, 2);
587
+ assert.deepEqual(getTaskSnapshot(getIssue(issueId)).sourceAttachments, listSourceAttachments(issueId));
588
+ const events = listWorkflowEventsForIssue(issueId);
589
+ const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
590
+ assert.equal(refreshed.length, 1);
591
+ assert.deepEqual(JSON.parse(refreshed[0].payloadJson).changedFields, ["sourceAttachments"]);
592
+ const types = events.map((e) => e.type);
593
+ assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
594
+ });
595
+ test("NOT-422: product-scope resume with an unchanged attachment manifest creates no refresh", async () => {
596
+ const issueId = newIssue();
597
+ replaceSourceAttachments(issueId, [
598
+ {
599
+ linearAttachmentId: "att-file-1",
600
+ kind: "file",
601
+ title: "repro.tar.gz",
602
+ safeFileName: "repro.tar.gz",
603
+ blobPath: "/blobs/repro.tar.gz",
604
+ contentType: "application/gzip",
605
+ sizeBytes: 12,
606
+ sha256: "v1-sha",
607
+ url: "https://uploads.linear.app/abc/repro.tar.gz",
608
+ },
609
+ { linearAttachmentId: "att-link-1", kind: "link", title: "Design doc", url: "https://docs.example.com/spec" },
610
+ ]);
611
+ startWorkflow(issueId);
612
+ await complete(issueId, cleanHandoff);
613
+ await complete(issueId, {
614
+ kind: "verdict",
615
+ result: { ...okReview("escalated"), productScopeQuestion: "Still needed?" },
616
+ });
617
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
618
+ // Rows frozen at start, untouched since — the diff must be empty, not a
619
+ // false-positive manifest change from the row round-trip.
620
+ assert.deepEqual(diffTaskSnapshotFromLive(getIssue(issueId)), []);
621
+ const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
622
+ assert.equal(resolved.ok, true);
623
+ assert.equal(snapshotArtifacts(issueId).length, 1);
624
+ assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "task_snapshot.refreshed").length, 0);
625
+ });
400
626
  test("resolving policy_escalation:resume resets the infra-attempt budget without spending a review round", async () => {
401
627
  const issueId = newIssue({ maxInfraAttempts: 1 });
402
628
  startWorkflow(issueId);
@@ -22,6 +22,8 @@ import { getTaskSnapshot } from "./commands.js";
22
22
  import { materializeSourceAttachments, SourceAttachmentError, } from "./source-attachments.js";
23
23
  import { extractOperatorCriteria } from "./operator-criteria.js";
24
24
  import { buildDeveloperPrompt } from "./prompts.js";
25
+ import { VISUAL_QA_EXCLUDE_LINE, collectDeveloperVisualQa } from "./visual-qa.js";
26
+ import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
25
27
  import { guidanceForNextSession } from "./guidance.js";
26
28
  import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
27
29
  import { ensureWorktreeDeps, worktreeDepsTimeoutMs, } from "../adapters/worktree-deps.js";
@@ -1093,6 +1095,16 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1093
1095
  : `source attachment materialization failed: ${String(err)}`;
1094
1096
  return { kind: "adapter_failure", reason };
1095
1097
  }
1098
+ // NOT-381: git-ignore the transient visual-QA directory before the session
1099
+ // spawns, so worker screenshots neither dirty the tree (which would break the
1100
+ // verification receipt's clean gate and the handoff's clean check) nor get
1101
+ // swept into a salvage commit — screenshots must never reach the product repo.
1102
+ try {
1103
+ ensureWorktreeExcluded(worktreePath, VISUAL_QA_EXCLUDE_LINE);
1104
+ }
1105
+ catch (err) {
1106
+ return { kind: "adapter_failure", reason: `visual-QA worktree exclude failed: ${String(err)}` };
1107
+ }
1096
1108
  // NOT-172: capture the worktree HEAD as this session's input-SHA baseline before
1097
1109
  // the agent spawns — developer work items are never enqueued with an input SHA,
1098
1110
  // so without this the sampler's HEAD diff (and the session_end fallback) have
@@ -1255,6 +1267,9 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1255
1267
  worktreePath,
1256
1268
  deckId: snapshot?.deckId ?? null,
1257
1269
  museDeveloper: isMuse,
1270
+ // NOT-381: runtime-aware visual-QA guidance (Muse preflight vs the
1271
+ // capable-runtime in-session attempt).
1272
+ runtime,
1258
1273
  guidance: guidance.length ? guidance : undefined,
1259
1274
  scopeDecisionNote,
1260
1275
  conflictRepair,
@@ -1947,6 +1962,27 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1947
1962
  publishable: { branch: branchName },
1948
1963
  };
1949
1964
  }
1965
+ // NOT-381: collect the structured Visual QA receipt against the just-pushed
1966
+ // head, before the checks wait. Accepted screenshots move to Dealer-owned
1967
+ // storage and the transient worktree directory is removed; a rejection is
1968
+ // persisted loudly but never blocks the handoff — visual QA is evidence
1969
+ // for review, not a gate, so a bad receipt must not burn another round.
1970
+ try {
1971
+ const visual = collectDeveloperVisualQa({
1972
+ issueId: issue.id,
1973
+ sessionId,
1974
+ worktreePath,
1975
+ conclusion: extractConclusion(spawned.transcript),
1976
+ expectedHeadSha: localHead,
1977
+ });
1978
+ if (visual.kind === "rejected") {
1979
+ console.warn(`[dealer] visual QA receipt rejected for ${issue.id}: ${visual.reason}`);
1980
+ }
1981
+ }
1982
+ catch (err) {
1983
+ // Evidence must never fail the handoff it describes.
1984
+ console.warn(`[dealer] visual QA collection failed for ${issue.id}: ${String(err)}`);
1985
+ }
1950
1986
  milestone("checks.started", `Developer · waiting on checks (round ${round})`, {
1951
1987
  prNumber: prView.number,
1952
1988
  headSha: prView.headRefOid,
@@ -13,7 +13,8 @@ const { createIssue, getIssue, updateIssue, transitionIssue } = await import("..
13
13
  const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
14
14
  const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
15
15
  const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
16
- const { claimWorkItem, getWorkItem } = await import("../repository/work-items.js");
16
+ const { claimWorkItem, getWorkItem, listWorkItemsForIssue } = await import("../repository/work-items.js");
17
+ const { createIssueArtifact } = await import("../repository/artifacts.js");
17
18
  const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, responseOptionsFor, } = await import("./commands.js");
18
19
  const { ReviewerResult } = await import("./reviewer-result.js");
19
20
  const { setMergePrForTests, clearFinalizeInflightForTests, finalizeAutoMerge, gateOperatorVerification, OPERATOR_VERIFICATION_INTENT, } = await import("./auto-merge.js");
@@ -345,3 +346,103 @@ test("responseOptionsFor(operator_verification) matches the gate's stored option
345
346
  { choice: "repair", label: "Another repair round" },
346
347
  ]);
347
348
  });
349
+ // NOT-384: a UI-affecting head whose recorded CI visual evidence is `missing`
350
+ // holds at the same gate — one operator_verification action naming the head
351
+ // SHA and the missing artifacts, no merge, and crucially no repair round.
352
+ function recordVisualEvidence(issueId, opts) {
353
+ createIssueArtifact({
354
+ issueId,
355
+ kind: "visual_evidence",
356
+ content: {
357
+ headSha: opts.headSha,
358
+ baseSha: "base1",
359
+ uiAffecting: true,
360
+ state: opts.state,
361
+ runId: opts.state === "missing" ? null : 42,
362
+ artifactNames: opts.state === "available" ? ["ui-screenshots", "ui-baseline", "ui-diff"] : [],
363
+ reason: opts.state === "available"
364
+ ? null
365
+ : opts.state === "failed"
366
+ ? "Visual run 42 for this head concluded failure — its captures are unusable"
367
+ : "no Visual workflow run found for head abc123",
368
+ },
369
+ author: "system",
370
+ });
371
+ }
372
+ function assertNoRepairWorkScheduled(issueId) {
373
+ const pending = listWorkItemsForIssue(issueId).filter((item) => item.status === "pending" || item.status === "leased");
374
+ assert.deepEqual(pending.map((item) => item.kind), [], "missing visual evidence must never schedule a repair round");
375
+ }
376
+ for (const state of ["missing", "failed"]) {
377
+ test(`NOT-384: ${state} visual evidence on a UI head raises operator_verification and never a repair round`, async () => {
378
+ let merges = 0;
379
+ setMergePrForTests(async () => {
380
+ merges += 1;
381
+ return { ok: true };
382
+ });
383
+ const issueId = newIssue("It works");
384
+ startWorkflow(issueId);
385
+ recordVisualEvidence(issueId, { headSha: "abc123", state });
386
+ const result = await approve(issueId);
387
+ assert.equal(result.applied, true);
388
+ if (result.applied) {
389
+ assert.equal(result.issueStatus, "needs_human");
390
+ assert.equal(result.nextWorkItemId, null, "the gate queues no repair round");
391
+ assert.ok(result.humanActionId);
392
+ }
393
+ assert.equal(merges, 0, "the merge adapter must not run behind the gate");
394
+ assertNoRepairWorkScheduled(issueId);
395
+ const action = openOperatorAction(issueId);
396
+ assert.match(action.question, /abc123/, "the action names the head SHA");
397
+ assert.match(action.question, /ui-screenshots/, "the action names the missing artifacts");
398
+ assert.match(action.question, new RegExp(state), "the action names the evidence state");
399
+ const evidence = JSON.parse(action.evidenceJson);
400
+ assert.equal(evidence.operatorVerification.headSha, "abc123");
401
+ assert.equal(evidence.operatorVerification.visualEvidence.state, state);
402
+ assert.ok(evidence.operatorVerification.visualEvidence.missingArtifacts.length > 0);
403
+ const options = JSON.parse(action.responseOptionsJson);
404
+ assert.deepEqual(options.map((o) => o.choice), ["verified", "waive", "repair"]);
405
+ });
406
+ }
407
+ test("NOT-384: available visual evidence on a UI head merges normally", async () => {
408
+ const calls = [];
409
+ setMergePrForTests(async (opts) => {
410
+ calls.push(opts);
411
+ return { ok: true };
412
+ });
413
+ const issueId = newIssue("It works");
414
+ startWorkflow(issueId);
415
+ recordVisualEvidence(issueId, { headSha: "abc123", state: "available" });
416
+ const result = await approve(issueId);
417
+ assert.equal(result.applied, true);
418
+ if (result.applied)
419
+ assert.equal(result.issueStatus, "done");
420
+ assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
421
+ assert.equal(getIssue(issueId).status, "done");
422
+ });
423
+ test("NOT-384: [operator] criteria plus a visual hold raise one action naming both", async () => {
424
+ let merges = 0;
425
+ setMergePrForTests(async () => {
426
+ merges += 1;
427
+ return { ok: true };
428
+ });
429
+ const issueId = newIssue();
430
+ startWorkflow(issueId);
431
+ recordVisualEvidence(issueId, { headSha: "abc123", state: "missing" });
432
+ await approve(issueId);
433
+ assert.equal(merges, 0);
434
+ const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
435
+ assert.equal(open.length, 1, "criteria and visual hold share one action for the head");
436
+ assert.equal(open[0].actionType, "operator_verification");
437
+ assert.match(open[0].question, /sign in with SSO/);
438
+ assert.match(open[0].question, /ui-screenshots/);
439
+ const evidence = JSON.parse(open[0].evidenceJson);
440
+ assert.equal(evidence.operatorVerification.criteria.length, 1);
441
+ assert.equal(evidence.operatorVerification.visualEvidence.state, "missing");
442
+ // One recorded result releases the combined action — a second finalize for
443
+ // the same head reuses it instead of raising another.
444
+ const again = await finalizeAutoMerge(issueId);
445
+ assert.equal(again.humanActionId, open[0].id);
446
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1);
447
+ assert.equal(merges, 0);
448
+ });