agent-dealer 1.2.7 → 1.2.8

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 (87) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +4 -1
  5. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  6. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  7. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  8. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  9. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  10. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  11. package/bundle/server/dist/coordinator/admission.js +14 -1
  12. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  13. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  14. package/bundle/server/dist/coordinator/auto-merge.js +39 -3
  15. package/bundle/server/dist/coordinator/commands.js +90 -5
  16. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  17. package/bundle/server/dist/coordinator/developer-effect.js +79 -1
  18. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  19. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  20. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  21. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  22. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  23. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  24. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  25. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  26. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  27. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  28. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  29. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  30. package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
  31. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  32. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  33. package/bundle/server/dist/coordinator/prompts.js +78 -2
  34. package/bundle/server/dist/coordinator/prompts.test.js +83 -0
  35. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  36. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  37. package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
  38. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  39. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  40. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  41. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  42. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  43. package/bundle/server/dist/db/index.js +5 -0
  44. package/bundle/server/dist/db/schema.sql +3 -0
  45. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  46. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  47. package/bundle/server/dist/repository/human-actions.js +14 -0
  48. package/bundle/server/dist/repository/issues.js +29 -4
  49. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  50. package/bundle/server/dist/routes/human-actions.js +10 -8
  51. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  52. package/bundle/server/dist/routes/issues.js +19 -2
  53. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  54. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  55. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  56. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  57. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  58. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  59. package/bundle/server/package.json +2 -2
  60. package/bundle/server/static-ui/assets/{index-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
  61. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  64. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  65. package/bundle/shared/dist/execution-contract.js +307 -0
  66. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  67. package/bundle/shared/dist/execution-contract.test.js +499 -0
  68. package/bundle/shared/dist/execution-report.d.ts +9 -9
  69. package/bundle/shared/dist/execution-report.js +2 -0
  70. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  71. package/bundle/shared/dist/failure-cause.js +2 -0
  72. package/bundle/shared/dist/human-actions.d.ts +4 -4
  73. package/bundle/shared/dist/human-actions.js +9 -0
  74. package/bundle/shared/dist/index.d.ts +85 -84
  75. package/bundle/shared/dist/index.js +3 -0
  76. package/bundle/shared/dist/issues.d.ts +146 -16
  77. package/bundle/shared/dist/issues.js +8 -0
  78. package/bundle/shared/dist/issues.test.js +1 -0
  79. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  80. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  81. package/bundle/shared/dist/worker-sessions.js +8 -0
  82. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  83. package/bundle/shared/dist/workflow.d.ts +4 -4
  84. package/bundle/shared/dist/workflow.js +10 -0
  85. package/bundle/shared/dist/workflow.test.js +2 -0
  86. package/bundle/shared/package.json +1 -1
  87. package/package.json +1 -1
@@ -403,7 +403,10 @@ test("NOT-178: a Muse Code developer without credentials is refused at admission
403
403
  });
404
404
  // NOT-277: a Muse Code auto-update is re-validated automatically. A version whose developer
405
405
  // shell/write check passes is admitted with no manual step; one that lost it is refused with the
406
- // versions and capability named; one whose check could not complete is refused, never assumed ok.
406
+ // versions and capability named.
407
+ // NOT-308: an inconclusive check never blocks on a confirmed baseline (admitted, retried in
408
+ // the background); a confirmed loss escalates exactly one human action, and acknowledging
409
+ // it lifts the block.
407
410
  test("NOT-277: a Muse Code update is admitted when capable, refused by name when not", async () => {
408
411
  const { clearAgentHealthCaches, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
409
412
  const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
@@ -463,11 +466,22 @@ test("NOT-277: a Muse Code update is admitted when capable, refused by name when
463
466
  const reason = getQueuedEntryForIssue(issue.id).waitReason;
464
467
  assert.match(reason, /developer unhealthy/);
465
468
  assert.match(reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
469
+ // NOT-308: the confirmed loss escalates exactly one human action naming the update
470
+ // and the capability — and acknowledging it lifts the admission block.
471
+ const { listHumanActionsByRequestId } = await import("../repository/human-actions.js");
472
+ const { museCapabilityRequestId } = await import("../adapters/muse-capability.js");
473
+ const { resolveHumanActionAndAdvance } = await import("./commands.js");
474
+ const escalated = listHumanActionsByRequestId("muse_capability", museCapabilityRequestId(BROKEN, "missing"));
475
+ assert.equal(escalated.filter((a) => a.status === "open").length, 1);
476
+ assert.match(escalated[0].reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
477
+ const acknowledged = resolveHumanActionAndAdvance(escalated[0].id, "test", "acknowledge");
478
+ assert.equal(acknowledged.ok, true);
479
+ assert.deepEqual(await developerHealth(), { ok: true }, "acknowledge lifts the block");
480
+ // NOT-308: an inconclusive check (the probe throws) does NOT block on the baseline.
466
481
  await museReports("9.9.9-R1");
467
- const unverified = await developerHealth();
468
- assert.equal(unverified.ok, false);
469
- assert.match(unverified.reason, new RegExp(`Could not verify Muse Code developer shell/write access after version change \\(${BROKEN} → 9\\.9\\.9-R1\\)`));
482
+ assert.deepEqual(await developerHealth(), { ok: true });
470
483
  assert.deepEqual(probed, [OLD, NEW, BROKEN, "9.9.9-R1"]);
484
+ assert.equal(listHumanActionsByRequestId("muse_capability", museCapabilityRequestId("9.9.9-R1", "unverified")).length, 0, "a first inconclusive result escalates nothing");
471
485
  }
472
486
  finally {
473
487
  for (const [key, value] of Object.entries({
@@ -486,6 +500,185 @@ test("NOT-277: a Muse Code update is admitted when capable, refused by name when
486
500
  setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
487
501
  }
488
502
  });
503
+ // NOT-308: real-checker Muse harness — stub `muse --version`, stub gh login, stub the
504
+ // capability probe. Restores every override so later tests keep the unit-test skip.
505
+ async function withMuseHarness(probe, fn) {
506
+ const { clearAgentHealthCaches, invalidateMuseHealthCache, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
507
+ const cap = await import("../adapters/muse-capability.js");
508
+ cap.resetMuseCapabilityStateForTests();
509
+ const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
510
+ const versionFile = path.join(stubDir, "version");
511
+ const stub = path.join(stubDir, "muse");
512
+ fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(versionFile)}\n`);
513
+ fs.chmodSync(stub, 0o755);
514
+ const ghStub = path.join(stubDir, "gh");
515
+ fs.writeFileSync(ghStub, "#!/bin/sh\necho 'Logged in to github.com account dealer-test'\nexit 0\n");
516
+ fs.chmodSync(ghStub, 0o755);
517
+ cap.setMuseCapabilityProbeForTests(probe);
518
+ const saved = {
519
+ MUSE_CLI: process.env.MUSE_CLI,
520
+ META_API_KEY: process.env.META_API_KEY,
521
+ SKIP: process.env.AGENT_DEALER_SKIP_AGENT_HEALTH,
522
+ PATH: process.env.PATH,
523
+ };
524
+ process.env.MUSE_CLI = stub;
525
+ process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`;
526
+ process.env.META_API_KEY = "k";
527
+ delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
528
+ setAdmissionHealthCheckerForTests(null);
529
+ clearAgentHealthCaches();
530
+ const writeVersion = (version) => {
531
+ fs.writeFileSync(versionFile, `Muse Code ${version.split("-")[0]} (${version})\n`);
532
+ };
533
+ try {
534
+ await fn({
535
+ versionFile,
536
+ show: writeVersion,
537
+ report: async (version) => {
538
+ writeVersion(version);
539
+ await runtimeIssuesUncached("muse_code");
540
+ await cap.settleMuseCapabilityCheckForTests();
541
+ },
542
+ reportUnsettled: async (version) => {
543
+ writeVersion(version);
544
+ // Targeted invalidation only: clearAgentHealthCaches would also reset the
545
+ // capability state under test.
546
+ invalidateMuseHealthCache();
547
+ return runtimeIssuesUncached("muse_code");
548
+ },
549
+ });
550
+ }
551
+ finally {
552
+ for (const [key, value] of Object.entries({
553
+ MUSE_CLI: saved.MUSE_CLI,
554
+ META_API_KEY: saved.META_API_KEY,
555
+ AGENT_DEALER_SKIP_AGENT_HEALTH: saved.SKIP,
556
+ PATH: saved.PATH,
557
+ })) {
558
+ if (value === undefined)
559
+ delete process.env[key];
560
+ else
561
+ process.env[key] = value;
562
+ }
563
+ cap.setMuseCapabilityProbeForTests(null);
564
+ clearAgentHealthCaches();
565
+ setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
566
+ }
567
+ }
568
+ // NOT-308: while the new version is still being verified, developer admission is allowed
569
+ // on the confirmed baseline — no `runtime_capability` block while the check is in flight.
570
+ test("NOT-308: developer admission is allowed while the new version check is in flight", async () => {
571
+ const cap = await import("../adapters/muse-capability.js");
572
+ const OLD = "1.3.0-R3401.1";
573
+ const NEW = "1.4.0-R4161.1";
574
+ let release;
575
+ await withMuseHarness(async (version) => {
576
+ if (version === NEW) {
577
+ return new Promise((resolve) => {
578
+ release = () => resolve({ status: "capable" });
579
+ });
580
+ }
581
+ return { status: "capable" };
582
+ }, async ({ report, reportUnsettled }) => {
583
+ await report(OLD);
584
+ const issues = await reportUnsettled(NEW);
585
+ assert.deepEqual(issues.filter((i) => i.code === "runtime_capability"), [], "no runtime_capability block while verifying on a baseline");
586
+ assert.equal(cap.museCapabilityCheckInFlight(), true);
587
+ // The admission gate agrees: healthy, with the check still running.
588
+ const issue = readyIssue("muse-inflight", { runtimes: { dev: "muse_code", rev: "codex_local" } });
589
+ const { invalidateMuseHealthCache } = await import("../adapters/agent-health.js");
590
+ invalidateMuseHealthCache();
591
+ assert.deepEqual(await checkRoleAgentHealthy(issue, "developer", { deckOnline: true }), {
592
+ ok: true,
593
+ });
594
+ assert.equal(cap.museCapabilityCheckInFlight(), true);
595
+ release();
596
+ await cap.settleMuseCapabilityCheckForTests();
597
+ invalidateMuseHealthCache();
598
+ assert.deepEqual(await checkRoleAgentHealthy(issue, "developer", { deckOnline: true }), {
599
+ ok: true,
600
+ });
601
+ });
602
+ });
603
+ // NOT-308: three inconclusive polls escalate exactly one "could not verify" action and stop
604
+ // probing — admission never blocks.
605
+ test("NOT-308: three inconclusive polls escalate once, then stop probing, never blocking", async () => {
606
+ const cap = await import("../adapters/muse-capability.js");
607
+ const { invalidateMuseHealthCache } = await import("../adapters/agent-health.js");
608
+ const { listHumanActionsByRequestId } = await import("../repository/human-actions.js");
609
+ const OLD = "1.3.0-R3401.1";
610
+ const NEW = "1.4.0-R4161.1";
611
+ const probed = [];
612
+ await withMuseHarness(async (version) => {
613
+ probed.push(version);
614
+ if (version === NEW)
615
+ return { status: "error", detail: "probe session timed out" };
616
+ return { status: "capable" };
617
+ }, async ({ report, show }) => {
618
+ await report(OLD);
619
+ show(NEW);
620
+ const issue = readyIssue("muse-unverified", {
621
+ runtimes: { dev: "muse_code", rev: "codex_local" },
622
+ });
623
+ const poll = async () => {
624
+ // Targeted invalidation only: clearAgentHealthCaches would also reset the
625
+ // capability attempts under test.
626
+ invalidateMuseHealthCache();
627
+ const health = await checkRoleAgentHealthy(issue, "developer", { deckOnline: true });
628
+ await cap.settleMuseCapabilityCheckForTests();
629
+ return health;
630
+ };
631
+ const { ageMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
632
+ assert.deepEqual(await poll(), { ok: true }, "attempt 1: allowed");
633
+ ageMuseCapabilityCheckForTests(61_000);
634
+ assert.deepEqual(await poll(), { ok: true }, "attempt 2: allowed");
635
+ ageMuseCapabilityCheckForTests(122_000);
636
+ assert.deepEqual(await poll(), { ok: true }, "attempt 3: allowed");
637
+ assert.deepEqual(probed, [OLD, NEW, NEW, NEW]);
638
+ // The third poll settles exhausted, so the next polls escalate — exactly once.
639
+ assert.deepEqual(await poll(), { ok: true }, "exhausted: still allowed");
640
+ assert.deepEqual(await poll(), { ok: true }, "second post-exhaustion poll: still allowed");
641
+ const actions = listHumanActionsByRequestId("muse_capability", cap.museCapabilityRequestId(NEW, "unverified"));
642
+ assert.equal(actions.filter((a) => a.status === "open").length, 1);
643
+ assert.match(actions[0].reason, /Could not verify Muse Code developer shell\/write access/);
644
+ assert.match(actions[0].reason, new RegExp(`for ${NEW} after 3 inconclusive checks`));
645
+ // No fourth probe ever, however long the wait.
646
+ ageMuseCapabilityCheckForTests(10 * 60_000);
647
+ invalidateMuseHealthCache();
648
+ assert.deepEqual(await checkRoleAgentHealthy(issue, "developer", { deckOnline: true }), {
649
+ ok: true,
650
+ });
651
+ assert.deepEqual(probed, [OLD, NEW, NEW, NEW], "no further probes after exhaustion");
652
+ });
653
+ });
654
+ // NOT-308: with no confirmed baseline ever (fresh install), the unknown still fails
655
+ // closed — in flight and errored alike.
656
+ test("NOT-308: with no confirmed baseline, admission stays blocked while unconfirmed", async () => {
657
+ const cap = await import("../adapters/muse-capability.js");
658
+ const NEW = "1.4.0-R4161.1";
659
+ let release;
660
+ let calls = 0;
661
+ await withMuseHarness(async () => {
662
+ calls += 1;
663
+ return new Promise((resolve) => {
664
+ release = () => resolve({ status: "error", detail: "timed out" });
665
+ });
666
+ }, async ({ reportUnsettled }) => {
667
+ const issue = readyIssue("muse-fresh", { runtimes: { dev: "muse_code", rev: "codex_local" } });
668
+ const health = (i) => checkRoleAgentHealthy(i, "developer", { deckOnline: true });
669
+ await reportUnsettled(NEW);
670
+ assert.equal(cap.museCapabilityCheckInFlight(), true);
671
+ const blocked = await health(issue);
672
+ assert.equal(blocked.ok, false);
673
+ assert.match(blocked.reason, /verifying developer shell\/write access/);
674
+ release();
675
+ await cap.settleMuseCapabilityCheckForTests();
676
+ const errored = await health(issue);
677
+ assert.equal(errored.ok, false);
678
+ assert.match(errored.reason, /Could not verify Muse Code/);
679
+ assert.equal(calls, 1);
680
+ });
681
+ });
489
682
  // NOT-178: Muse Code is developer-only, so it is never a healthy reviewer — even with the
490
683
  // unit-test health skip on, and without probing the CLI.
491
684
  test("NOT-178: a Muse Code reviewer is refused regardless of CLI health", async () => {
@@ -17,6 +17,8 @@ const { startWorkflow, applyCompletion } = await import("./commands.js");
17
17
  const { ReviewerResult } = await import("./reviewer-result.js");
18
18
  const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
19
19
  const { managedRepoPath } = await import("../adapters/managed-repo.js");
20
+ const { buildDeveloperPrompt } = await import("./prompts.js");
21
+ const { defaultSyncGitExec, setConflictSyncGithubForTests, setConflictSyncGitExecForTests, } = await import("./merge-conflict-sync.js");
20
22
  /** Real local checkout so resolveAutoMergeCwd accepts the default legacy repo. */
21
23
  let fixtureRepo = "";
22
24
  function initFixtureRepo() {
@@ -48,10 +50,14 @@ beforeEach(() => {
48
50
  `);
49
51
  clearFinalizeInflightForTests();
50
52
  setMergePrForTests(async () => ({ ok: true }));
53
+ setConflictSyncGitExecForTests(null);
54
+ setConflictSyncGithubForTests(null);
51
55
  });
52
56
  afterEach(() => {
53
57
  setMergePrForTests(null);
54
58
  clearFinalizeInflightForTests();
59
+ setConflictSyncGitExecForTests(null);
60
+ setConflictSyncGithubForTests(null);
55
61
  });
56
62
  function newIssue(opts = {}) {
57
63
  return createIssue({
@@ -388,6 +394,276 @@ test("success txn dismisses stale needs_human policy_escalation from a racing fa
388
394
  const resolved = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation" && a.status === "resolved");
389
395
  assert.ok(resolved, "expected the racing escalation to be marked resolved");
390
396
  });
397
+ /** NOT-310: origin + local clone where branch `issue-1` (commit B) is behind a moved
398
+ * base (commit C). With `conflict`, C edits the same line of shared.txt that B
399
+ * touched; otherwise C adds an unrelated file and merges cleanly. With
400
+ * `upToDate`, C lands before the branch is cut, so the branch already contains
401
+ * the base tip (a "not mergeable" failure there is a policy block, not
402
+ * staleness) — the conflict flag is then meaningless and must be false. */
403
+ function initNot310Repos(opts) {
404
+ assert.equal(opts.upToDate === true && opts.conflict === true, false, "upToDate fixtures cannot also conflict");
405
+ const origin = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not310-origin-"));
406
+ execFileSync("git", ["init", "-b", "main"], { cwd: origin });
407
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: origin });
408
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: origin });
409
+ fs.writeFileSync(path.join(origin, "base.txt"), "v1\n");
410
+ fs.writeFileSync(path.join(origin, "shared.txt"), "line1\nline2\n");
411
+ execFileSync("git", ["add", "."], { cwd: origin });
412
+ execFileSync("git", ["commit", "-m", "A base"], { cwd: origin });
413
+ const commitC = () => {
414
+ if (opts.conflict) {
415
+ fs.writeFileSync(path.join(origin, "shared.txt"), "line1\nbase change\n");
416
+ }
417
+ else {
418
+ fs.writeFileSync(path.join(origin, "other.txt"), "c\n");
419
+ }
420
+ execFileSync("git", ["add", "."], { cwd: origin });
421
+ execFileSync("git", ["commit", "-m", "C base moved"], { cwd: origin });
422
+ };
423
+ if (opts.upToDate)
424
+ commitC();
425
+ const local = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not310-local-"));
426
+ fs.rmSync(local, { recursive: true, force: true });
427
+ execFileSync("git", ["clone", origin, local]);
428
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: local });
429
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: local });
430
+ execFileSync("git", ["checkout", "-b", "issue-1"], { cwd: local });
431
+ fs.writeFileSync(path.join(local, "shared.txt"), "line1\nfeature change\n");
432
+ execFileSync("git", ["add", "."], { cwd: local });
433
+ execFileSync("git", ["commit", "-m", "B feature"], { cwd: local });
434
+ execFileSync("git", ["push", "-u", "origin", "issue-1"], { cwd: local });
435
+ // Like a production managed clone, the main checkout rests on the base — the
436
+ // issue branch must not be checked out anywhere for the sync to proceed.
437
+ execFileSync("git", ["checkout", "-q", "main"], { cwd: local });
438
+ if (!opts.upToDate)
439
+ commitC();
440
+ const baseMovedSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: origin, encoding: "utf8" }).trim();
441
+ return { origin, local, baseMovedSha };
442
+ }
443
+ function branchContains(repo, sha, branch) {
444
+ try {
445
+ execFileSync("git", ["merge-base", "--is-ancestor", sha, branch], { cwd: repo });
446
+ return true;
447
+ }
448
+ catch {
449
+ return false;
450
+ }
451
+ }
452
+ const NOT_MERGEABLE = "Pull request #42 is not mergeable: the merge commit cannot be cleanly created";
453
+ test("NOT-310: cleanly-mergeable base syncs, pushes without force, polls checks, merges untouched", async () => {
454
+ const { origin, local, baseMovedSha } = initNot310Repos({ conflict: false });
455
+ const gitArgs = [];
456
+ setConflictSyncGitExecForTests(async (args, opts) => {
457
+ gitArgs.push(args);
458
+ return defaultSyncGitExec(args, opts);
459
+ });
460
+ let checksPolls = 0;
461
+ setConflictSyncGithubForTests({
462
+ checksSnapshot: async () => {
463
+ checksPolls += 1;
464
+ return "success";
465
+ },
466
+ });
467
+ const mergeCalls = [];
468
+ let merges = 0;
469
+ setMergePrForTests(async (opts) => {
470
+ mergeCalls.push(opts);
471
+ merges += 1;
472
+ return merges === 1 ? { ok: false, reason: NOT_MERGEABLE } : { ok: true };
473
+ });
474
+ const issueId = newIssue({ autoMerge: true, repo: local });
475
+ startWorkflow(issueId);
476
+ await complete(issueId, cleanHandoff);
477
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
478
+ assert.equal(merges, 2, "one failing merge plus one post-sync retry");
479
+ assert.deepEqual(mergeCalls, [
480
+ { cwd: local, number: 42 },
481
+ { cwd: local, number: 42 },
482
+ ]);
483
+ assert.equal(getIssue(issueId).status, "done");
484
+ assert.equal(getActiveWorkflowInstance(issueId), null);
485
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "no human action when the sync resolves the staleness");
486
+ assert.ok(checksPolls >= 1, "expected the checks poll to run after the sync push");
487
+ // The sync merged the moved base and pushed it: origin's branch contains C.
488
+ assert.equal(branchContains(origin, baseMovedSha, "issue-1"), true);
489
+ // No force-push anywhere in the new path.
490
+ const pushes = gitArgs.filter((a) => a[0] === "push");
491
+ assert.ok(pushes.length >= 1, "expected at least one push");
492
+ for (const args of pushes) {
493
+ assert.ok(args.every((a) => !/^(-f|--force)/.test(a)), `push must never force: ${args.join(" ")}`);
494
+ }
495
+ assert.ok(gitArgs.some((a) => a.includes("merge") && a.includes("origin/main")), "expected a merge of the fetched base");
496
+ const root = path.join(local, ".agent-dealer-worktrees");
497
+ const leftovers = fs.existsSync(root) ? fs.readdirSync(root).filter((e) => e.startsWith("merge-sync-")) : [];
498
+ assert.deepEqual(leftovers, []);
499
+ });
500
+ test("NOT-310: retry_merge runs the same conflict sync (commands.ts retry path)", async () => {
501
+ const { resolveHumanActionAndAdvanceAsync } = await import("./commands.js");
502
+ const { cancelWorkItem } = await import("../repository/work-items.js");
503
+ const { createHumanAction } = await import("../repository/human-actions.js");
504
+ const { transitionIssue } = await import("../repository/issues.js");
505
+ const { origin, local, baseMovedSha } = initNot310Repos({ conflict: false });
506
+ setConflictSyncGithubForTests({ checksSnapshot: async () => "success" });
507
+ let merges = 0;
508
+ setMergePrForTests(async () => {
509
+ merges += 1;
510
+ return merges === 1 ? { ok: false, reason: NOT_MERGEABLE } : { ok: true };
511
+ });
512
+ const issueId = newIssue({ autoMerge: true, repo: local });
513
+ startWorkflow(issueId);
514
+ await complete(issueId, cleanHandoff);
515
+ // Park like a merge failure did: drop the pending reviewer round, go
516
+ // needs_human with a merge-failure action offering retry_merge.
517
+ for (const item of listWorkItemsForIssue(issueId)) {
518
+ if (item.status === "pending" || item.status === "leased")
519
+ cancelWorkItem(item.id);
520
+ }
521
+ transitionIssue(issueId, "needs_human", {
522
+ currentOwner: "human",
523
+ currentIntent: `Auto-merge failed: ${NOT_MERGEABLE}`,
524
+ });
525
+ const action = createHumanAction({
526
+ issueId,
527
+ workflowInstanceId: getActiveWorkflowInstance(issueId).id,
528
+ actionType: "policy_escalation",
529
+ reason: `Auto-merge failed: ${NOT_MERGEABLE}`,
530
+ question: `Auto-merge failed: ${NOT_MERGEABLE} Retry the merge, queue another repair round, or close the issue?`,
531
+ evidence: { mergeFailure: true },
532
+ responseOptions: [
533
+ { choice: "retry_merge", label: "Retry merge" },
534
+ { choice: "repair", label: "Another repair round" },
535
+ { choice: "close", label: "Close" },
536
+ ],
537
+ });
538
+ const resolved = await resolveHumanActionAndAdvanceAsync(action.id, "op", "retry_merge");
539
+ assert.equal(resolved.ok, true);
540
+ assert.equal(merges, 2, "retry_merge must sync + retry, not just re-run gh");
541
+ assert.equal(getIssue(issueId).status, "done");
542
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
543
+ assert.equal(branchContains(origin, baseMovedSha, "issue-1"), true);
544
+ });
545
+ test("NOT-310: up-to-date branch that stays not mergeable escalates directly, no repair round", async () => {
546
+ const { local, baseMovedSha } = initNot310Repos({ conflict: false, upToDate: true });
547
+ setConflictSyncGithubForTests({ checksSnapshot: async () => "success" });
548
+ const policyReason = "Pull request #42 is not mergeable: base branch policy prohibits the merge";
549
+ let merges = 0;
550
+ setMergePrForTests(async () => {
551
+ merges += 1;
552
+ return { ok: false, reason: policyReason };
553
+ });
554
+ const issueId = newIssue({ autoMerge: true, repo: local });
555
+ startWorkflow(issueId);
556
+ await complete(issueId, cleanHandoff);
557
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
558
+ assert.equal(merges, 2, "failing merge plus one post-sync retry");
559
+ assert.equal(getIssue(issueId).status, "needs_human");
560
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.status === "pending").length, 0, "a policy block must not spend a conflict-repair round");
561
+ const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
562
+ assert.equal(open.length, 1);
563
+ assert.equal(open[0].actionType, "policy_escalation");
564
+ assert.match(open[0].reason, /base branch policy prohibits the merge/);
565
+ assert.match(open[0].reason, /already contains main .* — not a staleness conflict/);
566
+ const evidence = JSON.parse(open[0].evidenceJson);
567
+ assert.equal(evidence.mergeFailure, true);
568
+ assert.ok(!("conflictingFiles" in evidence), "no file list when nothing conflicted");
569
+ assert.equal(branchContains(local, baseMovedSha, "issue-1"), true);
570
+ });
571
+ test("NOT-310: base advancing during the checks poll still earns the repair round", async () => {
572
+ const { origin, local } = initNot310Repos({ conflict: false });
573
+ // D lands on origin/main while the sync poll runs — a genuine new conflict.
574
+ let advanced = false;
575
+ setConflictSyncGithubForTests({
576
+ checksSnapshot: async () => {
577
+ if (!advanced) {
578
+ advanced = true;
579
+ fs.writeFileSync(path.join(origin, "shared.txt"), "line1\nbase change\n");
580
+ execFileSync("git", ["add", "."], { cwd: origin });
581
+ execFileSync("git", ["commit", "-m", "D base moved again"], { cwd: origin });
582
+ }
583
+ return "success";
584
+ },
585
+ });
586
+ let merges = 0;
587
+ setMergePrForTests(async () => {
588
+ merges += 1;
589
+ return { ok: false, reason: NOT_MERGEABLE };
590
+ });
591
+ const issueId = newIssue({ autoMerge: true, repo: local });
592
+ startWorkflow(issueId);
593
+ await complete(issueId, cleanHandoff);
594
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
595
+ assert.equal(merges, 2);
596
+ assert.equal(getIssue(issueId).status, "repairing");
597
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
598
+ const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
599
+ assert.equal(pending.length, 1);
600
+ const payload = JSON.parse(pending[0].payloadJson);
601
+ assert.deepEqual(payload.conflictRepair, { baseBranch: "main", branch: "issue-1", files: [] });
602
+ });
603
+ test("NOT-310: textual conflict aborts the sync and queues a conflict-repair round, no human action yet", async () => {
604
+ const { local, baseMovedSha } = initNot310Repos({ conflict: true });
605
+ let merges = 0;
606
+ setMergePrForTests(async () => {
607
+ merges += 1;
608
+ return { ok: false, reason: NOT_MERGEABLE };
609
+ });
610
+ const issueId = newIssue({ autoMerge: true, repo: local });
611
+ startWorkflow(issueId);
612
+ await complete(issueId, cleanHandoff);
613
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
614
+ assert.equal(merges, 1);
615
+ assert.equal(getIssue(issueId).status, "repairing");
616
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "no human action is opened before the conflict-repair round runs");
617
+ const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
618
+ assert.equal(pending.length, 1);
619
+ assert.equal(pending[0].kind, "developer");
620
+ const payload = JSON.parse(pending[0].payloadJson);
621
+ assert.deepEqual(payload.conflictRepair, { baseBranch: "main", branch: "issue-1", files: ["shared.txt"] });
622
+ const prompt = buildDeveloperPrompt({
623
+ taskSnapshot: { title: "t", description: "d", acceptanceCriteria: "a", repo: local, baseBranch: "main" },
624
+ round: pending[0].round,
625
+ conflictRepair: payload.conflictRepair,
626
+ });
627
+ assert.ok(prompt.includes("main"), "repair prompt names the base branch");
628
+ assert.ok(prompt.includes("shared.txt"), "repair prompt names the conflicting files");
629
+ // The worktree merge was aborted: the branch still lacks the moved base ...
630
+ assert.equal(branchContains(local, baseMovedSha, "issue-1"), false);
631
+ // ... and no merge-sync checkout is left behind.
632
+ const root = path.join(local, ".agent-dealer-worktrees");
633
+ const leftovers = fs.existsSync(root) ? fs.readdirSync(root).filter((e) => e.startsWith("merge-sync-")) : [];
634
+ assert.deepEqual(leftovers, []);
635
+ });
636
+ test("NOT-310: still conflicting after the conflict-repair round escalates once, listing files", async () => {
637
+ const { local } = initNot310Repos({ conflict: true });
638
+ let merges = 0;
639
+ setMergePrForTests(async () => {
640
+ merges += 1;
641
+ return { ok: false, reason: NOT_MERGEABLE };
642
+ });
643
+ const issueId = newIssue({ autoMerge: true, repo: local });
644
+ startWorkflow(issueId);
645
+ await complete(issueId, cleanHandoff);
646
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
647
+ assert.equal(getIssue(issueId).status, "repairing");
648
+ // The conflict-repair round runs and hands off; the reviewer approves again.
649
+ await complete(issueId, cleanHandoff);
650
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
651
+ assert.equal(merges, 2);
652
+ assert.equal(getIssue(issueId).status, "needs_human");
653
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.status === "pending").length, 0);
654
+ const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
655
+ assert.equal(open.length, 1, "exactly one policy_escalation exists");
656
+ assert.equal(open[0].actionType, "policy_escalation");
657
+ assert.match(open[0].reason, /shared\.txt/);
658
+ assert.deepEqual(JSON.parse(open[0].responseOptionsJson), [
659
+ { choice: "retry_merge", label: "Retry merge" },
660
+ { choice: "repair", label: "Another repair round" },
661
+ { choice: "close", label: "Close" },
662
+ ]);
663
+ const evidence = JSON.parse(open[0].evidenceJson);
664
+ assert.equal(evidence.mergeFailure, true);
665
+ assert.deepEqual(evidence.conflictingFiles, ["shared.txt"]);
666
+ });
391
667
  test("escalate txn no-ops when a racing success already marked the issue done", async () => {
392
668
  const { finalizeAutoMerge } = await import("./auto-merge.js");
393
669
  const { transitionIssue } = await import("../repository/issues.js");
@@ -29,6 +29,7 @@ import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
29
29
  import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
30
30
  import { createHumanAction, findOpenHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
31
31
  import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./human-resolution.js";
32
+ import { isMergeConflictFailure, runMergeConflictSync } from "./merge-conflict-sync.js";
32
33
  const run = promisify(execFile);
33
34
  /** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
34
35
  export const GH_MERGE_TIMEOUT_MS = 20_000;
@@ -187,7 +188,42 @@ async function finalizeAutoMergeOnce(issueId) {
187
188
  if (!resolvedCwd.ok) {
188
189
  return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
189
190
  }
190
- const merge = await mergePrImpl({ cwd: resolvedCwd.cwd, number: issue.prNumber });
191
+ let merge = await mergePrImpl({ cwd: resolvedCwd.cwd, number: issue.prNumber });
192
+ // NOT-310: a not-mergeable failure is staleness Dealer can resolve itself —
193
+ // sync the base, re-verify, retry — instead of escalating with no way forward.
194
+ // Anything the sync refuses or cannot fix degrades to today's escalation.
195
+ // retry_merge parks into this same finalize, so it is covered automatically.
196
+ if (!merge.ok && isMergeConflictFailure(merge.reason) && issue.branch) {
197
+ const sync = await runMergeConflictSync({
198
+ issueId: issue.id,
199
+ instanceId: instance.id,
200
+ repo: issue.repo,
201
+ branch: issue.branch,
202
+ baseBranch: issue.baseBranch,
203
+ prNumber: issue.prNumber,
204
+ mergeReason: merge.reason,
205
+ mergePr: mergePrImpl,
206
+ });
207
+ if (sync.outcome === "merged") {
208
+ merge = { ok: true };
209
+ }
210
+ else if (sync.outcome === "repair_queued") {
211
+ return {
212
+ applied: true,
213
+ issueStatus: "repairing",
214
+ nextWorkItemId: sync.workItemId,
215
+ humanActionId: null,
216
+ instanceCompleted: false,
217
+ triggerReflect: false,
218
+ };
219
+ }
220
+ else if (sync.outcome === "escalate") {
221
+ return escalateMergeFailure(issue, instance.id, sync.reason, sync.evidence);
222
+ }
223
+ else {
224
+ return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
225
+ }
226
+ }
191
227
  if (!merge.ok) {
192
228
  return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
193
229
  }
@@ -251,7 +287,7 @@ async function finalizeAutoMergeOnce(issueId) {
251
287
  };
252
288
  })();
253
289
  }
254
- function escalateMergeFailure(issue, workflowInstanceId, reason) {
290
+ function escalateMergeFailure(issue, workflowInstanceId, reason, evidence = { [MERGE_FAILURE_EVIDENCE_KEY]: true }) {
255
291
  return getDb().transaction(() => {
256
292
  const current = getIssue(issue.id);
257
293
  // Concurrent success already completed — never open a dangling escalation on done.
@@ -304,7 +340,7 @@ function escalateMergeFailure(issue, workflowInstanceId, reason) {
304
340
  actionType: "policy_escalation",
305
341
  reason,
306
342
  question: `${reason} Retry the merge, queue another repair round, or close the issue?`,
307
- evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
343
+ evidence,
308
344
  responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
309
345
  });
310
346
  appendWorkflowEvent({