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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +4 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +39 -3
- package/bundle/server/dist/coordinator/commands.js +90 -5
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +79 -1
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +17 -1
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +78 -2
- package/bundle/server/dist/coordinator/prompts.test.js +83 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.test.js +14 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/index.js +5 -0
- package/bundle/server/dist/db/schema.sql +3 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +29 -4
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
- package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +9 -9
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +9 -0
- package/bundle/shared/dist/index.d.ts +85 -84
- package/bundle/shared/dist/index.js +3 -0
- package/bundle/shared/dist/issues.d.ts +146 -16
- package/bundle/shared/dist/issues.js +8 -0
- package/bundle/shared/dist/issues.test.js +1 -0
- package/bundle/shared/dist/outbound-draft.d.ts +12 -12
- package/bundle/shared/dist/worker-sessions.d.ts +10 -0
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +10 -0
- package/bundle/shared/dist/workflow.test.js +2 -0
- package/bundle/shared/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
343
|
+
evidence,
|
|
308
344
|
responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
|
|
309
345
|
});
|
|
310
346
|
appendWorkflowEvent({
|