agent-dealer 1.2.8 → 1.2.10

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 (60) hide show
  1. package/bundle/server/dist/adapters/github.js +93 -4
  2. package/bundle/server/dist/adapters/github.test.js +96 -1
  3. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  4. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +205 -1
  6. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  7. package/bundle/server/dist/coordinator/commands.js +341 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +237 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +460 -3
  11. package/bundle/server/dist/coordinator/human-resolution.js +19 -0
  12. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  13. package/bundle/server/dist/coordinator/merge-conflict-sync.js +100 -12
  14. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -0
  15. package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
  16. package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
  17. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  18. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  19. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  20. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  21. package/bundle/server/dist/coordinator/projection.js +10 -4
  22. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  23. package/bundle/server/dist/coordinator/prompts.js +134 -1
  24. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  25. package/bundle/server/dist/coordinator/recovery.js +57 -3
  26. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  27. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  28. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  29. package/bundle/server/dist/coordinator/routing.js +55 -6
  30. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  31. package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
  32. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  33. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  34. package/bundle/server/dist/db/index.js +13 -0
  35. package/bundle/server/dist/db/schema.sql +2 -0
  36. package/bundle/server/dist/repository/issues.js +39 -3
  37. package/bundle/server/dist/repository/work-items.js +7 -0
  38. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  39. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  40. package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
  41. package/bundle/server/dist/runners/muse-code.test.js +13 -1
  42. package/bundle/server/dist/runners/spawn-cli.js +93 -20
  43. package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
  44. package/bundle/server/package.json +2 -2
  45. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  46. package/bundle/server/static-ui/index.html +1 -1
  47. package/bundle/shared/dist/human-actions.d.ts +4 -4
  48. package/bundle/shared/dist/human-actions.js +8 -0
  49. package/bundle/shared/dist/issues.d.ts +30 -0
  50. package/bundle/shared/dist/issues.js +14 -0
  51. package/bundle/shared/dist/issues.test.js +2 -0
  52. package/bundle/shared/dist/workflow.d.ts +4 -4
  53. package/bundle/shared/dist/workflow.js +6 -0
  54. package/bundle/shared/dist/workflow.test.js +1 -0
  55. package/bundle/shared/package.json +1 -1
  56. package/dist/action.js +4 -1
  57. package/dist/action.test.js +42 -0
  58. package/dist/index.js +1 -1
  59. package/package.json +1 -1
  60. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -151,12 +151,25 @@ function fakeGithub(opts = {}) {
151
151
  if (number == null && !branch) {
152
152
  throw new Error("fakeGithub.checksSnapshot requires an explicit number or branch — bare checks lookup is the NOT-82 bug");
153
153
  }
154
- return opts.checks ?? "success";
154
+ // NOT-311: a function lets one test flip CI mid-wait (pending → success) so the
155
+ // deferred re-poll can prove the wait closes without a fresh agent session.
156
+ return typeof opts.checks === "function" ? opts.checks() : (opts.checks ?? "success");
157
+ },
158
+ async hasPullRequestWorkflow() {
159
+ return opts.pullRequestWorkflow ?? null;
155
160
  },
156
161
  async publishReview() {
157
162
  throw new Error("publishReview is unused by the developer effect");
158
163
  }
159
164
  };
165
+ if (opts.mergeable !== undefined) {
166
+ const mergeable = opts.mergeable;
167
+ adapter.prMergeableState = async ({ number }) => {
168
+ if (number == null)
169
+ throw new Error("fakeGithub.prMergeableState requires an explicit PR number");
170
+ return typeof mergeable === "function" ? mergeable() : mergeable;
171
+ };
172
+ }
160
173
  return adapter;
161
174
  }
162
175
  test("clean handoff: real worktree, real push, fake GitHub — issue moves to reviewing with a reviewer work item queued", async () => {
@@ -530,6 +543,8 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
530
543
  await pump(1);
531
544
  const issue = getIssue(issueId);
532
545
  assert.equal(issue.status, "developing");
546
+ assert.equal(issue.ciAttempts, 1, "a checks_failed retry spends the CI budget");
547
+ assert.equal(issue.infraAttempts, 0, "a checks_failed retry never touches the infra budget");
533
548
  assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
534
549
  const kinds = listArtifactsForIssue(issueId).map((a) => a.kind);
535
550
  const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
@@ -540,12 +555,323 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
540
555
  assert.ok(kinds.includes("implementation_conclusion"), "conclusion must survive a post-session verification failure");
541
556
  assert.ok(kinds.includes("developer_transcript"), "raw trace must survive a post-session verification failure");
542
557
  });
543
- test("timed_out (checks poll): checks stay pending past the poll deadline", async () => {
558
+ test("NOT-311: checks_pending — checks still pending at the poll deadline defers, spending no attempt", async () => {
559
+ // Previously this returned `timed_out`, spending an infra attempt on a session that
560
+ // had already ended cleanly. Now the item waits on backoff and re-polls.
544
561
  const issueId = await makeIssue();
545
562
  registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
546
563
  startWorkflow(issueId);
547
564
  await pump(1);
565
+ const issue = getIssue(issueId);
566
+ assert.equal(issue.status, "developing");
567
+ assert.equal(issue.infraAttempts, 0, "a CI wait spends no infra attempt");
568
+ assert.equal(issue.currentRound, 1, "a CI wait must not spend a review round");
569
+ assert.match(issue.currentIntent ?? "", /Waiting for CI \(running\) - no attempt spent/);
570
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on unverified CI");
571
+ const items = listWorkItemsForIssue(issueId);
572
+ assert.equal(items.length, 1);
573
+ assert.equal(items[0].status, "pending", "the item waits, it is not finished or dead-lettered");
574
+ assert.equal(items[0].attemptCount, 0, "the claim-time attempt bump is reverted");
575
+ assert.ok(Date.parse(items[0].availableAt) > Date.parse(items[0].updatedAt), "the re-poll is gated behind a backoff");
576
+ const deferredPayload = JSON.parse(items[0].payloadJson ?? "{}");
577
+ assert.equal(deferredPayload.publishOnly, true, "the re-poll takes the no-agent publish path, not a fresh spawn");
578
+ assert.equal(deferredPayload.branch, issueBranchName(issueId));
579
+ assert.ok(deferredPayload.checksWaitStartedAt, "the wait anchors its ceiling at the first poll");
580
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
581
+ const events = listWorkflowEventsForIssue(issueId);
582
+ const deferrals = events.filter((e) => e.type === "worker.deferred");
583
+ assert.ok(deferrals.length > 0);
584
+ assert.equal(JSON.parse(deferrals[0].payloadJson ?? "{}").outcome, "checks_pending");
585
+ assert.equal(events.some((e) => e.type === "worker.failed"), false, "a CI wait is not a failure");
586
+ const completed = events.find((e) => e.type === "checks.completed");
587
+ assert.ok(completed, "the wait still records a checks.completed milestone");
588
+ assert.equal(JSON.parse(completed.payloadJson ?? "{}").snapshot, "timeout");
589
+ });
590
+ test("NOT-311: the deferred CI wait re-polls through publish-only and hands off once CI passes", async () => {
591
+ // First run waits (pending), the deferred re-poll sees success and reaches review —
592
+ // all without spawning a second agent session and without spending infra attempts.
593
+ let ci = "pending";
594
+ let spawns = 0;
595
+ const countingSpawn = async (input) => {
596
+ spawns++;
597
+ return commitingSpawn(input);
598
+ };
599
+ // A short backoff (set before the first deferral computes its `until`) lets the
600
+ // second pump actually pick the waiting item back up.
601
+ const prevBase = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
602
+ const prevMax = process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
603
+ process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "10";
604
+ process.env.DECK_OUTAGE_BACKOFF_MAX_MS = "10";
605
+ try {
606
+ const issueId = await makeIssue();
607
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: countingSpawn, github: fakeGithub({ checks: () => ci }) }));
608
+ startWorkflow(issueId);
609
+ await pump(1);
610
+ assert.equal(getIssue(issueId).status, "developing");
611
+ assert.equal(getIssue(issueId).infraAttempts, 0);
612
+ const deferred = listWorkItemsForIssue(issueId)[0];
613
+ assert.equal(deferred.status, "pending");
614
+ // Fast-forward past the deferral backoff, then let CI pass.
615
+ await new Promise((resolve) => setTimeout(resolve, 60));
616
+ ci = "success";
617
+ await pump(1);
618
+ const issue = getIssue(issueId);
619
+ assert.equal(issue.status, "reviewing", "the re-poll hands off once CI passes");
620
+ assert.equal(issue.infraAttempts, 0, "waiting plus a passing re-poll still spends nothing");
621
+ assert.equal(spawns, 1, "the re-poll must not spawn a second agent session");
622
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
623
+ }
624
+ finally {
625
+ if (prevBase === undefined)
626
+ delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
627
+ else
628
+ process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBase;
629
+ if (prevMax === undefined)
630
+ delete process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
631
+ else
632
+ process.env.DECK_OUTAGE_BACKOFF_MAX_MS = prevMax;
633
+ }
634
+ });
635
+ test("NOT-311: none with a pull_request workflow on the base waits (no review handoff)", async () => {
636
+ const issueId = await makeIssue();
637
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: true }) }));
638
+ startWorkflow(issueId);
639
+ await pump(1);
640
+ const issue = getIssue(issueId);
641
+ assert.equal(issue.status, "developing");
642
+ assert.equal(issue.infraAttempts, 0, "an unregistered-CI wait spends no infra attempt");
643
+ assert.match(issue.currentIntent ?? "", /Waiting for CI \(queued\) - no attempt spent/);
644
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on 'no checks yet'");
645
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
646
+ assert.ok(completed);
647
+ const payload = JSON.parse(completed.payloadJson ?? "{}");
648
+ assert.equal(payload.snapshot, "none");
649
+ assert.equal(payload.reason, "none_with_workflow");
650
+ });
651
+ test("NOT-311: none with no pull_request workflow on the base proceeds as today", async () => {
652
+ const issueId = await makeIssue();
653
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: false }) }));
654
+ startWorkflow(issueId);
655
+ await pump(1);
656
+ const issue = getIssue(issueId);
657
+ assert.equal(issue.status, "reviewing", "no configured CI still hands off");
658
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
659
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
660
+ assert.ok(completed);
661
+ const payload = JSON.parse(completed.payloadJson ?? "{}");
662
+ assert.equal(payload.snapshot, "none");
663
+ assert.equal(payload.reason, "none_no_workflow");
664
+ });
665
+ test("NOT-311: none with an unknown workflow answer waits fail-closed", async () => {
666
+ // The adapter cannot read the base (or has no such method): 'no checks yet' must
667
+ // never read as a pass.
668
+ const issueId = await makeIssue();
669
+ const github = fakeGithub({ checks: "none", pullRequestWorkflow: null });
670
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
671
+ startWorkflow(issueId);
672
+ await pump(1);
548
673
  assert.equal(getIssue(issueId).status, "developing");
674
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
675
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
676
+ assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
677
+ });
678
+ /** NOT-354: records every base-sync invocation and answers from `respond`. */
679
+ function recordingConflictSync(respond) {
680
+ const calls = [];
681
+ return {
682
+ calls,
683
+ sync: async (call) => {
684
+ calls.push(call);
685
+ return respond(call, calls.length);
686
+ },
687
+ };
688
+ }
689
+ async function withShortDeferralBackoff(fn) {
690
+ const prevBase = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
691
+ const prevMax = process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
692
+ process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "10";
693
+ process.env.DECK_OUTAGE_BACKOFF_MAX_MS = "10";
694
+ try {
695
+ await fn();
696
+ }
697
+ finally {
698
+ if (prevBase === undefined)
699
+ delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
700
+ else
701
+ process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBase;
702
+ if (prevMax === undefined)
703
+ delete process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
704
+ else
705
+ process.env.DECK_OUTAGE_BACKOFF_MAX_MS = prevMax;
706
+ }
707
+ }
708
+ for (const state of ["UNKNOWN", "MERGEABLE"]) {
709
+ test(`NOT-354: none + pull_request workflow + ${state} PR keeps the queued CI wait, no base sync`, async () => {
710
+ const issueId = await makeIssue();
711
+ const recorder = recordingConflictSync(async () => {
712
+ throw new Error("a non-conflicting PR must never run the base sync");
713
+ });
714
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
715
+ deckCallTool: okDeckCallTool,
716
+ spawn: commitingSpawn,
717
+ github: fakeGithub({ checks: "none", pullRequestWorkflow: true, mergeable: state }),
718
+ conflictSync: recorder.sync,
719
+ }));
720
+ startWorkflow(issueId);
721
+ await pump(1);
722
+ assert.equal(recorder.calls.length, 0);
723
+ const issue = getIssue(issueId);
724
+ assert.equal(issue.status, "developing");
725
+ assert.match(issue.currentIntent ?? "", /Waiting for CI \(queued\) - no attempt spent/);
726
+ const deferred = listWorkflowEventsForIssue(issueId).filter((e) => e.type === "worker.deferred");
727
+ assert.equal(deferred.length, 1);
728
+ assert.equal(JSON.parse(deferred[0].payloadJson ?? "{}").outcome, "checks_pending");
729
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
730
+ assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
731
+ });
732
+ }
733
+ test("NOT-354: none + pull_request workflow + CONFLICTING runs the base sync instead of a queued wait; a clean sync re-polls and hands off", async () => {
734
+ await withShortDeferralBackoff(async () => {
735
+ let mergeable = "CONFLICTING";
736
+ let ci = "none";
737
+ let spawns = 0;
738
+ const countingSpawn = async (input) => {
739
+ spawns++;
740
+ return commitingSpawn(input);
741
+ };
742
+ const issueId = await makeIssue();
743
+ const branch = issueBranchName(issueId);
744
+ const recorder = recordingConflictSync(async () => ({ outcome: "synced", headSha: git(remote, "rev-parse", branch) }));
745
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
746
+ deckCallTool: okDeckCallTool,
747
+ spawn: countingSpawn,
748
+ github: fakeGithub({ checks: () => ci, pullRequestWorkflow: true, mergeable: () => mergeable }),
749
+ conflictSync: recorder.sync,
750
+ }));
751
+ startWorkflow(issueId);
752
+ await pump(1);
753
+ // The coordinator invoked the NOT-310 sync through its checks-wait entry.
754
+ assert.equal(recorder.calls.length, 1);
755
+ const call = recorder.calls[0];
756
+ assert.equal(call.entry, "checks_wait");
757
+ assert.equal(call.branch, branch);
758
+ assert.equal(call.baseBranch, "main");
759
+ assert.equal(call.repo, repo);
760
+ assert.equal(call.mergePr, undefined, "the checks-wait entry never merges");
761
+ assert.match(call.mergeReason, /conflicts with main/);
762
+ const events = listWorkflowEventsForIssue(issueId);
763
+ const completed = events.find((e) => e.type === "checks.completed");
764
+ assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "conflicting");
765
+ const deferred = events.filter((e) => e.type === "worker.deferred");
766
+ assert.equal(deferred.length, 1, "the synced head waits on a normal CI re-poll");
767
+ const deferredReason = JSON.parse(deferred[0].payloadJson ?? "{}").reason;
768
+ assert.doesNotMatch(deferredReason, /Waiting for CI \(queued\)/);
769
+ assert.match(deferredReason, /Synced main into PR #\d+/);
770
+ const item = listWorkItemsForIssue(issueId)[0];
771
+ const payload = JSON.parse(item.payloadJson ?? "{}");
772
+ assert.equal(payload.publishOnly, true, "the re-poll takes the no-agent publish path");
773
+ assert.ok(payload.checksWaitStartedAt, "the wait keeps its original ceiling anchor");
774
+ // GitHub recomputes mergeability on the pushed head and CI runs.
775
+ await new Promise((resolve) => setTimeout(resolve, 60));
776
+ mergeable = "MERGEABLE";
777
+ ci = "success";
778
+ await pump(1);
779
+ const issue = getIssue(issueId);
780
+ assert.equal(issue.status, "reviewing", "the synced head continues to review with no human action");
781
+ assert.equal(spawns, 1, "the re-poll spawns no agent");
782
+ assert.equal(recorder.calls.length, 1, "no second sync");
783
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
784
+ assert.equal(issue.infraAttempts, 0);
785
+ });
786
+ });
787
+ test("NOT-354: CONFLICTING with an unresolvable conflict queues exactly one conflict-repair round; still conflicting after it escalates once with the files", async () => {
788
+ const issueId = await makeIssue();
789
+ const prompts = [];
790
+ let n = 0;
791
+ const appendingSpawn = async (input) => {
792
+ prompts.push(input.prompt);
793
+ n++;
794
+ fs.appendFileSync(path.join(input.cwd, "feature.txt"), `round ${n}\n`);
795
+ git(input.cwd, "add", ".");
796
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", `round ${n}`);
797
+ return { exitCode: 0, transcript: "Implementation conclusion: done.", logPath: "/dev/null", timedOut: false };
798
+ };
799
+ const { runMergeConflictSync } = await import("./merge-conflict-sync.js");
800
+ // First episode call: the sync hit a textual conflict. Second call: the real
801
+ // sync, whose episode bound must escalate before touching git.
802
+ const recorder = recordingConflictSync(async (call, i) => i === 1 ? { outcome: "repair_needed", files: ["shared.txt"] } : runMergeConflictSync(call));
803
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
804
+ deckCallTool: okDeckCallTool,
805
+ spawn: appendingSpawn,
806
+ github: fakeGithub({ checks: "none", pullRequestWorkflow: true, mergeable: "CONFLICTING" }),
807
+ conflictSync: recorder.sync,
808
+ }));
809
+ startWorkflow(issueId);
810
+ await pump(1);
811
+ let issue = getIssue(issueId);
812
+ assert.equal(issue.status, "developing");
813
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
814
+ const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
815
+ assert.equal(pending.length, 1, "exactly one conflict-repair round");
816
+ assert.equal(pending[0].kind, "developer");
817
+ assert.equal(pending[0].round, 2);
818
+ const payload = JSON.parse(pending[0].payloadJson ?? "{}");
819
+ assert.deepEqual(payload.conflictRepair, { baseBranch: "main", branch: issueBranchName(issueId), files: ["shared.txt"] });
820
+ assert.equal(payload.publishOnly, undefined, "the repair round is an agent session, not a re-poll");
821
+ assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "worker.deferred").length, 0, "a conflicting PR never defers as a CI wait");
822
+ await pump(1);
823
+ assert.equal(prompts.length, 2);
824
+ assert.ok(prompts[1].includes("main"), "repair prompt names the base");
825
+ assert.ok(prompts[1].includes("shared.txt"), "repair prompt names the conflicting files");
826
+ issue = getIssue(issueId);
827
+ assert.equal(issue.status, "needs_human");
828
+ assert.equal(recorder.calls.length, 2);
829
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.status === "pending").length, 0);
830
+ const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
831
+ assert.equal(open.length, 1, "escalates exactly once");
832
+ assert.equal(open[0].actionType, "policy_escalation");
833
+ assert.match(open[0].reason, /shared\.txt/);
834
+ assert.match(open[0].reason, /conflicts with main/);
835
+ assert.doesNotMatch(open[0].reason, /Auto-merge failed/);
836
+ const evidence = JSON.parse(open[0].evidenceJson);
837
+ assert.deepEqual(evidence.conflictingFiles, ["shared.txt"]);
838
+ assert.equal(evidence.mergeFailure, undefined, "a pre-review conflict offers no retry_merge");
839
+ await pump(1);
840
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1, "no repeat escalation");
841
+ });
842
+ test("NOT-311: past CHECKS_WAIT_CEILING_MS the wait escalates naming the PR and head SHA", async () => {
843
+ // A zero ceiling makes the very first wait already past it — no clock to inject,
844
+ // no sleeping: the deferral reports escalated and a policy_escalation lands.
845
+ const prev = process.env.CHECKS_WAIT_CEILING_MS;
846
+ process.env.CHECKS_WAIT_CEILING_MS = "0";
847
+ try {
848
+ const issueId = await makeIssue();
849
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
850
+ startWorkflow(issueId);
851
+ await pump(1);
852
+ const issue = getIssue(issueId);
853
+ assert.equal(issue.status, "needs_human");
854
+ assert.equal(issue.infraAttempts, 0, "a ceiling escalation spends no infra attempt");
855
+ const actions = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
856
+ assert.equal(actions.length, 1);
857
+ assert.equal(actions[0].actionType, "policy_escalation");
858
+ // The wait never hands off, so the issue row carries no PR/SHA — the milestone
859
+ // and the human action do.
860
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
861
+ assert.ok(completed);
862
+ const msPayload = JSON.parse(completed.payloadJson ?? "{}");
863
+ assert.equal(msPayload.reason, "pending_ceiling");
864
+ assert.ok(msPayload.prNumber, "the milestone names the PR");
865
+ assert.ok(msPayload.headSha, "the milestone names the head SHA");
866
+ assert.match(actions[0].reason, new RegExp(`CI did not complete on ${msPayload.headSha}`));
867
+ assert.match(actions[0].reason, new RegExp(`PR #${msPayload.prNumber}`));
868
+ }
869
+ finally {
870
+ if (prev === undefined)
871
+ delete process.env.CHECKS_WAIT_CEILING_MS;
872
+ else
873
+ process.env.CHECKS_WAIT_CEILING_MS = prev;
874
+ }
549
875
  });
550
876
  test("NOT-255: a non-draft PR with otherwise-verified identity hands off cleanly (just-failed retry_merge)", async () => {
551
877
  // A merge-failure escalation is reachable from `retry_merge`, which undrafts the PR
@@ -2079,7 +2405,9 @@ test("NOT-252: checks_failed enriches checks_evidence and the retry prompt with
2079
2405
  registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github }));
2080
2406
  startWorkflow(issueId);
2081
2407
  await pump(1);
2082
- assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the infra budget");
2408
+ assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the CI budget");
2409
+ assert.equal(getIssue(issueId).ciAttempts, 1, "the enriched retry spends ci_attempts");
2410
+ assert.equal(getIssue(issueId).infraAttempts, 0, "the enriched retry leaves infra_attempts alone");
2083
2411
  const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
2084
2412
  assert.ok(evidence, "enriched checks_failed persists checks_evidence");
2085
2413
  const content = JSON.parse(evidence.contentJson);
@@ -2138,3 +2466,132 @@ test("NOT-252: publish-only retry on checks_failed enriches evidence and the nex
2138
2466
  assert.match(prompts[1], /publish-checks/);
2139
2467
  assert.match(prompts[1], /is not in this registry/);
2140
2468
  });
2469
+ test("NOT-313: three CI repair attempts then a CI-specific escalation — infra_attempts stays 0", async () => {
2470
+ // Exit-predicate loop: the CI budget (default 3) buys three repair attempts;
2471
+ // the fourth consecutive checks_failed escalates with a CI-specific reason.
2472
+ const issueId = await makeIssue();
2473
+ const prompts = [];
2474
+ let call = 0;
2475
+ const evolvingSpawn = async (input) => {
2476
+ call++;
2477
+ prompts.push(input.prompt);
2478
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), `implemented attempt ${call}\n`);
2479
+ git(input.cwd, "add", ".");
2480
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", `implement ${call}`);
2481
+ return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
2482
+ };
2483
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github: fakeGithub({ checks: "failure" }) }));
2484
+ startWorkflow(issueId);
2485
+ await pump(1); // failure #1 → CI repair attempt 1
2486
+ assert.equal(getIssue(issueId).status, "developing");
2487
+ assert.equal(getIssue(issueId).ciAttempts, 1);
2488
+ assert.equal(getIssue(issueId).infraAttempts, 0);
2489
+ await pump(1); // failure #2 → CI repair attempt 2
2490
+ assert.equal(getIssue(issueId).status, "developing");
2491
+ assert.equal(getIssue(issueId).ciAttempts, 2);
2492
+ assert.equal(getIssue(issueId).infraAttempts, 0);
2493
+ assert.match(prompts[1], /CI repair attempt 1 of 3/);
2494
+ await pump(1); // failure #3 → CI repair attempt 3
2495
+ assert.equal(getIssue(issueId).status, "developing");
2496
+ assert.equal(getIssue(issueId).ciAttempts, 3);
2497
+ assert.equal(getIssue(issueId).infraAttempts, 0);
2498
+ assert.match(prompts[2], /CI repair attempt 2 of 3/);
2499
+ await pump(1); // failure #4 → budget spent → escalate
2500
+ const issue = getIssue(issueId);
2501
+ assert.equal(issue.status, "needs_human");
2502
+ assert.equal(issue.ciAttempts, 3, "the escalation itself spends no further CI attempt");
2503
+ assert.equal(issue.infraAttempts, 0, "four CI failures never touched the infra budget");
2504
+ assert.match(prompts[3], /CI repair attempt 3 of 3/);
2505
+ const action = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
2506
+ assert.ok(action, "the exhausted CI budget raises a human action");
2507
+ assert.equal(action.actionType, "policy_escalation");
2508
+ assert.match(action.reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
2509
+ assert.doesNotMatch(action.reason, /infra-attempt limit reached/);
2510
+ });
2511
+ test("NOT-315: a failing dependency install is a worktree-setup failure with the stderr tail — the session is not spawned", async () => {
2512
+ // Hermetic repo: the shared fixture carries no package.json (so the step would skip
2513
+ // before the runner is ever invoked), so this test cuts its own repo + remote whose
2514
+ // main tip declares npm dependencies — the fresh worktree then really triggers the install.
2515
+ const npmRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-repo-"));
2516
+ const npmRemote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-remote-"));
2517
+ try {
2518
+ git(npmRepo, "init", "-q", "-b", "main");
2519
+ git(npmRepo, "config", "user.email", "test@example.com");
2520
+ git(npmRepo, "config", "user.name", "Test");
2521
+ fs.writeFileSync(path.join(npmRepo, "README.md"), "hello\n");
2522
+ fs.writeFileSync(path.join(npmRepo, "package.json"), JSON.stringify({ name: "wt", version: "1.0.0" }));
2523
+ fs.writeFileSync(path.join(npmRepo, "package-lock.json"), JSON.stringify({ name: "wt", lockfileVersion: 3, packages: {} }));
2524
+ git(npmRepo, "add", ".");
2525
+ git(npmRepo, "commit", "-q", "-m", "init");
2526
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", npmRemote]);
2527
+ git(npmRepo, "remote", "add", "origin", npmRemote);
2528
+ git(npmRepo, "push", "-q", "origin", "main");
2529
+ const devAgent = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
2530
+ const revAgent = createAgent({ name: `rev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
2531
+ const issueId = createIssue({
2532
+ title: "Add widget",
2533
+ description: "Build the widget.",
2534
+ acceptanceCriteria: "Widget renders.",
2535
+ repo: npmRepo,
2536
+ baseBranch: "main",
2537
+ developerAgentId: devAgent.id,
2538
+ reviewerAgentId: revAgent.id,
2539
+ maxReviewRounds: 3,
2540
+ maxInfraAttempts: 3,
2541
+ source: "manual"
2542
+ }).id;
2543
+ let spawnCalls = 0;
2544
+ const neverSpawn = async () => {
2545
+ spawnCalls++;
2546
+ return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
2547
+ };
2548
+ let depsCalls = 0;
2549
+ const failingDeps = async () => {
2550
+ depsCalls++;
2551
+ const err = new Error("npm ci exited");
2552
+ err.stderr = "npm error code EAI_AGAIN\nnpm error registry unreachable (sandbox has no network)\n";
2553
+ throw err;
2554
+ };
2555
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: neverSpawn, github: fakeGithub(), depsRunner: failingDeps }));
2556
+ startWorkflow(issueId);
2557
+ await pump(1);
2558
+ assert.equal(depsCalls, 1, "the worktree declares npm deps, so the install must run exactly once");
2559
+ assert.equal(spawnCalls, 0, "the session must not spawn when dependency provisioning fails");
2560
+ const issue = getIssue(issueId);
2561
+ assert.equal(issue.status, "developing", "adapter_failure retries on the infra budget");
2562
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
2563
+ // adapter_failure never spawned, so the session row goes terminal (done) with the
2564
+ // setup failure recorded as its reason — like every other adapter_failure.
2565
+ assert.ok(dev.status === "done" || dev.status === "failed", `terminal session, got ${dev.status}`);
2566
+ const reason = JSON.parse(dev.errorJson ?? "{}").reason ?? dev.errorJson ?? "";
2567
+ assert.match(reason, /worktree setup failed/);
2568
+ assert.match(reason, /dependency install failed: /);
2569
+ assert.match(reason, /registry unreachable/);
2570
+ }
2571
+ finally {
2572
+ fs.rmSync(npmRepo, { recursive: true, force: true });
2573
+ fs.rmSync(npmRemote, { recursive: true, force: true });
2574
+ }
2575
+ });
2576
+ test("NOT-315: a skipped dependency install still emits worktree.deps_ready with ran/skipped and durationMs", async () => {
2577
+ // The fixture repo carries no package.json, so the step skips (no package.json) and
2578
+ // the fake runner is never called — the milestone must still record the skip.
2579
+ const issueId = await makeIssue();
2580
+ let depsCalls = 0;
2581
+ const countingDeps = async () => {
2582
+ depsCalls++;
2583
+ return { stdout: "", stderr: "" };
2584
+ };
2585
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub(), depsRunner: countingDeps }));
2586
+ startWorkflow(issueId);
2587
+ await pump(1);
2588
+ assert.equal(getIssue(issueId).status, "reviewing", "a skipped install must not block handoff");
2589
+ assert.equal(depsCalls, 0, "no package.json means the runner is never invoked");
2590
+ const { listWorkflowEventsForIssue: listEvents } = await import("../repository/workflow-events.js");
2591
+ const depsEvent = listEvents(issueId).find((e) => e.type === "worktree.deps_ready");
2592
+ assert.ok(depsEvent, "expected a worktree.deps_ready milestone");
2593
+ const payload = JSON.parse(depsEvent.payloadJson);
2594
+ assert.equal(payload.ran, false);
2595
+ assert.match(String(payload.reason ?? ""), /no package\.json/);
2596
+ assert.ok(typeof payload.durationMs === "number" && payload.durationMs >= 0);
2597
+ });
@@ -82,6 +82,7 @@ const VALID_CHOICES = {
82
82
  // below and the only legal resolver is commands.ts's dedicated muse_capability branch
83
83
  // (acknowledge records a per-version override; there is no workflow outcome to map).
84
84
  muse_capability: ["acknowledge"],
85
+ operator_verification: ["verified", "waive", "repair"],
85
86
  };
86
87
  /**
87
88
  * Validates a raw (actionType, choice) pair against that action type's allowed response
@@ -114,6 +115,16 @@ export function parseHumanResolution(actionType, choice, note) {
114
115
  if (actionType === "product_scope_decision" && note) {
115
116
  return { actionType, choice, note };
116
117
  }
118
+ // NOT-314: operator_verification always carries a required non-empty note (the
119
+ // recorded result, the waiver reason, or the repair instruction). A missing or
120
+ // blank note is a caller error — resolveHumanActionAndAdvance 400s, never stores.
121
+ if (actionType === "operator_verification") {
122
+ if (!VALID_CHOICES.operator_verification.includes(choice))
123
+ return null;
124
+ if (!note || !note.trim())
125
+ return null;
126
+ return { actionType, choice, note: note.trim() };
127
+ }
117
128
  return { actionType, choice };
118
129
  }
119
130
  /**
@@ -164,6 +175,14 @@ export function resolveHumanActionOutcome(resolution) {
164
175
  throw new Error(`Unrecognized policy_escalation choice: ${resolution.choice}`);
165
176
  case "product_scope_decision":
166
177
  return { issueStatus: "developing", startNewRound: true, roundKind: "none" };
178
+ case "operator_verification":
179
+ // NOT-314: `repair` is a genuine repair cycle (same shape as
180
+ // final_review:repair). `verified` / `waive` never reach here — the sync
181
+ // core parks them for undraft+merge before consulting this outcome map
182
+ // (same as policy_escalation:retry_merge).
183
+ if (resolution.choice === "repair")
184
+ return { issueStatus: "repairing", startNewRound: true, roundKind: "review" };
185
+ throw new Error(`Unrecognized operator_verification choice: ${resolution.choice}`);
167
186
  case "deck_interaction_required":
168
187
  // Same semantics as policy_escalation:resume — an authority/control-plane hiccup is
169
188
  // not a review-round spend; a fresh attempt mints its own new authority when it runs.
@@ -82,3 +82,36 @@ test("deck_interaction_required close ends the issue", () => {
82
82
  assert.equal(result.issueStatus, "closed");
83
83
  assert.equal(result.workflowOutcome, "closed");
84
84
  });
85
+ // NOT-314: operator_verification parses only with a non-empty note.
86
+ test("parseHumanResolution accepts operator_verification choices only with a note", () => {
87
+ assert.deepStrictEqual(parseHumanResolution("operator_verification", "verified", "SSO login OK"), {
88
+ actionType: "operator_verification",
89
+ choice: "verified",
90
+ note: "SSO login OK",
91
+ });
92
+ assert.deepStrictEqual(parseHumanResolution("operator_verification", "waive", " waiver reason "), {
93
+ actionType: "operator_verification",
94
+ choice: "waive",
95
+ note: "waiver reason",
96
+ });
97
+ assert.deepStrictEqual(parseHumanResolution("operator_verification", "repair", "fix the probe"), {
98
+ actionType: "operator_verification",
99
+ choice: "repair",
100
+ note: "fix the probe",
101
+ });
102
+ assert.equal(parseHumanResolution("operator_verification", "verified"), null);
103
+ assert.equal(parseHumanResolution("operator_verification", "verified", ""), null);
104
+ assert.equal(parseHumanResolution("operator_verification", "verified", " "), null);
105
+ assert.equal(parseHumanResolution("operator_verification", "merge", "a note"), null);
106
+ });
107
+ test("operator_verification repair queues another repair round; verified/waive never map here", () => {
108
+ const result = resolveHumanActionOutcome({
109
+ actionType: "operator_verification",
110
+ choice: "repair",
111
+ note: "fix the probe",
112
+ });
113
+ assert.equal(result.issueStatus, "repairing");
114
+ assert.equal(result.startNewRound, true);
115
+ assert.equal(result.roundKind, "review");
116
+ assert.throws(() => resolveHumanActionOutcome({ actionType: "operator_verification", choice: "verified", note: "ok" }), /Unrecognized operator_verification choice/);
117
+ });