agent-dealer 1.2.7 → 1.2.9
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 +84 -5
- package/bundle/server/dist/adapters/github.test.js +83 -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/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -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 +244 -4
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +284 -23
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +224 -4
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
- 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 +36 -1
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- 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/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +211 -2
- package/bundle/server/dist/coordinator/prompts.test.js +231 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- 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/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +76 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -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/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +18 -0
- package/bundle/server/dist/db/schema.sql +5 -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 +67 -6
- package/bundle/server/dist/repository/work-items.js +7 -0
- 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-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- 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-CU6fNntg.js +64 -0
- 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 +17 -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 +176 -16
- package/bundle/shared/dist/issues.js +22 -0
- package/bundle/shared/dist/issues.test.js +3 -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 +16 -0
- package/bundle/shared/dist/workflow.test.js +3 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
|
@@ -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");
|
|
@@ -27,13 +27,27 @@ import { classifyIssueRepo } from "../adapters/managed-repo.js";
|
|
|
27
27
|
import { getDb } from "../db/index.js";
|
|
28
28
|
import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
|
|
29
29
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
30
|
-
import { createHumanAction, findOpenHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
|
|
30
|
+
import { createHumanAction, findOpenHumanAction, findOpenHumanActionByRequestId, 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";
|
|
33
|
+
import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
|
|
32
34
|
const run = promisify(execFile);
|
|
33
35
|
/** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
|
|
34
36
|
export const GH_MERGE_TIMEOUT_MS = 20_000;
|
|
35
37
|
/** Must match projection.ts's auto_merge currentIntent — recovery keys off this string. */
|
|
36
38
|
export const AUTO_MERGE_INTENT = "Auto-merging approved PR";
|
|
39
|
+
/**
|
|
40
|
+
* NOT-314: the issue intent while an `[operator]` gate holds the merge. Named
|
|
41
|
+
* verbatim by the ticket: the PR stays unmerged in `needs_human` under this intent
|
|
42
|
+
* until the human records a result or waives the criterion.
|
|
43
|
+
*/
|
|
44
|
+
export const OPERATOR_VERIFICATION_INTENT = "Operator verification required before merge";
|
|
45
|
+
/**
|
|
46
|
+
* NOT-314 fail-closed: the stable request id for the no-head escalation, so
|
|
47
|
+
* repeat finalizes dedupe onto the one open `policy_escalation` instead of
|
|
48
|
+
* stacking a new one per tick.
|
|
49
|
+
*/
|
|
50
|
+
export const OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID = "operator-verification-blocked:no-head";
|
|
37
51
|
const ALREADY_MERGED = /already (been )?merged|pull request is not mergeable:.*merged/i;
|
|
38
52
|
/**
|
|
39
53
|
* Map issue.repo (portable identity or legacy local path) to a real filesystem cwd for
|
|
@@ -183,11 +197,55 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
183
197
|
if (issue.prNumber == null) {
|
|
184
198
|
return escalateMergeFailure(issue, instance.id, "Reviewer approved but the issue has no PR number to merge.");
|
|
185
199
|
}
|
|
200
|
+
// NOT-314 seam: this finalize is the single path from reviewer-approve to the
|
|
201
|
+
// `gh` merge — auto-merge-on routes here from applyCompletion, auto-merge-off
|
|
202
|
+
// arrives via final_review:merge / policy_escalation:retry_merge, and crash
|
|
203
|
+
// recovery re-enters through recoverStrandedAutoMerges. An `[operator]`
|
|
204
|
+
// criterion in the frozen snapshot blocks here (PR unmerged, needs_human)
|
|
205
|
+
// until a human records the result for the current head.
|
|
206
|
+
const operatorBlock = gateOperatorVerification(issue.id);
|
|
207
|
+
if (operatorBlock)
|
|
208
|
+
return operatorBlock;
|
|
186
209
|
const resolvedCwd = resolveAutoMergeCwd(issue.repo);
|
|
187
210
|
if (!resolvedCwd.ok) {
|
|
188
211
|
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
|
|
189
212
|
}
|
|
190
|
-
|
|
213
|
+
let merge = await mergePrImpl({ cwd: resolvedCwd.cwd, number: issue.prNumber });
|
|
214
|
+
// NOT-310: a not-mergeable failure is staleness Dealer can resolve itself —
|
|
215
|
+
// sync the base, re-verify, retry — instead of escalating with no way forward.
|
|
216
|
+
// Anything the sync refuses or cannot fix degrades to today's escalation.
|
|
217
|
+
// retry_merge parks into this same finalize, so it is covered automatically.
|
|
218
|
+
if (!merge.ok && isMergeConflictFailure(merge.reason) && issue.branch) {
|
|
219
|
+
const sync = await runMergeConflictSync({
|
|
220
|
+
issueId: issue.id,
|
|
221
|
+
instanceId: instance.id,
|
|
222
|
+
repo: issue.repo,
|
|
223
|
+
branch: issue.branch,
|
|
224
|
+
baseBranch: issue.baseBranch,
|
|
225
|
+
prNumber: issue.prNumber,
|
|
226
|
+
mergeReason: merge.reason,
|
|
227
|
+
mergePr: mergePrImpl,
|
|
228
|
+
});
|
|
229
|
+
if (sync.outcome === "merged") {
|
|
230
|
+
merge = { ok: true };
|
|
231
|
+
}
|
|
232
|
+
else if (sync.outcome === "repair_queued") {
|
|
233
|
+
return {
|
|
234
|
+
applied: true,
|
|
235
|
+
issueStatus: "repairing",
|
|
236
|
+
nextWorkItemId: sync.workItemId,
|
|
237
|
+
humanActionId: null,
|
|
238
|
+
instanceCompleted: false,
|
|
239
|
+
triggerReflect: false,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
else if (sync.outcome === "escalate") {
|
|
243
|
+
return escalateMergeFailure(issue, instance.id, sync.reason, sync.evidence);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
191
249
|
if (!merge.ok) {
|
|
192
250
|
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
|
|
193
251
|
}
|
|
@@ -251,7 +309,189 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
251
309
|
};
|
|
252
310
|
})();
|
|
253
311
|
}
|
|
254
|
-
|
|
312
|
+
/**
|
|
313
|
+
* NOT-314: the `[operator]` merge gate. After reviewer approve and before the
|
|
314
|
+
* `gh` merge: when the frozen snapshot holds operator criteria and no result is
|
|
315
|
+
* recorded for the current head (a `verified` artifact or a
|
|
316
|
+
* `operator_verification.waived` event — both head-pinned), the PR stays
|
|
317
|
+
* unmerged and the issue parks in `needs_human` with an `operator_verification`
|
|
318
|
+
* action listing each criterion and its command.
|
|
319
|
+
*
|
|
320
|
+
* Returns null when the merge may proceed (no operator criteria, a recorded
|
|
321
|
+
* result for this head, or no workflow to gate). Idempotent: a second finalize
|
|
322
|
+
* for the same head reuses the open action instead of raising another.
|
|
323
|
+
*
|
|
324
|
+
* Fail-closed: when operator criteria exist, every non-proceed path returns a
|
|
325
|
+
* result (never null), so a missing head SHA or an unexpected status blocks the
|
|
326
|
+
* `gh` merge instead of letting it through.
|
|
327
|
+
*/
|
|
328
|
+
export function gateOperatorVerification(issueId) {
|
|
329
|
+
return getDb().transaction(() => {
|
|
330
|
+
const current = getIssue(issueId);
|
|
331
|
+
if (!current)
|
|
332
|
+
return null;
|
|
333
|
+
const active = getActiveWorkflowInstance(issueId);
|
|
334
|
+
if (!active)
|
|
335
|
+
return null;
|
|
336
|
+
const criteria = getOperatorCriteriaForIssue(current);
|
|
337
|
+
if (criteria.length === 0)
|
|
338
|
+
return null;
|
|
339
|
+
const head = current.headSha;
|
|
340
|
+
if (head && hasOperatorVerificationForHead(issueId, head))
|
|
341
|
+
return null;
|
|
342
|
+
if (current.status === "done")
|
|
343
|
+
return null;
|
|
344
|
+
// Only the auto-merge park (or an already-gated issue) is gated — any other
|
|
345
|
+
// status is a racer or a non-parked issue; do not invent an action there,
|
|
346
|
+
// but do not let the merge through either — park the finalize as a no-op.
|
|
347
|
+
if (current.status !== "final_review" && current.status !== "needs_human") {
|
|
348
|
+
return {
|
|
349
|
+
applied: true,
|
|
350
|
+
issueStatus: current.status,
|
|
351
|
+
nextWorkItemId: null,
|
|
352
|
+
humanActionId: null,
|
|
353
|
+
instanceCompleted: false,
|
|
354
|
+
triggerReflect: false,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// No head SHA to pin the verification to (no handoff recorded one): the
|
|
358
|
+
// gate cannot name a head, so escalate instead of merging — retry_merge
|
|
359
|
+
// re-enters this same finalize once a head exists and gates normally.
|
|
360
|
+
// Repeat finalizes reuse the one open escalation (no duplicate action or
|
|
361
|
+
// event), and the transition is skipped when already parked
|
|
362
|
+
// (needs_human has no self-loop).
|
|
363
|
+
if (!head) {
|
|
364
|
+
const retry = findOpenHumanActionByRequestId(issueId, "policy_escalation", OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID);
|
|
365
|
+
if (retry) {
|
|
366
|
+
if (current.status !== "needs_human") {
|
|
367
|
+
transitionIssue(issueId, "needs_human", {
|
|
368
|
+
currentOwner: "human",
|
|
369
|
+
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
applied: true,
|
|
374
|
+
issueStatus: "needs_human",
|
|
375
|
+
nextWorkItemId: null,
|
|
376
|
+
humanActionId: retry.id,
|
|
377
|
+
instanceCompleted: false,
|
|
378
|
+
triggerReflect: false,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const reason = `Reviewer approved, but ${criteria.length} acceptance ` +
|
|
382
|
+
`${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
|
|
383
|
+
`to verify (tagged [operator] in the frozen task snapshot) and no head SHA is ` +
|
|
384
|
+
`recorded to pin the verification to. The PR stays unmerged.`;
|
|
385
|
+
if (current.status !== "needs_human") {
|
|
386
|
+
transitionIssue(issueId, "needs_human", {
|
|
387
|
+
currentOwner: "human",
|
|
388
|
+
currentIntent: reason,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const action = createHumanAction({
|
|
392
|
+
issueId,
|
|
393
|
+
workflowInstanceId: active.id,
|
|
394
|
+
actionType: "policy_escalation",
|
|
395
|
+
reason,
|
|
396
|
+
question: `${reason} Retry the merge once a head is recorded, queue another repair round, or close the issue?`,
|
|
397
|
+
evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true, operatorVerificationBlocked: true },
|
|
398
|
+
responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
|
|
399
|
+
requestId: OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID,
|
|
400
|
+
});
|
|
401
|
+
appendWorkflowEvent({
|
|
402
|
+
issueId,
|
|
403
|
+
workflowInstanceId: active.id,
|
|
404
|
+
workerSessionId: null,
|
|
405
|
+
type: "human_action.requested",
|
|
406
|
+
actorType: "system",
|
|
407
|
+
stage: "needs_human",
|
|
408
|
+
round: current.currentRound,
|
|
409
|
+
payload: { actionType: "policy_escalation", actionId: action.id, operatorVerificationBlocked: true },
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
applied: true,
|
|
413
|
+
issueStatus: "needs_human",
|
|
414
|
+
nextWorkItemId: null,
|
|
415
|
+
humanActionId: action.id,
|
|
416
|
+
instanceCompleted: false,
|
|
417
|
+
triggerReflect: false,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const existing = findOpenHumanAction(issueId, "operator_verification");
|
|
421
|
+
if (existing && operatorActionHeadSha(existing) === head) {
|
|
422
|
+
if (current.status !== "needs_human") {
|
|
423
|
+
transitionIssue(issueId, "needs_human", {
|
|
424
|
+
currentOwner: "human",
|
|
425
|
+
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
applied: true,
|
|
430
|
+
issueStatus: "needs_human",
|
|
431
|
+
nextWorkItemId: null,
|
|
432
|
+
humanActionId: existing.id,
|
|
433
|
+
instanceCompleted: false,
|
|
434
|
+
triggerReflect: false,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const shortHead = head.slice(0, 8);
|
|
438
|
+
const reason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
|
|
439
|
+
`${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
|
|
440
|
+
`to verify (tagged [operator] in the frozen task snapshot) and no result is recorded ` +
|
|
441
|
+
`for this head. The PR stays unmerged until the result is recorded:\n` +
|
|
442
|
+
formatOperatorCriteria(criteria).join("\n");
|
|
443
|
+
transitionIssue(issueId, "needs_human", {
|
|
444
|
+
currentOwner: "human",
|
|
445
|
+
currentIntent: OPERATOR_VERIFICATION_INTENT,
|
|
446
|
+
});
|
|
447
|
+
// Head-pinned request_id: a concurrent finalize for the same head dedupes
|
|
448
|
+
// onto this action through createHumanAction's open-request conflict path;
|
|
449
|
+
// new commits after the gate get a fresh action with their own commands.
|
|
450
|
+
const action = createHumanAction({
|
|
451
|
+
issueId,
|
|
452
|
+
workflowInstanceId: active.id,
|
|
453
|
+
actionType: "operator_verification",
|
|
454
|
+
reason,
|
|
455
|
+
question: `${reason}\n\nPaste the probe output to record verification, waive with a reason, ` +
|
|
456
|
+
`or send the work back for another repair round?`,
|
|
457
|
+
evidence: { operatorVerification: { criteria, headSha: head } },
|
|
458
|
+
responseOptions: [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS],
|
|
459
|
+
requestId: operatorVerificationRequestId(head),
|
|
460
|
+
});
|
|
461
|
+
appendWorkflowEvent({
|
|
462
|
+
issueId,
|
|
463
|
+
workflowInstanceId: active.id,
|
|
464
|
+
workerSessionId: null,
|
|
465
|
+
type: "human_action.requested",
|
|
466
|
+
actorType: "system",
|
|
467
|
+
stage: "needs_human",
|
|
468
|
+
round: current.currentRound,
|
|
469
|
+
payload: { actionType: "operator_verification", actionId: action.id, headSha: head },
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
applied: true,
|
|
473
|
+
issueStatus: "needs_human",
|
|
474
|
+
nextWorkItemId: null,
|
|
475
|
+
humanActionId: action.id,
|
|
476
|
+
instanceCompleted: false,
|
|
477
|
+
triggerReflect: false,
|
|
478
|
+
};
|
|
479
|
+
})();
|
|
480
|
+
}
|
|
481
|
+
/** The head SHA an `operator_verification` action gates (null when unreadable). */
|
|
482
|
+
function operatorActionHeadSha(action) {
|
|
483
|
+
if (!action.evidenceJson)
|
|
484
|
+
return null;
|
|
485
|
+
try {
|
|
486
|
+
const evidence = JSON.parse(action.evidenceJson);
|
|
487
|
+
const headSha = evidence.operatorVerification?.headSha;
|
|
488
|
+
return typeof headSha === "string" && headSha ? headSha : null;
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function escalateMergeFailure(issue, workflowInstanceId, reason, evidence = { [MERGE_FAILURE_EVIDENCE_KEY]: true }) {
|
|
255
495
|
return getDb().transaction(() => {
|
|
256
496
|
const current = getIssue(issue.id);
|
|
257
497
|
// Concurrent success already completed — never open a dangling escalation on done.
|
|
@@ -304,7 +544,7 @@ function escalateMergeFailure(issue, workflowInstanceId, reason) {
|
|
|
304
544
|
actionType: "policy_escalation",
|
|
305
545
|
reason,
|
|
306
546
|
question: `${reason} Retry the merge, queue another repair round, or close the issue?`,
|
|
307
|
-
evidence
|
|
547
|
+
evidence,
|
|
308
548
|
responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
|
|
309
549
|
});
|
|
310
550
|
appendWorkflowEvent({
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// packages/server/src/coordinator/checks-wait-config.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-311: how long one work item may wait on CI before the wait itself escalates.
|
|
4
|
+
// The per-poll bound (CHECKS_POLL_TIMEOUT_MS, default 10 min) only ends a single
|
|
5
|
+
// poll with a `checks_pending` deferral; this ceiling bounds the whole wait measured
|
|
6
|
+
// from the first poll, across deferrals. Past it the item stops re-queueing and a
|
|
7
|
+
// human is asked to look at the stuck CI run instead of waiting forever.
|
|
8
|
+
/** Overall CI-wait ceiling measured from the first checks poll (default 2 h). */
|
|
9
|
+
export function checksWaitCeilingMs() {
|
|
10
|
+
const parsed = Number(process.env.CHECKS_WAIT_CEILING_MS ?? 2 * 60 * 60_000);
|
|
11
|
+
return Number.isFinite(parsed) ? parsed : 2 * 60 * 60_000;
|
|
12
|
+
}
|
|
13
|
+
/** True once `nowMs` is at or past the ceiling after the first wait started. */
|
|
14
|
+
export function checksWaitCeilingExceeded(firstWaitStartedAt, nowMs = Date.now()) {
|
|
15
|
+
const start = Date.parse(firstWaitStartedAt);
|
|
16
|
+
if (!Number.isFinite(start))
|
|
17
|
+
return false;
|
|
18
|
+
return nowMs - start >= checksWaitCeilingMs();
|
|
19
|
+
}
|