forgedock 1.35.0 → 1.37.0

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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -37,6 +37,8 @@
37
37
  * issue while maintenance starts stay within `share` of all lane starts (`dispatch: {started, maint}`, persisted), then
38
38
  * P2+ features, then (`whenIdle`) maintenance once no feature is pending. Maintenance the budget never admits ends `held`.
39
39
  *
40
+ * Milestone lifecycle (#4657), off unless `lifecycle` is passed (`bin/lean/lifecycle.mjs` makeLifecycle): at batch end
41
+ * `lifecycle.onBatchEnd(titles)` advances/closes each milestone a `merged` row's result named. Errors never change an outcome.
40
42
  * Milestone drift (#4161), off unless `drift` is passed (`bin/lean/drift.mjs` makeDrift): a `merged` milestone-lane run
41
43
  * whose result carries `structure` awaits `drift.onMerged(issue, row, result)` before its slot frees. A drift error is
42
44
  * reported as a `drift` row and never changes the outcome. With `drift.holds(issue, {body, lane})` (block_dispatch), a
@@ -100,10 +102,12 @@
100
102
  */
101
103
  import { parseDependsOn } from "../engine/phases.mjs";
102
104
  import { classifyNeedsHuman, recoveryCodeOf, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
105
+ import { detectGateDeadlock, formatGateDeadlock } from "./gatewatch.mjs";
103
106
  import { recoveryEvidence } from "./render.mjs";
104
107
  import { renderAlerts } from "../stats/render.mjs";
105
108
  import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
106
109
  import { laneSize, SIZES } from "./budget.mjs";
110
+ import { requeueKey, REQUEUABLE } from "./batch.mjs";
107
111
 
108
112
  // A dependency that finished like this no longer blocks its dependents.
109
113
  const SATISFIED = new Set(["merged", "invalid"]);
@@ -234,10 +238,10 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
234
238
  * @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
235
239
  */
236
240
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
237
- store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
238
- classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
239
- recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, stopLane = null,
240
- estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS }) {
241
+ store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
242
+ classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
243
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, getGates = null, stopLane = null,
244
+ estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null }) {
241
245
  const saved = store?.load?.() ?? null;
242
246
  const order = saved ? [...saved.order] : [];
243
247
  const set = new Set(order);
@@ -272,7 +276,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
272
276
  const parkedBase = new Map(); // #4525: issue -> base sha its parked PR was last synced (or reported conflicting) against
273
277
  let lastParkedSync = null;
274
278
  const bodies = new Map(); // issue -> body, kept in memory for drift.holds only
279
+ const lifecycleTitles = new Set(); // #4657: milestones this batch merged into, re-evaluated at batch end
275
280
  const driftHeld = new Map(); // issue -> flagged package dir holding it this pass
281
+ const integHeld = new Map(); // issue -> reason (red integration streak on its milestone lane, #4667) holding it this pass
276
282
  const reg = sched?.registry ?? null;
277
283
  const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
278
284
  const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
@@ -301,6 +307,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
301
307
  const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
302
308
  const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
303
309
  const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
310
+ const requeueApplied = new Set(saved?.requeueApplied ?? []); // #4550: `--requeue` entries already applied (batch `requeue` file)
311
+ const requeuedInfo = toMap(saved?.requeued); // #4550: issue -> {reason, by, at, prior: [earlier rows]}
312
+ const everRelabelled = new Set(saved?.relabelled ?? []); // #4656: issues the runner already relabelled; never parked on top
304
313
  const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
305
314
  // #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
306
315
  const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
@@ -314,6 +323,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
314
323
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
315
324
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
316
325
  ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
326
+ ...(requeueApplied.size ? { requeueApplied: [...requeueApplied], requeued: toObj(requeuedInfo) } : {}),
317
327
  ...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
318
328
  ...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
319
329
  };
@@ -489,14 +499,23 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
489
499
  return true;
490
500
  };
491
501
 
492
- const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
502
+ // #4687: a dependency skip is a waiting state while a blocker re-check can still re-admit it (`waiting`, cleared when the batch ends);
503
+ // the log row names the triggering issue.
504
+ const skip = (n, detail, trigger = null) => {
505
+ results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail, ...(trigger !== null && dispatchCheck ? { waiting: true } : {}) });
506
+ update({ ...results.get(n), ...(trigger !== null ? { trigger } : {}) });
507
+ };
493
508
  const requeue = (n, delayMs) => { pending.push(n); enqueued.set(n, now()); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
494
- const noteEngineError = () => {
509
+ const TRANSPORT_TEXT = /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|HTTP 50[234]/i;
510
+ const TRANSPORT_COOLDOWN_MS = 5 * 60000;
511
+ let transportErrors = 0; // #4685: engine-errors in the current window that were link failures
512
+ const noteEngineError = (detail = "") => {
495
513
  if (!breaker) return;
496
514
  const t = now(), windowMs = breaker.window_min * 60000;
497
515
  errorTimes = [...errorTimes.filter((x) => t - x < windowMs), t];
516
+ transportErrors = TRANSPORT_TEXT.test(String(detail)) ? Math.min(transportErrors + 1, errorTimes.length) : 0;
498
517
  if (!tripped && errorTimes.length >= breaker.errors) {
499
- tripped = { at: t, detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
518
+ tripped = { at: t, ...(transportErrors >= errorTimes.length ? { transport: true } : {}), detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
500
519
  update({ issue: null, outcome: "breaker", detail: tripped.detail });
501
520
  }
502
521
  };
@@ -540,11 +559,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
540
559
  }
541
560
  if (parked !== undefined) {
542
561
  done();
543
- const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human` };
562
+ const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human`, ...(dispatchCheck ? { waiting: true } : {}) };
544
563
  results.set(n, final); save();
545
564
  decided({ issue: n, what: `skipped: dependency #${parked} needs-human (not pulled into the batch)`, why: firstLine(row.detail) || "the lane reported an open dependency",
546
565
  alternative: `pull #${parked} in and run it again` });
547
- update(final);
566
+ update({ ...final, trigger: parked });
548
567
  return;
549
568
  }
550
569
  }
@@ -584,7 +603,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
584
603
  row.detail = `gave up after ${deferrals.get(n) ?? 0} deferrals: ${r?.detail ?? r?.code ?? ""}`;
585
604
  }
586
605
  if (row.outcome === "engine-error") {
587
- noteEngineError();
606
+ noteEngineError(r?.detail);
588
607
  if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
589
608
  engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
590
609
  requeue(n, 0); done(); save();
@@ -644,7 +663,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
644
663
  if (!prior && row.outcome === "budget-exceeded" && typeof relabelNeedsHuman === "function") {
645
664
  try { await relabelNeedsHuman({ issue: n, evidence: `Implement turn budget overrun (\`budget.exceeded\`) not recovered in this batch:\n${String(row.detail ?? "")}` }); } catch { /* best effort */ }
646
665
  }
666
+ // #4656: a mechanical terminal park (recovery spent or none applies) moves to the remediation queue, not the human queue.
667
+ if (row.outcome === "needs-human" && !row.parkedQueued && !everRelabelled.has(n) && typeof relabelNeedsHuman === "function" && (cls => cls === "parked" || (cls === "recoverable" && prior))(classifyNeedsHuman({ ...row, issueEdited: r?.issueEdited === true }, { parked: true }))) { // a recoverable row with a finished recovery has used its attempts
668
+ try { const res = await relabelNeedsHuman({ issue: n, parked: { code: row.code, pr: Number.isInteger(row.pr) ? row.pr : null }, evidence: `Mechanical park \`${row.code}\`${Number.isInteger(row.pr) ? ` (PR #${row.pr})` : ""}:\n${String(row.detail ?? "")}` }); if (res !== false && res !== "failed" && res !== "skipped") row.parkedQueued = true; } catch { /* best effort */ }
669
+ }
647
670
  results.set(n, row);
671
+ if (row.outcome === "merged" && lifecycle && r?.milestone) lifecycleTitles.add(String(r.milestone)); // #4657
672
+ if (row.outcome === "merged" && integration?.onMerged && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
673
+ // #4667: counts the merge toward the milestone's integration run; an error is a row, never the outcome.
674
+ try { await integration.onMerged(n, { ...row, lane: rec?.lane ?? r?.lane }, r); } catch (e) { update({ issue: n, outcome: "integration", detail: `integration check failed: ${String(e?.message ?? e).slice(0, 300)}` }); }
675
+ }
648
676
  if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
649
677
  save(); update(row);
650
678
  // Before the slot frees: the batch never ends with a fold pending, and block_dispatch sees the new flags.
@@ -742,7 +770,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
742
770
  }
743
771
  save();
744
772
 
745
- const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
773
+ const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (integHeld.has(n) ? `integration: ${integHeld.get(n)}` : (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run"));
746
774
  // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
747
775
  // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
748
776
  // #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
@@ -755,7 +783,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
755
783
  queued: q.attempt, cause: why, recoveryMax });
756
784
  let res;
757
785
  try { res = await relabelNeedsHuman({ issue: n, evidence }); } catch { res = "failed"; }
758
- if (res !== false && res !== "failed") relabelled.add(n); // a failed label add is retried by a resume
786
+ if (res !== false && res !== "failed") { relabelled.add(n); everRelabelled.add(n); } // a failed label add is retried by a resume
759
787
  }
760
788
  save();
761
789
  })());
@@ -782,10 +810,47 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
782
810
  update({ issue: null, outcome: "added", detail: list });
783
811
  return fresh.length;
784
812
  };
785
- // #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result.
813
+ // #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
814
+ // return to pending, the prior rows kept in history; edges stay, so a dependent still waits for the issue to merge.
815
+ const drainRequeues = async () => {
816
+ if (!getRequeued) return 0;
817
+ let got = [];
818
+ try { got = getRequeued() ?? []; } catch { got = []; }
819
+ const fresh = (Array.isArray(got) ? got : []).filter((e) => Number.isSafeInteger(e?.issue) && !requeueApplied.has(requeueKey(e)));
820
+ let n = 0;
821
+ for (const e of fresh) {
822
+ requeueApplied.add(requeueKey(e));
823
+ const row = results.get(e.issue);
824
+ if (!row || !REQUEUABLE(row.outcome) || running.has(e.issue)) continue;
825
+ const moved = [e.issue], priors = new Map([[e.issue, row]]);
826
+ results.delete(e.issue);
827
+ for (let i = 0; i < moved.length; i++) { // dependents skipped behind a requeued issue (fixpoint)
828
+ for (const [m, r] of [...results]) {
829
+ if (r?.outcome !== "skipped" || Number(/^dependency #(\d+)/.exec(r.detail ?? "")?.[1]) !== moved[i]) continue;
830
+ priors.set(m, r); results.delete(m); moved.push(m);
831
+ }
832
+ }
833
+ for (const m of moved) {
834
+ for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
835
+ relabelled.delete(m);
836
+ if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
837
+ if (!pending.includes(m)) requeue(m, 0);
838
+ }
839
+ save();
840
+ const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
841
+ decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
842
+ why: e.reason || "operator decision", alternative: "start a second batch" });
843
+ update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
844
+ for (const m of moved) if (m !== e.issue) update({ issue: m, outcome: "requeued", detail: `dependency #${e.issue} requeued`, trigger: e.issue }); // #4687
845
+ n++;
846
+ }
847
+ if (fresh.length) save();
848
+ return n;
849
+ };
850
+ // #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result unless the cancel carries `stopRemote` (#4612).
786
851
  const stopSent = new Set();
787
852
  function stop(n, rec) {
788
- if (!stopLane || rec?.remote || stopSent.has(n)) return;
853
+ if (!stopLane || (rec?.remote && cancelled?.stopRemote !== true) || stopSent.has(n)) return; // #4612: remote lanes only when the cancel asked for it
789
854
  stopSent.add(n);
790
855
  try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
791
856
  }
@@ -807,6 +872,22 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
807
872
  update({ issue: null, outcome: "release", detail: fmt(freed) });
808
873
  }
809
874
  };
875
+ // #4656: a gate (hold list, settle, ship pre-flight) waiting on an issue the scheduler will not admit: one decision and row per (gate, issue).
876
+ const gateSeen = new Map(), gateReported = new Set();
877
+ const checkGates = () => {
878
+ const gates = [];
879
+ if (opHeld.size) gates.push({ name: "hold", waitsOn: [...opHeld] });
880
+ if (getGates) { try { gates.push(...(getGates() ?? [])); } catch { /* best effort */ } }
881
+ if (!gates.length) return;
882
+ const unadmittable = (n) => { const x = results.get(n); return !(x && (x.outcome === "needs-human" || x.parkedQueued)); };
883
+ for (const d of detectGateDeadlock({ gates, admissible: unadmittable, now: now(), firstSeen: gateSeen })) {
884
+ const key = `${d.gate}#${d.issue}`;
885
+ if (gateReported.has(key)) continue;
886
+ gateReported.add(key);
887
+ decided({ issue: d.issue, what: formatGateDeadlock(d), why: "the gate waits on a parked issue the scheduler will not admit", alternative: "/work-on:remediate the parked issue or release the gate" });
888
+ update({ issue: d.issue, outcome: "GATE_DEADLOCK", detail: formatGateDeadlock(d) });
889
+ }
890
+ };
810
891
  const checkCancel = () => {
811
892
  if (!getCancelled) return;
812
893
  let c = null;
@@ -838,6 +919,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
838
919
  // #4595: a skipped row whose blocker (`dependency #B ...`) was finished outside the lane is re-admitted: B is re-checked
839
920
  // through `dispatchCheck` (at most every WATCH_EVERY_MS, and once before the batch would end); fail-open.
840
921
  let lastWatch = null;
922
+ const settled = new Set(); // #4687: blockers whose close is final (not planned): their dependents stay skipped, no more re-checks
841
923
  const readmitted = new Set(); // "n:b": re-admit n on blocker b once; a lane that re-skips on b stays skipped
842
924
  const blockerOf = (n) => { const r = results.get(n); const m = r?.outcome === "skipped" ? /^dependency #(\d+) /.exec(r.detail ?? "") : null; return m ? Number(m[1]) : null; };
843
925
  const watchable = () => !!dispatchCheck && [...results.keys()].some((n) => blockerOf(n) !== null);
@@ -847,10 +929,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
847
929
  if (!final && lastWatch !== null && t0 - lastWatch < WATCH_EVERY_MS) return;
848
930
  lastWatch = t0;
849
931
  const resolved = new Set();
850
- const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null));
932
+ const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null && !settled.has(b)));
851
933
  for (const b of roots) {
852
934
  let g = null;
853
935
  try { g = (await dispatchCheck(b)) ?? null; } catch { g = null; }
936
+ if (g?.final) { // #4687: closed as not planned: terminal for its dependents
937
+ settled.add(b);
938
+ for (const n of results.keys()) {
939
+ const r = results.get(n);
940
+ if (blockerOf(n) === b && r.waiting) { const { waiting, ...rest } = r; results.set(n, rest); update({ ...rest, trigger: b }); }
941
+ }
942
+ continue;
943
+ }
854
944
  if (!g || !SATISFIED.has(g.outcome)) continue;
855
945
  resolved.add(b);
856
946
  const row = results.get(b);
@@ -869,6 +959,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
869
959
  readmitted.add(`${n}:${b}`);
870
960
  results.delete(n);
871
961
  pending.push(n);
962
+ update({ issue: n, outcome: "readmitted", detail: `dependency #${b} merged or closed`, trigger: b }); // #4687
872
963
  decided({ issue: n, what: `re-admitted: dependency #${b} merged or closed after this issue was skipped`, why: "the blocker was finished outside this batch lane",
873
964
  alternative: "leave it skipped until the next batch" });
874
965
  }
@@ -879,6 +970,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
879
970
  for (;;) {
880
971
  if (signal?.aborted) throw halt();
881
972
  await drainAdds();
973
+ await drainRequeues();
882
974
  checkCancel();
883
975
  await watchBlockers(!pending.length && !running.size);
884
976
  if (!pending.length && !running.size) break;
@@ -889,18 +981,25 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
889
981
  const n = pending[i];
890
982
  if (results.has(n)) { pending.splice(i, 1); continue; }
891
983
  const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
892
- if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); again = true; }
984
+ if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`, bad); again = true; }
893
985
  }
894
986
  }
895
987
  const t = now();
896
988
  const holdUntil = heldUntil();
897
989
  let started = 0;
898
990
  driftHeld.clear();
991
+ integHeld.clear();
899
992
  opHeldHit.clear();
900
993
  refreshHeld();
994
+ checkGates();
901
995
  const isReady = (n) => {
902
996
  if (opHeld.has(n)) { opHeldHit.add(n); return false; }
903
997
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
998
+ if (integration?.holds) {
999
+ let why = null;
1000
+ try { why = integration.holds(n, { lane: lanes.get(n)?.lane ?? null }); } catch { why = null; }
1001
+ if (why) { integHeld.set(n, why); return false; }
1002
+ }
904
1003
  if (!drift?.holds) return true;
905
1004
  let pkg = null;
906
1005
  try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
@@ -946,6 +1045,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
946
1045
  }
947
1046
  }
948
1047
  if (reg) withdrawExcept(wanted);
1048
+ // #4685: a breaker tripped only by link failures (a router restart) closes itself after a cooldown
1049
+ if (tripped?.transport && now() - tripped.at >= TRANSPORT_COOLDOWN_MS) { tripped = null; errorTimes = []; transportErrors = 0; save(); }
949
1050
  if (tripped && !running.size) { await drainAdds(); break; } // breaker: let running lanes finish, keep the rest pending for a resume
950
1051
  if (cancelled && !running.size) { await drainAdds(); break; } // #4376: cancel: the rest end held for a resume
951
1052
  if (budgetStop && !running.size) { await drainAdds(); break; } // execution budget (stop): the same, the rest end held
@@ -970,8 +1071,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
970
1071
  if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
971
1072
  if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
972
1073
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1074
+ if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
973
1075
  if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
974
- if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
975
1076
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
976
1077
  if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
977
1078
  if (tripped) wake = null;
@@ -984,7 +1085,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
984
1085
  else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
985
1086
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
986
1087
  else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
987
- else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
1088
+ else if (pending.length && (driftHeld.size || integHeld.size)) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch) or a red integration streak (#4667): pending for a later batch
988
1089
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
989
1090
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
990
1091
  save();
@@ -997,8 +1098,14 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
997
1098
  }
998
1099
  if (reg) withdrawExcept(new Set());
999
1100
  await relabelQueued();
1101
+ // #4657: a milestone whose `close_on` state is reached closes here; a failure is a row, never an outcome change.
1102
+ if (lifecycle?.onBatchEnd && lifecycleTitles.size && !halted) {
1103
+ try { await lifecycle.onBatchEnd([...lifecycleTitles]); } catch (e) { onUpdate({ issue: 0, outcome: "lifecycle", detail: `lifecycle check failed: ${String(e?.message ?? e).slice(0, 300)}` }); }
1104
+ }
1105
+ for (const [n, r] of results) if (r?.waiting) { const { waiting, ...rest } = r; results.set(n, rest); } // #4687: the batch ended: skipped for good
1000
1106
  save();
1001
- return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
1107
+ const withRequeue = (r) => (r && requeuedInfo.has(r.issue) ? { ...r, requeued: { reason: requeuedInfo.get(r.issue).reason, by: requeuedInfo.get(r.issue).by, prior: requeuedInfo.get(r.issue).prior.map((p) => ({ outcome: p.outcome, detail: p.detail ?? null, pr: p.pr ?? null })) } } : r);
1108
+ return order.map((n) => withRequeue(results.get(n)) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
1002
1109
  ...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
1003
1110
  ...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
1004
1111
  }
@@ -1027,14 +1134,19 @@ export const OUTCOME_LINE_MAX = 200;
1027
1134
  export function formatOutcomeLists(rows) {
1028
1135
  const out = [];
1029
1136
  const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
1137
+ const requeued = (rows ?? []).filter((r) => r?.requeued); // #4550: operator --requeue after a decision; the row is the latest outcome
1138
+ if (requeued.length) out.push(`requeued: ${requeued.map((r) => `#${r.issue} (${one(r.requeued.reason)})`).join(" ")}`);
1030
1139
  const rec = (rows ?? []).filter((r) => r?.recovered);
1031
1140
  if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
1032
1141
  for (const r of rows ?? []) {
1033
1142
  if (!r || r.recovered) continue;
1034
1143
  const human = r.outcome === "needs-human" && classifyNeedsHuman(r) === "human";
1144
+ if (r.parkedQueued && r.outcome === "needs-human") continue; // #4656: listed on the remediation line below, not as unrecovered
1035
1145
  if (!human && (r.outcome === "needs-human" || r.outcome === "budget-exceeded" || r.recovery)) out.push(`unrecovered: #${r.issue} (${r.code ?? r.recovery?.code ?? "unclassified"}: ${one(r.recovery ? `recovery ${r.recovery.code} ended ${r.recovery.outcome}: ${r.recovery.detail}` : r.detail)})`);
1036
1146
  }
1037
1147
  const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
1148
+ const rq = (rows ?? []).filter((r) => r?.outcome === "needs-human" && r.parkedQueued); // #4656: parked past recovery: the single remediation queue, not escalation
1149
+ if (rq.length) out.push(`remediation queue (/work-on:remediate): ${rq.map((r) => `#${r.issue} (parked:${r.code}${r.pr ? ` PR #${r.pr}` : ""})`).join(" ")}`);
1038
1150
  const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
1039
1151
  if (parked) out.push(parked);
1040
1152
  if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
@@ -1045,7 +1157,7 @@ export function formatTable(rows) {
1045
1157
  // #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
1046
1158
  const sbx = rows.some((r) => r?.sandbox);
1047
1159
  const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
1048
- const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
1160
+ const body = rows.map((r) => [`#${r.issue}`, (r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome) + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
1049
1161
  ...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
1050
1162
  const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
1051
1163
  const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
@@ -0,0 +1,45 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Parked post-deploy follow-up sweep (#4689). A follow-up parked by the #4389 gate (`awaiting-promotion`, `awaiting-due`,
5
+ * `awaiting-measurement`) is re-evaluated on the daemon timer, next to `sweepDue`, instead of waiting for the next batch:
6
+ * `prefilterIssue` with `gateOnly` re-runs the same deterministic gate (git ancestry, due date, soak window, precondition,
7
+ * named checks; no model call, no lane). A released issue loses its wait labels and is picked up by the next scheduler pass;
8
+ * one that is still waiting is left as it is.
9
+ */
10
+ import { prefilterIssue, lazyDefaultBranch, AWAITING_PROMOTION_LABEL, AWAITING_DUE_LABEL } from "./prefilter.mjs";
11
+ import { AWAITING_MEASUREMENT_LABEL } from "./postdeploy-check.mjs";
12
+
13
+ export const PARKED_LABELS = [AWAITING_PROMOTION_LABEL, AWAITING_DUE_LABEL, AWAITING_MEASUREMENT_LABEL];
14
+
15
+ /**
16
+ * Re-evaluate every parked follow-up of the repo. Sequential; an error is an `error` row, never a throw.
17
+ * Outcomes: released | closed | still-parked | skipped | error.
18
+ */
19
+ export async function sweepParked({ repo, cwd = process.cwd(), io, defaultBranch = null, preconditionCmd = null, minAge = null, dryRun = false, onResult = null } = {}) {
20
+ const found = new Set();
21
+ for (const label of PARKED_LABELS) {
22
+ try {
23
+ const list = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", label, "--limit", "100", "--json", "number"])));
24
+ for (const i of Array.isArray(list) ? list : []) if (Number.isInteger(i?.number)) found.add(i.number);
25
+ } catch { /* one unreadable label list: the others still run */ }
26
+ }
27
+ const resolve = defaultBranch ?? lazyDefaultBranch({ io, repo, cwd });
28
+ const rows = [];
29
+ for (const issue of [...found].sort((a, b) => a - b)) {
30
+ let row;
31
+ try {
32
+ const r = await prefilterIssue({ issue, repo, cwd, io, dryRun, defaultBranch: resolve, preconditionCmd, minAge, gateOnly: true });
33
+ row = { issue, outcome: /^pre-filter error/.test(r.detail) ? "error" : r.closed ? "closed" : r.parked ? "still-parked" : /^released/.test(r.detail) ? "released" : "skipped", detail: r.detail, kind: r.kind ?? null };
34
+ } catch (e) { row = { issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) }; }
35
+ rows.push(row);
36
+ try { if (!dryRun) await onResult?.(row); } catch { /* an event sink never fails the sweep */ }
37
+ }
38
+ return rows;
39
+ }
40
+
41
+ /** One line, e.g. `parked: released #4 #5; closed #6`; "" when nothing changed. */
42
+ export function formatParkedSweepSummary(rows) {
43
+ const parts = [["released", "released"], ["closed", "closed"], ["error", "error"]].map(([k, l]) => [l, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)]).filter(([, a]) => a.length).map(([l, a]) => `${l} ${a.join(" ")}`);
44
+ return parts.length ? `parked: ${parts.join("; ")}` : "";
45
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { sweepParked, formatParkedSweepSummary } from "./parked-sweep.mjs";
6
+
7
+ const MERGE = "a".repeat(40);
8
+ const item = (extra = "") => `- [ ] \`R2: the window shows a row\` — post-deploy: [check:smoke] ${extra} after promotion`;
9
+ const bodyOf = (extra) => `## AC\n\n${item(extra)}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
10
+ const CHECKS = JSON.stringify({ smoke: "echo ok" });
11
+
12
+ /** Fake repo: `parked` maps issue -> {labels, body}; `promoted` says whether the parent's merge SHA is on origin/main. */
13
+ function world({ parked, promoted, now = Date.parse("2026-11-04T00:00:00Z") }) {
14
+ const w = { edits: [], closed: [], comments: [], commits: [] };
15
+ w.io = {
16
+ now: () => now,
17
+ gh: async (a) => {
18
+ const k = a.slice(0, 2).join(" ");
19
+ if (k === "issue list") { const l = a[a.indexOf("--label") + 1]; return JSON.stringify(Object.entries(parked).filter(([, v]) => v.labels.includes(l)).map(([n]) => ({ number: Number(n) }))); }
20
+ if (k === "issue view") { const n = Number(a[2]); return JSON.stringify({ number: n, state: "OPEN", title: "", comments: [], ...parked[n], labels: parked[n].labels.map((name) => ({ name })) }); }
21
+ if (k === "pr list") return JSON.stringify([{ number: 90, title: "fix (#40)", body: "", mergeCommit: { oid: MERGE } }]);
22
+ if (k === "issue edit") { w.edits.push(a); return ""; }
23
+ if (k === "issue comment") { w.comments.push(a); return ""; }
24
+ if (k === "issue close") { w.closed.push(Number(a[2])); return ""; }
25
+ throw new Error(`unexpected gh ${a.join(" ")}`);
26
+ },
27
+ git: async (a) => {
28
+ if (a[0] === "fetch") return "";
29
+ if (a[0] === "rev-parse") return "deadbeef\n";
30
+ if (a[0] === "show") return CHECKS;
31
+ if (a[0] === "merge-base") { if (promoted) return ""; throw Object.assign(new Error("exit 1"), { code: 1 }); }
32
+ throw new Error(`unexpected git ${a.join(" ")}`);
33
+ },
34
+ spawnCheck: async () => ({ code: 0, stdout: "ok", stderr: "" }),
35
+ };
36
+ return w;
37
+ }
38
+ const sweep = (w, extra = {}) => sweepParked({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main", ...extra });
39
+
40
+ describe("parked follow-up sweep (#4689)", () => {
41
+ it("one pass after the parent's commit reaches the default branch removes awaiting-promotion and measures; before it nothing changes", async () => {
42
+ const parked = { 41: { labels: ["awaiting-promotion"], body: bodyOf() } };
43
+ const before = world({ parked, promoted: false });
44
+ const rows0 = await sweep(before);
45
+ assert.deepEqual(rows0.map((r) => [r.issue, r.outcome, r.kind]), [[41, "still-parked", "awaiting-promotion"]]);
46
+ assert.deepEqual(before.closed, []);
47
+ const after = world({ parked, promoted: true });
48
+ const rows = await sweep(after, { dryRun: false });
49
+ assert.ok(after.edits.some((a) => a.includes("--remove-label") && a.includes("awaiting-promotion")), "label removed");
50
+ assert.equal(rows[0].issue, 41); assert.notEqual(rows[0].outcome, "still-parked");
51
+ assert.equal(formatParkedSweepSummary([{ issue: 41, outcome: "released" }, { issue: 5, outcome: "error" }]), "parked: released #41; error #5");
52
+ });
53
+
54
+ it("a due date in the past is released, a future one stays parked", async () => {
55
+ const parked = { 50: { labels: ["awaiting-due", "due:2026-11-03"], body: bodyOf("due:2026-11-03") }, 51: { labels: ["awaiting-due"], body: bodyOf("due:2026-12-01") } };
56
+ const w = world({ parked, promoted: true });
57
+ const rows = await sweep(w);
58
+ assert.equal(rows.find((r) => r.issue === 51).outcome, "still-parked");
59
+ assert.notEqual(rows.find((r) => r.issue === 50).outcome, "still-parked");
60
+ assert.ok(w.edits.some((a) => a[2] === "50" && a.includes("--remove-label") && /awaiting-due,due:2026-11-03/.test(a.join(" "))));
61
+ });
62
+
63
+ it("an unreadable issue is an error row, never a throw; a dry run changes nothing", async () => {
64
+ const w = world({ parked: { 60: { labels: ["awaiting-promotion"], body: bodyOf() } }, promoted: true });
65
+ const g = w.io.gh; w.io.gh = async (a) => { if (a[1] === "view") throw new Error("boom"); return g(a); };
66
+ assert.equal((await sweep(w))[0].outcome, "error");
67
+ const w2 = world({ parked: { 61: { labels: ["awaiting-promotion"], body: bodyOf() } }, promoted: true });
68
+ await sweep(w2, { dryRun: true });
69
+ assert.deepEqual(w2.edits, []); assert.deepEqual(w2.closed, []);
70
+ });
71
+ });