forgedock 1.30.0 → 1.31.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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -4,9 +4,12 @@
4
4
  * Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `Depends on` / `Blocked by`
5
5
  * among the given issues (wait for the dependency to merge; skip when it ends anywhere but merged/invalid).
6
6
  * A `dependency-open` run is requeued behind its dependencies, pulling an out-of-set one into the batch; a
7
- * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124).
7
+ * `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124). A dependency that is parked
8
+ * (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
9
+ * the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
8
10
  * No investigation, synthesis or agent spawning. `runOne(issue)` and `readBody(issue)` are injected.
9
11
  * Optional `prefilter(issue, lane)` (#4123) runs in the lane slot before `runOne`: when it closed the issue, no lane starts.
12
+ * When it parked the issue (#4389, `parked: true`), no lane starts either and the row ends with the parked kind.
10
13
  * Optional `resolveLane(issue)` (#4125) works out each issue's own lane (milestone branch or staging): an issue whose
11
14
  * lane is refused ends `lane-refused` without a run, and a dependency edge between two lanes goes to `onCrossLane`.
12
15
  *
@@ -15,9 +18,13 @@
15
18
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
16
19
  * limit holds admissions for that account until its reset, and non-host deferrals stop after `maxDeferrals`.
17
20
  * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
18
- * host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. An `engine-error` is
21
+ * host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
22
+ * batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
19
23
  * relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
20
24
  * rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
25
+ * `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
26
+ * join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
27
+ * batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
21
28
  *
22
29
  * Maintenance budget (#4197), off unless `maintenance: {share, whenIdle}` is passed: `classify(issue)` gives each issue
23
30
  * `{maintenance, priority}` (persisted as `classes`) and admission follows `pickNext`: P0/P1 features, then a maintenance
@@ -65,10 +72,23 @@
65
72
  * A merged recovery row carries `recovered: <code>`; any other end is final with `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
66
73
  * (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
67
74
  * fire-and-forget: the runner never waits on it.
75
+ * Daemon shutdown (#4454): an aborted `signal` stops the runner without ending the batch: nothing more is admitted, every wait
76
+ * wakes, running lanes are neither awaited nor stopped (they are detached), nothing is relabelled and the state is saved once
77
+ * with its `running` records, so a `--resume` re-adopts the lanes; then it throws `code: "batch.stopped"` (no rows, no report).
78
+ *
79
+ * Longest-first admission (#4409): `estimate(issue, {body})` (minutes; off when null, persisted as `estimates`) ranks the
80
+ * ready issues inside a priority level. All admission paths order them by priority (unchanged), then the head band
81
+ * (sub-issues of a decomposed lane, inserted at the front of the queue; with an estimate also issues queued longer than
82
+ * `starveMs`, oldest first), then the estimate plus the estimates of their in-batch dependents (critical path), descending,
83
+ * then queue order. A sub-issue's estimate is the parent's ÷ N, floored at the M default. Without `estimate` the order is
84
+ * the queue order except that decomposed sub-issues go first. A settled row carries `est_minutes` (estimated vs. actual).
68
85
  */
69
86
  import { parseDependsOn } from "../engine/phases.mjs";
70
87
  import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
71
88
  import { recoveryEvidence } from "./render.mjs";
89
+ import { renderAlerts } from "../stats/render.mjs";
90
+ import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
91
+ import { laneSize, SIZES } from "./budget.mjs";
72
92
 
73
93
  // A dependency that finished like this no longer blocks its dependents.
74
94
  const SATISFIED = new Set(["merged", "invalid"]);
@@ -85,6 +105,40 @@ export const DEFER_BACKOFF_MAX_S = 30 * 60;
85
105
  // A dependency-open whose dependencies already finished satisfied (GitHub not yet closed): wait before relaunching.
86
106
  export const DEP_REQUEUE_DELAY_S = 60;
87
107
 
108
+ // #4409 lane duration estimates (minutes) without history; the ratio follows the S/M/L turn budgets.
109
+ export const EST_DEFAULT_MIN = Object.freeze({ S: 10, M: 25, L: 50 });
110
+ // A post-deploy-only follow-up starts no real lane (the pre-filter parks or closes it).
111
+ export const POST_DEPLOY_EST_MIN = 2;
112
+ // A ready issue queued longer than this joins the head band (starvation guard; only with an estimator).
113
+ export const DEFAULT_STARVE_MS = 30 * 60000;
114
+ // Merged runs of one repo and size needed before their median replaces the default.
115
+ export const HISTORY_MIN_SAMPLES = 3;
116
+
117
+ /** #4409: estimated lane minutes from facts: post-deploy-only body, else the `size:` label (unsized = M) -> history median or default. */
118
+ export function estimateLaneMinutes({ labels = [], body = "", history = null } = {}) {
119
+ if (postDeployOnly(body)) return POST_DEPLOY_EST_MIN;
120
+ const size = laneSize({ labels }) ?? "M";
121
+ const h = Number(history?.[size]);
122
+ return Number.isFinite(h) && h > 0 ? h : EST_DEFAULT_MIN[size];
123
+ }
124
+
125
+ /** #4409: {S?, M?, L?} median wall minutes of `repo`'s merged runs (summariseLog rows) per size, only sizes with enough samples. */
126
+ export function laneHistory(runs, repo) {
127
+ const by = {};
128
+ for (const r of runs ?? []) {
129
+ if (r?.repo !== repo || r?.reason !== "merged" || !SIZES.includes(r?.size) || !Number.isFinite(r?.wallMs)) continue;
130
+ (by[r.size] ??= []).push(r.wallMs / 60000);
131
+ }
132
+ const out = {};
133
+ for (const [s, xs] of Object.entries(by)) {
134
+ if (xs.length < HISTORY_MIN_SAMPLES) continue;
135
+ xs.sort((a, b) => a - b);
136
+ const m = xs.length >> 1;
137
+ out[s] = Math.round((xs.length % 2 ? xs[m] : (xs[m - 1] + xs[m]) / 2) * 10) / 10;
138
+ }
139
+ return out;
140
+ }
141
+
88
142
  /** Declared dependencies of an issue body; the same parser as the engine's admission (`openDependency`). */
89
143
  export function parseDeps(body, issue) {
90
144
  return parseDependsOn(body, issue);
@@ -160,9 +214,10 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
160
214
  * @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
161
215
  */
162
216
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
163
- store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
217
+ store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
164
218
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
165
- recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null }) {
219
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, stopLane = null,
220
+ estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null }) {
166
221
  const saved = store?.load?.() ?? null;
167
222
  const order = saved ? [...saved.order] : [];
168
223
  const set = new Set(order);
@@ -178,11 +233,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
178
233
  const deferrals = toMap(saved?.deferrals);
179
234
  const engineErrors = toMap(saved?.engineErrors);
180
235
  const checked = toMap(saved?.prefilter); // issue -> pre-filter result
236
+ const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
237
+ const enqueued = toMap(saved?.enqueued); // #4409: issue -> when it last entered the queue (starvation guard)
238
+ const heads = new Set(saved?.heads ?? []); // #4409: decomposed sub-issues not started yet: the head of their priority band
181
239
  let errorTimes = [...(saved?.errorTimes ?? [])];
182
240
  // A legacy batch-local `holdUntil` (pre-#4190) names no account and is ignored: a same-account lane re-sets the hold.
183
241
  const holds = limits ?? memoryLimits(now);
184
242
  const heldUntil = () => { try { const id = identity(); const u = holds.holdUntil(id); return Number.isFinite(u) && u > now() ? u : null; } catch { return null; } };
185
243
  let tripped = saved?.breaker ?? null;
244
+ // #4376: operator cancel (the batch `cancel` file through `getCancelled`); persisted, but the file stays the authority.
245
+ let cancelled = getCancelled ? (saved?.cancelled ?? null) : null;
186
246
  const budget = maintenance && Number(maintenance.share) > 0 ? { share: Number(maintenance.share), whenIdle: !!maintenance.whenIdle } : null;
187
247
  const classes = toMap(saved?.classes); // issue -> {maintenance, priority}; only with a maintenance budget
188
248
  const dispatch = { started: saved?.dispatch?.started ?? 0, maint: saved?.dispatch?.maint ?? 0 };
@@ -219,14 +279,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
219
279
  const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
220
280
  const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
221
281
 
282
+ let halted = false; // #4454: stopped by `signal`; the batch's state is no longer this process's to write
222
283
  const save = () => {
223
- if (!store) return;
284
+ if (!store || halted) return;
224
285
  store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
225
286
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
226
287
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
227
288
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
228
289
  ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
229
- ...(relabelled.size ? { relabelled: [...relabelled] } : {}) });
290
+ ...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
291
+ ...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
230
292
  };
231
293
  const update = (row) => { store?.log?.(row); onUpdate(row); };
232
294
  const classifyOne = async (n) => {
@@ -241,11 +303,19 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
241
303
  try { p = priorityOf ? await priorityOf(n) : null; } catch { p = null; }
242
304
  priorities.set(n, p !== null && p !== "" && Number.isFinite(Number(p)) ? Number(p) : 2);
243
305
  };
306
+ // #4409: fail-open: a thrown or junk estimate is the M default.
307
+ const readEstimate = async (n, body) => {
308
+ if (!estimate || estimates.has(n)) return;
309
+ let v = null;
310
+ try { v = Number(await estimate(n, { body })); } catch { v = null; }
311
+ estimates.set(n, Number.isFinite(v) && v > 0 ? v : EST_DEFAULT_MIN.M);
312
+ };
244
313
  const readDeps = async (n) => {
245
314
  let body = "";
246
315
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
247
316
  if (drift?.holds) bodies.set(n, body);
248
317
  deps.set(n, parseDeps(body, n));
318
+ await readEstimate(n, body);
249
319
  await classifyOne(n);
250
320
  await readPriority(n);
251
321
  if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e) }; } lanes.set(n, l); }
@@ -260,15 +330,31 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
260
330
  onCrossLane({ issue: n, lane: a.lane, dependency: d, dependencyLane: b.lane });
261
331
  }
262
332
  };
263
- const addIssues = async (ns) => {
333
+ // #4409: `head` (decomposed sub-issues) puts them at the front of the queue in their given order; a sub-issue's
334
+ // estimate is the parent's ÷ N, floored at the M default.
335
+ const addIssues = async (ns, { head = false, parent = null } = {}) => {
264
336
  const fresh = ns.filter((d) => !set.has(d));
265
- for (const d of fresh) { set.add(d); order.push(d); }
266
- for (const d of fresh) { await readDeps(d); pending.push(d); }
337
+ for (const d of fresh) {
338
+ set.add(d); order.push(d);
339
+ if (estimate && parent !== null && estimates.has(parent)) estimates.set(d, Math.max(EST_DEFAULT_MIN.M, estimates.get(parent) / ns.length));
340
+ }
341
+ let at = 0;
342
+ for (const d of fresh) {
343
+ await readDeps(d);
344
+ if (head) { pending.splice(at++, 0, d); heads.add(d); } else pending.push(d);
345
+ enqueued.set(d, now());
346
+ }
267
347
  noteCrossLane();
268
348
  };
269
349
  // New issues (a fresh batch, or extra issues passed to a resume) join the queue.
270
350
  for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
351
+ for (const n of pending) if (!enqueued.has(n)) enqueued.set(n, now());
271
352
  for (const n of order) if (!deps.has(n)) await readDeps(n);
353
+ if (estimate) for (const n of order) { // a resume that enabled the estimator
354
+ if (estimates.has(n) || results.has(n)) continue;
355
+ let b = ""; try { b = await readBody(n); } catch { b = ""; }
356
+ await readEstimate(n, b);
357
+ }
272
358
  if (budget) for (const n of order) if (!classes.has(n)) await classifyOne(n); // a resume that enabled the budget
273
359
  if (drift?.holds) for (const n of pending) if (!bodies.has(n)) { try { bodies.set(n, await readBody(n)); } catch { bodies.set(n, ""); } } // a resume
274
360
  if (reg) for (const n of order) if (!priorities.has(n)) await readPriority(n); // a resume of a pre-#4191 batch
@@ -280,6 +366,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
280
366
  let fellBack = false;
281
367
  const keyOf = (n) => `${sched?.batch ?? batchId ?? "?"}#${n}`;
282
368
  const prioOf = (n) => priorities.get(n) ?? 2;
369
+ // #4409: priority, then the head band (decomposed sub-issues; with an estimator also starved issues, oldest first), then
370
+ // the estimate plus in-batch dependents' estimates descending, then queue order. Without an estimator: queue order.
371
+ const rank = (ready) => {
372
+ const t = now(), idx = new Map(pending.map((n, i) => [n, i]));
373
+ const starved = (n) => !!estimate && !heads.has(n) && t - (enqueued.get(n) ?? t) > starveMs;
374
+ const weight = (n) => {
375
+ if (!estimate) return 0;
376
+ let w = estimates.get(n) ?? 0;
377
+ for (const m of order) if (m !== n && !results.has(m) && edges(m).includes(n)) w += estimates.get(m) ?? 0;
378
+ return w;
379
+ };
380
+ return ready.map((n) => ({ n, p: prioOf(n), b: heads.has(n) || starved(n) ? 0 : 1, s: starved(n) ? enqueued.get(n) : 0, w: weight(n), i: idx.get(n) ?? 0 }))
381
+ .sort((a, b) => a.p - b.p || a.b - b.b || a.s - b.s || b.w - a.w || a.i - b.i).map((x) => x.n);
382
+ };
283
383
  const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n) });
284
384
  const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
285
385
  const offerFresh = (ns) => { const fresh = ns.filter((n) => !offered.has(n)); if (!fresh.length) return; reg.offer(fresh.map(entryOf)); for (const n of fresh) offered.add(n); };
@@ -334,7 +434,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
334
434
  };
335
435
 
336
436
  const skip = (n, detail) => { results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail }); update(results.get(n)); };
337
- const requeue = (n, delayMs) => { pending.push(n); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
437
+ const requeue = (n, delayMs) => { pending.push(n); enqueued.set(n, now()); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
338
438
  const noteEngineError = () => {
339
439
  if (!breaker) return;
340
440
  const t = now(), windowMs = breaker.window_min * 60000;
@@ -347,14 +447,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
347
447
 
348
448
  // Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
349
449
  const settle = async (n, r, rec, pf) => {
450
+ if (halted) return; // #4454: a resume adopts the lane and settles it
350
451
  // #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
351
452
  const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
352
453
  const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
353
454
  ...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
354
455
  ...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
355
- ...(rec?.placement ? { placement: rec.placement } : {}) };
456
+ ...(typeof r?.firstPass === "boolean" ? { first_pass: r.firstPass } : {}), // #4362
457
+ ...(r?.turns && typeof r.turns === "object" ? { turns: r.turns } : {}), // #4392
458
+ ...(r?.conformance && typeof r.conformance === "object" ? { conformance: r.conformance } : {}), // #4398
459
+ ...(rec?.placement ? { placement: rec.placement } : {}), ...(estimates.has(n) ? { est_minutes: Math.round(estimates.get(n) * 10) / 10 } : {}) }; // #4409
356
460
  const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
357
461
  const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
462
+ if (subs.length) row.subissues = subs; // #4396: links a decomposed parent to its sub-lanes in the report
463
+ if (r?.decomposition && typeof r.decomposition === "object") row.decomposition = r.decomposition;
358
464
  if (r?.sandbox && typeof r.sandbox === "object") {
359
465
  const prev = sandboxUse.get(n) ?? { minutes: 0, cost_usd: 0, attempts: 0 };
360
466
  const m = Number(r.sandbox.minutes) >= 0 ? Number(r.sandbox.minutes) : 0, c = Number(r.sandbox.cost_usd) >= 0 ? Number(r.sandbox.cost_usd) : 0;
@@ -363,6 +469,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
363
469
  }
364
470
  if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n) };
365
471
  const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); remoteRun.delete(n); };
472
+ if (row.outcome === "dependency-open" && depList.length) {
473
+ // #4387: a parked dependency is not pulled in again (fail-open on an unreadable label).
474
+ let parked;
475
+ for (const d of depList) {
476
+ if (results.get(d)?.outcome === "needs-human") { parked = d; break; }
477
+ if (set.has(d) || typeof issueLabels !== "function") continue;
478
+ let names = [];
479
+ try { names = (await issueLabels(d)) ?? []; } catch { names = []; }
480
+ if (Array.isArray(names) && names.includes("needs-human")) { parked = d; break; }
481
+ }
482
+ if (parked !== undefined) {
483
+ done();
484
+ const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human` };
485
+ results.set(n, final); save();
486
+ decided({ issue: n, what: `skipped: dependency #${parked} needs-human (not pulled into the batch)`, why: firstLine(row.detail) || "the lane reported an open dependency",
487
+ alternative: `pull #${parked} in and run it again` });
488
+ update(final);
489
+ return;
490
+ }
491
+ }
366
492
  if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
367
493
  // A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
368
494
  requeues.set(n, (requeues.get(n) ?? 0) + 1);
@@ -376,6 +502,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
376
502
  update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
377
503
  return;
378
504
  }
505
+ // #4376: a lane stopped by a cancel is not a scheduling failure: requeued (held at the end), no deferral, no hold.
506
+ if (cancelled && (row.outcome === "deferred" || row.outcome === "engine-error")) {
507
+ requeue(n, 0); done(); save();
508
+ update({ ...row, detail: `stopped by cancel: ${r?.code ?? row.outcome}${r?.detail ? ` ${r.detail}` : ""}` });
509
+ return;
510
+ }
379
511
  if (row.outcome === "deferred") {
380
512
  const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
381
513
  if (!pol.capped) {
@@ -403,7 +535,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
403
535
  }
404
536
  }
405
537
  if (row.outcome === "decomposed" && subs.length) {
406
- await addIssues(subs);
538
+ await addIssues(subs, { head: true, parent: n }); // #4409: on the batch's critical path by construction
407
539
  decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
408
540
  alternative: "end the batch row decomposed and let dependents wait for a later batch" });
409
541
  for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
@@ -472,18 +604,24 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
472
604
  if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
473
605
  const pl = placed.get(n);
474
606
  placed.delete(n);
607
+ heads.delete(n); enqueued.delete(n);
475
608
  const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
476
609
  runRecs.set(n, rec); notBefore.delete(n); save();
477
610
  track(n, (async () => {
478
611
  // Pre-filter once per issue (a requeued issue is not re-checked); fail-open: an error means a normal lane.
479
612
  if (prefilter && !checked.has(n)) { let pf = null; try { pf = await prefilter(n, lane); } catch { pf = null; } checked.set(n, pf); save(); }
480
613
  const pf = checked.get(n) ?? null;
614
+ if (halted) return; // #4454: stopped before launch; no pid, so a resume queues it again
481
615
  let r;
482
- if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
616
+ if (cancelled) r = { terminalReason: "deferred", code: "run.interrupted", cost_usd: 0, detail: "cancelled before launch" }; // #4376
617
+ else if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
618
+ // #4389: a parked post-deploy follow-up starts no lane; it ends awaiting-promotion|awaiting-precondition, re-checked next batch.
619
+ else if (pf?.parked) r = { terminalReason: pf.kind, cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
483
620
  else {
484
621
  try {
485
622
  r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
486
623
  Object.assign(rec, info ?? {}); save();
624
+ if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
487
625
  if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
488
626
  } });
489
627
  }
@@ -502,7 +640,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
502
640
  if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
503
641
  track(n, Promise.resolve(p).then((r) => settle(n, r, rec, checked.get(n) ?? null)));
504
642
  }
505
- else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) pending.unshift(n); }
643
+ else { runRecs.delete(n); if (!pending.includes(n) && !results.has(n)) { pending.unshift(n); enqueued.set(n, now()); } }
506
644
  }
507
645
  // A resume re-derives each unfinished issue's lane from its current milestone (#4153): a re-milestone takes effect.
508
646
  if (saved && resolveLane) {
@@ -515,7 +653,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
515
653
  }
516
654
  save();
517
655
 
518
- const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
656
+ const heldDetail = (n) => cancelled ? "cancelled by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
519
657
  // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
520
658
  // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
521
659
  // #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
@@ -534,9 +672,69 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
534
672
  })());
535
673
  if (typeof onExit === "function") { try { onExit(relabelQueued); } catch { /* best effort */ } }
536
674
 
675
+ // #4408: issues added to the running batch; returns how many joined. Re-reading the cumulative list is a no-op.
676
+ const drainAdds = async () => {
677
+ if (!getAdded) return 0;
678
+ let got = [];
679
+ try { got = getAdded() ?? []; } catch { got = []; }
680
+ const fresh = [...new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0 && !set.has(n)))];
681
+ if (!fresh.length) return 0;
682
+ // An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
683
+ for (const n of pending) {
684
+ let body;
685
+ try { body = await readBody(n); } catch { continue; }
686
+ if (drift?.holds) bodies.set(n, body);
687
+ deps.set(n, [...new Set([...(deps.get(n) ?? []), ...parseDeps(body, n)])]);
688
+ }
689
+ await addIssues(fresh);
690
+ save();
691
+ const list = fresh.map((n) => `#${n}`).join(", ");
692
+ decided({ issue: null, what: `added ${list} to the batch (forgedock orchestrate --add)`, why: "operator request", alternative: "start a second batch" });
693
+ update({ issue: null, outcome: "added", detail: list });
694
+ return fresh.length;
695
+ };
696
+ // #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result.
697
+ const stopSent = new Set();
698
+ function stop(n, rec) {
699
+ if (!stopLane || rec?.remote || stopSent.has(n)) return;
700
+ stopSent.add(n);
701
+ try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
702
+ }
703
+ const checkCancel = () => {
704
+ if (!getCancelled) return;
705
+ let c = null;
706
+ try { c = getCancelled() ?? null; } catch { c = null; }
707
+ if (!c) { if (cancelled) { cancelled = null; save(); } return; }
708
+ const first = !cancelled;
709
+ cancelled = c;
710
+ if (first) {
711
+ save();
712
+ decided({ issue: null, what: "cancelled the batch (forgedock orchestrate --cancel)", why: "operator request", alternative: "stop the shared schedd" });
713
+ update({ issue: null, outcome: "cancelled", detail: "cancelled by operator (forgedock orchestrate --cancel)" });
714
+ if (reg) withdrawExcept(new Set());
715
+ }
716
+ for (const [n, rec] of runRecs) if (running.has(n)) stop(n, rec);
717
+ };
718
+ // #4454: the shutdown stop; every wait races it and cancels its sleep.
719
+ const aborted = signal ? new Promise((r) => { if (signal.aborted) r(); else signal.addEventListener("abort", () => r(), { once: true }); }) : null;
720
+ const nap = (ms) => {
721
+ const ac = new AbortController();
722
+ return Promise.race([sleep(ms, ac.signal), ...(aborted ? [aborted] : [])]).finally(() => ac.abort());
723
+ };
724
+ const race = (ps) => Promise.race([...ps, ...(aborted ? [aborted] : [])]);
725
+ const halt = () => {
726
+ if (reg) withdrawExcept(new Set());
727
+ save();
728
+ halted = true;
729
+ return Object.assign(new Error("batch stopped: schedd shutting down"), { code: "batch.stopped" });
730
+ };
537
731
  const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
538
732
  try {
539
- while (pending.length || running.size) {
733
+ for (;;) {
734
+ if (signal?.aborted) throw halt();
735
+ await drainAdds();
736
+ checkCancel();
737
+ if (!pending.length && !running.size) break;
540
738
  // Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
541
739
  for (let i = pending.length - 1; i >= 0; i--) {
542
740
  const n = pending[i];
@@ -558,12 +756,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
558
756
  };
559
757
  budgetHeld = false;
560
758
  // #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
561
- if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
759
+ if (beforeAdmit && !tripped && !cancelled && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
760
+ if (signal?.aborted) throw halt();
562
761
  budgetStop = null;
563
762
  const wanted = new Set(); // issues that stay offered in the host registry after this pass
564
- if (!tripped && !holdUntil && budget) {
763
+ if (!tripped && !cancelled && !holdUntil && budget) {
565
764
  while (running.size < K()) {
566
- const ready = pending.filter(isReady);
765
+ const ready = rank(pending.filter(isReady));
567
766
  const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
568
767
  featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
569
768
  if (n === null) { budgetHeld = ready.length > 0; break; }
@@ -572,9 +771,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
572
771
  if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
573
772
  start(n); started++;
574
773
  }
575
- } else if (!tripped && !holdUntil && reg) {
576
- // Priority first, pending order within a priority; the free per-batch K is offered so the host queue keeps FIFO.
577
- const ready = pending.filter(isReady).map((n, i) => [n, i]).sort((a, b) => prioOf(a[0]) - prioOf(b[0]) || a[1] - b[1]).map(([n]) => n);
774
+ } else if (!tripped && !cancelled && !holdUntil && reg) {
775
+ // Priority first, then `rank` within a priority; the free per-batch K is offered so the host queue keeps FIFO.
776
+ const ready = rank(pending.filter(isReady));
578
777
  const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
579
778
  offerFresh(want);
580
779
  for (const n of want) wanted.add(n);
@@ -582,39 +781,42 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
582
781
  if (running.size >= K() || !grant(n, wanted)) break;
583
782
  pending.splice(pending.indexOf(n), 1); start(n); started++;
584
783
  }
585
- } else if (!tripped && !holdUntil) {
586
- for (let i = 0; i < pending.length && running.size < K();) {
587
- const n = pending[i];
588
- if (isReady(n) && grant(n, new Set())) { pending.splice(i, 1); start(n); started++; }
589
- else i++;
784
+ } else if (!tripped && !cancelled && !holdUntil) {
785
+ for (const n of rank(pending.filter(isReady))) {
786
+ if (running.size >= K()) break;
787
+ if (grant(n, new Set())) { pending.splice(pending.indexOf(n), 1); start(n); started++; }
590
788
  }
591
789
  }
592
790
  if (reg) withdrawExcept(wanted);
593
- if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
594
- if (budgetStop && !running.size) break; // execution budget (stop): the same, the rest end held
791
+ if (tripped && !running.size) { await drainAdds(); break; } // breaker: let running lanes finish, keep the rest pending for a resume
792
+ if (cancelled && !running.size) { await drainAdds(); break; } // #4376: cancel: the rest end held for a resume
793
+ if (budgetStop && !running.size) { await drainAdds(); break; } // execution budget (stop): the same, the rest end held
595
794
  // Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
596
795
  const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
597
796
  if (holdUntil) waits.push(holdUntil);
598
797
  let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
798
+ if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
599
799
  if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
600
800
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
801
+ if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
601
802
  if (tripped) wake = null;
803
+ if (getCancelled && running.size && !cancelled) wake = Math.min(wake ?? pollMs, pollMs); // #4376: notice a cancel while lanes run
602
804
  if (running.size) {
603
- if (wake === null) await Promise.race(running.values());
604
- else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
805
+ if (wake === null) await race(running.values());
806
+ else { const ac = new AbortController(); await race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
605
807
  } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
606
- else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
607
- else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
608
- else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
609
- else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
808
+ else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
809
+ 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
810
+ else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
811
+ else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
610
812
  else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
611
813
  for (const n of pending.splice(0)) skip(n, "dependency cycle");
612
814
  save();
613
815
  }
614
816
  }
615
817
  } catch (e) {
616
- // #4318: an exception out of the loop still relabels the queued recoveries before it propagates
617
- try { await relabelQueued(`batch failed: ${firstLine(e?.message ?? e)}`); } catch { /* best effort */ }
818
+ // #4318: an exception out of the loop still relabels the queued recoveries before it propagates (not a #4454 stop)
819
+ if (!halted) try { await relabelQueued(`batch failed: ${firstLine(e?.message ?? e)}`); } catch { /* best effort */ }
618
820
  throw e;
619
821
  }
620
822
  if (reg) withdrawExcept(new Set());
@@ -625,6 +827,20 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
625
827
  ...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
626
828
  }
627
829
 
830
+ /**
831
+ * #4409 batch-report line: estimated vs. actual lane minutes over the rows that ran with an estimate (feeds the estimator).
832
+ * Null when no row has both.
833
+ */
834
+ export function formatEstimateSummary(rows) {
835
+ const xs = (rows ?? []).filter((r) => Number.isFinite(r?.est_minutes) && Number(r?.minutes) > 0 && !["held", "skipped", "lane-refused"].includes(r.outcome));
836
+ if (!xs.length) return null;
837
+ const sum = (f) => xs.reduce((a, r) => a + f(r), 0);
838
+ const errs = xs.map((r) => Math.abs(r.minutes - r.est_minutes)).sort((a, b) => a - b);
839
+ const med = errs.length % 2 ? errs[errs.length >> 1] : (errs[errs.length / 2 - 1] + errs[errs.length / 2]) / 2;
840
+ const worst = xs.reduce((a, r) => (Math.abs(r.minutes - r.est_minutes) > Math.abs(a.minutes - a.est_minutes) ? r : a));
841
+ return `estimates: ${xs.length} lane${xs.length === 1 ? "" : "s"}, est ${sum((r) => r.est_minutes).toFixed(1)} min vs actual ${sum((r) => r.minutes).toFixed(1)} min; median |error| ${med.toFixed(1)} min; worst #${worst.issue} est ${worst.est_minutes} actual ${worst.minutes}`;
842
+ }
843
+
628
844
  /** Max chars of one row's detail in the batch-report outcome lists (#4298). */
629
845
  export const OUTCOME_LINE_MAX = 200;
630
846
  /**
@@ -643,6 +859,8 @@ export function formatOutcomeLists(rows) {
643
859
  if (!human && (r.outcome === "needs-human" || 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)})`);
644
860
  }
645
861
  const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
862
+ const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
863
+ if (parked) out.push(parked);
646
864
  if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
647
865
  return out;
648
866
  }
@@ -651,7 +869,7 @@ export function formatTable(rows) {
651
869
  // #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
652
870
  const sbx = rows.some((r) => r?.sandbox);
653
871
  const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
654
- const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
872
+ 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)}`,
655
873
  ...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
656
874
  const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
657
875
  const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
@@ -672,6 +890,11 @@ export function formatWaitSummary(rows, heavyStats = null) {
672
890
  return `waits by phase: ${waits}; heavy queue: now ${hasStats && Number.isInteger(heavyStats.queued) ? heavyStats.queued : "?"}, max ${seen}`;
673
891
  }
674
892
 
893
+ /** #4354 batch-report tail: the fail-soft alerts (stats computeAlerts) for the batch's repo; [] when none fired. */
894
+ export function formatAlertLines(alerts) {
895
+ return alerts?.length ? ["ALERTS", ...renderAlerts(alerts)] : [];
896
+ }
897
+
675
898
  /**
676
899
  * #4255 batch-report line: sandbox lanes, minutes and cost next to those lanes' token cost, plus the execution budget
677
900
  * (`budget` from parseExecution, `day` from openExecutionBudget `dayTotals`) when a cap is set. Null without sandbox usage.