forgedock 1.29.0 → 1.30.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 (50) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +102 -38
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -51,17 +51,24 @@
51
51
  * and instead of stopping when the budget refuses. The final `{where, reason}` is kept on the run record and the row as
52
52
  * `placement` and passed to `runOne` as `ctx.placement` (the lane records it in its FORGE:STATE).
53
53
  *
54
- * In-batch recovery (#4267), off unless `recovery: {maxAttempts}` is passed: a row `classifyNeedsHuman` calls
54
+ * In-batch recovery (#4267), on by default: this module recovers only when `recovery: {maxAttempts}` is passed, and the
55
+ * CLI always passes it (forge.yaml `orchestration.recovery.max_attempts`, default 1; 0 disables): a row `classifyNeedsHuman` calls
55
56
  * recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
56
57
  * edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts) is not final while its persisted `recoveries` stay
57
- * under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt}`) under the same admission as any
58
- * lane, so its dependents stay queued. A merged recovery row carries `recovered: <code>`; any other end is final with
59
- * `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
58
+ * under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
59
+ * `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
60
+ * itself; a non-final attempt that ends needs-human/ci-pending without a successor is relabelled here, once (#4302). A recovery
61
+ * still queued when the batch exits (breaker, budget, hold, or a resume that does not relaunch it) is relabelled once with the
62
+ * full persisted lane reason (#4315); also when the loop throws, and through `onExit(fn)` (the caller's signal handler
63
+ * awaits `fn(cause)`), each once (#4318). `relabelNeedsHuman` may return `false`/"failed" (not recorded, a resume retries)
64
+ * or "skipped" (closed or leased issue: recorded); any other result counts as relabelled.
65
+ * 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
60
66
  * (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
61
67
  * fire-and-forget: the runner never waits on it.
62
68
  */
63
69
  import { parseDependsOn } from "../engine/phases.mjs";
64
70
  import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
71
+ import { recoveryEvidence } from "./render.mjs";
65
72
 
66
73
  // A dependency that finished like this no longer blocks its dependents.
67
74
  const SATISFIED = new Set(["merged", "invalid"]);
@@ -155,7 +162,7 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
155
162
  export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
156
163
  store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
157
164
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
158
- recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null }) {
165
+ recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null }) {
159
166
  const saved = store?.load?.() ?? null;
160
167
  const order = saved ? [...saved.order] : [];
161
168
  const set = new Set(order);
@@ -206,7 +213,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
206
213
  let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
207
214
  const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
208
215
  const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
209
- const recovering = toMap(saved?.recovering); // issue -> {code, attempt} of the recovery lane queued or running
216
+ const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
217
+ const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
210
218
  // #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
211
219
  const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
212
220
  const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
@@ -217,7 +225,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
217
225
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
218
226
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
219
227
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
220
- ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}) });
228
+ ...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
229
+ ...(relabelled.size ? { relabelled: [...relabelled] } : {}) });
221
230
  };
222
231
  const update = (row) => { store?.log?.(row); onUpdate(row); };
223
232
  const classifyOne = async (n) => {
@@ -338,8 +347,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
338
347
 
339
348
  // Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
340
349
  const settle = async (n, r, rec, pf) => {
341
- const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + (pf?.cost_usd ?? 0), minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
350
+ // #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
351
+ const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
352
+ 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 } : {}),
342
353
  ...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
354
+ ...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
343
355
  ...(rec?.placement ? { placement: rec.placement } : {}) };
344
356
  const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
345
357
  const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
@@ -399,6 +411,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
399
411
  }
400
412
  // #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
401
413
  const prior = recovering.get(n) ?? null;
414
+ // #4318: the full persisted lane reason (capped by the comment writer), not its first 300 chars
415
+ const evidence = () => recoveryEvidence({ code: prior.code, attempt: prior.attempt, outcome: row.outcome, resultCode: row.code, detail: row.detail, full: true });
402
416
  if (prior) {
403
417
  recovering.delete(n);
404
418
  // #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
@@ -409,7 +423,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
409
423
  // A recovery that failed for good keeps needs-human. The lane relabels needs-human/ci-pending itself; engine-error (after its
410
424
  // relaunch) and deferred (no longer requeued) end here, so the runner does it, once. invalid/decomposed/dependency-*/merged never.
411
425
  if (["engine-error", "deferred"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
412
- try { await relabelNeedsHuman({ issue: n, evidence: `Recovery pass for \`${prior.code}\` (attempt ${prior.attempt}) did not merge: it ended \`${row.outcome}\`${row.code ? ` (\`${row.code}\`)` : ""}${firstLine(row.detail) ? `: ${firstLine(row.detail)}` : ""}. The issue stays \`needs-human\`.` }); } catch { /* best effort */ }
426
+ try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
413
427
  }
414
428
  }
415
429
  if (recoveryMax && (row.outcome === "needs-human" || row.outcome === "ci-pending")) {
@@ -421,7 +435,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
421
435
  const used = recoveries.get(n) ?? 0;
422
436
  if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
423
437
  const code = recoveryCodeOf(facts), attempt = used + 1;
424
- recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0 });
438
+ // #4315: the full lane detail is kept (never firstLine) for the relabel when the batch stops before this attempt
439
+ recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
440
+ detail: String(row.detail ?? ""), outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), prev_attempt: prior?.attempt ?? 0 });
425
441
  requeue(n, 0); done(); save();
426
442
  decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
427
443
  alternative: "leave needs-human for the operator and skip the dependents" });
@@ -429,6 +445,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
429
445
  return;
430
446
  }
431
447
  }
448
+ // #4302: a non-final recovery attempt left needs-human/ci-pending unlabelled for its successor; none is launched, so relabel once.
449
+ if (prior && prior.attempt < recoveryMax && ["needs-human", "ci-pending"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
450
+ try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
451
+ }
432
452
  results.set(n, row);
433
453
  if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
434
454
  save(); update(row);
@@ -449,6 +469,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
449
469
  return;
450
470
  }
451
471
  const lane = ln?.lane;
472
+ if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
452
473
  const pl = placed.get(n);
453
474
  placed.delete(n);
454
475
  const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
@@ -461,7 +482,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
461
482
  if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
462
483
  else {
463
484
  try {
464
- 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 } } : {}), onStart: (info) => {
485
+ 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) => {
465
486
  Object.assign(rec, info ?? {}); save();
466
487
  if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
467
488
  } });
@@ -494,86 +515,114 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
494
515
  }
495
516
  save();
496
517
 
497
- const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
498
- while (pending.length || running.size) {
499
- // Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
500
- for (let i = pending.length - 1; i >= 0; i--) {
501
- const n = pending[i];
502
- if (results.has(n)) { pending.splice(i, 1); continue; }
503
- const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
504
- if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
518
+ const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
519
+ // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
520
+ // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
521
+ // #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
522
+ let exitRelabel = null;
523
+ const relabelQueued = (cause = null) => (exitRelabel ??= (async () => {
524
+ for (const [n, q] of recovering) {
525
+ if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
526
+ const why = cause ?? (heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n));
527
+ const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
528
+ queued: q.attempt, cause: why, recoveryMax });
529
+ let res;
530
+ try { res = await relabelNeedsHuman({ issue: n, evidence }); } catch { res = "failed"; }
531
+ if (res !== false && res !== "failed") relabelled.add(n); // a failed label add is retried by a resume
505
532
  }
506
- const t = now();
507
- const holdUntil = heldUntil();
508
- let started = 0;
509
- driftHeld.clear();
510
- const isReady = (n) => {
511
- if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
512
- if (!drift?.holds) return true;
513
- let pkg = null;
514
- try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
515
- if (pkg) driftHeld.set(n, pkg);
516
- return !pkg;
517
- };
518
- budgetHeld = false;
519
- // #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
520
- if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
521
- budgetStop = null;
522
- const wanted = new Set(); // issues that stay offered in the host registry after this pass
523
- if (!tripped && !holdUntil && budget) {
524
- while (running.size < K()) {
525
- const ready = pending.filter(isReady);
526
- const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
527
- featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
528
- if (n === null) { budgetHeld = ready.length > 0; break; }
529
- if (!grant(n, wanted)) break;
530
- pending.splice(pending.indexOf(n), 1);
531
- if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
532
- start(n); started++;
533
+ save();
534
+ })());
535
+ if (typeof onExit === "function") { try { onExit(relabelQueued); } catch { /* best effort */ } }
536
+
537
+ const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
538
+ try {
539
+ while (pending.length || running.size) {
540
+ // Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
541
+ for (let i = pending.length - 1; i >= 0; i--) {
542
+ const n = pending[i];
543
+ if (results.has(n)) { pending.splice(i, 1); continue; }
544
+ const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
545
+ if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
533
546
  }
534
- } else if (!tripped && !holdUntil && reg) {
535
- // Priority first, pending order within a priority; the free per-batch K is offered so the host queue keeps FIFO.
536
- 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);
537
- const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
538
- offerFresh(want);
539
- for (const n of want) wanted.add(n);
540
- for (const n of ready) {
541
- if (running.size >= K() || !grant(n, wanted)) break;
542
- pending.splice(pending.indexOf(n), 1); start(n); started++;
547
+ const t = now();
548
+ const holdUntil = heldUntil();
549
+ let started = 0;
550
+ driftHeld.clear();
551
+ const isReady = (n) => {
552
+ if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
553
+ if (!drift?.holds) return true;
554
+ let pkg = null;
555
+ try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
556
+ if (pkg) driftHeld.set(n, pkg);
557
+ return !pkg;
558
+ };
559
+ budgetHeld = false;
560
+ // #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 */ } }
562
+ budgetStop = null;
563
+ const wanted = new Set(); // issues that stay offered in the host registry after this pass
564
+ if (!tripped && !holdUntil && budget) {
565
+ while (running.size < K()) {
566
+ const ready = pending.filter(isReady);
567
+ const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
568
+ featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
569
+ if (n === null) { budgetHeld = ready.length > 0; break; }
570
+ if (!grant(n, wanted)) break;
571
+ pending.splice(pending.indexOf(n), 1);
572
+ if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
573
+ start(n); started++;
574
+ }
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);
578
+ const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
579
+ offerFresh(want);
580
+ for (const n of want) wanted.add(n);
581
+ for (const n of ready) {
582
+ if (running.size >= K() || !grant(n, wanted)) break;
583
+ pending.splice(pending.indexOf(n), 1); start(n); started++;
584
+ }
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++;
590
+ }
543
591
  }
544
- } else if (!tripped && !holdUntil) {
545
- for (let i = 0; i < pending.length && running.size < K();) {
546
- const n = pending[i];
547
- if (isReady(n) && grant(n, new Set())) { pending.splice(i, 1); start(n); started++; }
548
- else i++;
592
+ 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
595
+ // Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
596
+ const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
597
+ if (holdUntil) waits.push(holdUntil);
598
+ let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
599
+ if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
600
+ if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
601
+ if (tripped) wake = null;
602
+ 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(); }
605
+ } 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
610
+ else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
611
+ for (const n of pending.splice(0)) skip(n, "dependency cycle");
612
+ save();
549
613
  }
550
614
  }
551
- if (reg) withdrawExcept(wanted);
552
- if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
553
- if (budgetStop && !running.size) break; // execution budget (stop): the same, the rest end held
554
- // Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
555
- const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
556
- if (holdUntil) waits.push(holdUntil);
557
- let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
558
- if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
559
- if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
560
- if (tripped) wake = null;
561
- if (running.size) {
562
- if (wake === null) await Promise.race(running.values());
563
- else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
564
- } else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
565
- else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
566
- else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
567
- else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
568
- else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
569
- else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
570
- for (const n of pending.splice(0)) skip(n, "dependency cycle");
571
- save();
572
- }
615
+ } 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 */ }
618
+ throw e;
573
619
  }
574
620
  if (reg) withdrawExcept(new Set());
621
+ await relabelQueued();
575
622
  save();
576
- return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run") });
623
+ return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
624
+ ...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
625
+ ...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
577
626
  }
578
627
 
579
628
  /** Max chars of one row's detail in the batch-report outcome lists (#4298). */
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Pre-PR declared check commands (#4240), one round (#4331): the commands run concurrently up to the host worker cap
5
+ * (the caller holds one heavy-step slot for the whole round), each timed, inside one total budget. A command that never
6
+ * started before the budget ran out, or that the budget cut short, is left to CI (`deferred-to-ci`), never a failure.
7
+ * Repo-agnostic: commands are opaque `bash -c` strings run through the injected `run`.
8
+ */
9
+
10
+ /** rc of a command killed by its timeout (effects.mjs runCheckCommand). */
11
+ const TIMEOUT_RC = 124;
12
+
13
+ /**
14
+ * @param {{commands:string[], parallelism?:number, budgetMs:number, perCommandMs:number, now?:() => number,
15
+ * run:(a:{command:string, timeoutMs:number}) => Promise<{rc:number, output?:string}>}} p
16
+ * @returns {Promise<{results:{command:string, rc:number, output:string, ms:number}[], deferred:string[], ms:number}>}
17
+ * results in input order (commands that ran and were not cut by the budget); deferred in input order
18
+ */
19
+ export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), run }) {
20
+ const list = Array.isArray(commands) ? commands : [];
21
+ const workers = Math.max(1, Math.floor(Number(parallelism)) || 1);
22
+ const start = now(), deadline = start + (Number(budgetMs) > 0 ? Number(budgetMs) : 0);
23
+ const per = Number(perCommandMs) > 0 ? Number(perCommandMs) : Infinity;
24
+ const slots = new Array(list.length).fill(null);
25
+ let next = 0;
26
+ async function worker() {
27
+ while (next < list.length) {
28
+ const i = next++, command = list[i], left = deadline - now();
29
+ if (left <= 0) { slots[i] = { deferred: true }; continue; }
30
+ const timeoutMs = Math.min(per, left), t = now();
31
+ let r;
32
+ try { r = await run({ command, timeoutMs }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
33
+ const rc = Number.isInteger(r?.rc) ? r.rc : 1;
34
+ // the budget, not the command's own timeout, cut it short: CI runs it
35
+ if (rc === TIMEOUT_RC && timeoutMs < per) { slots[i] = { deferred: true }; continue; }
36
+ slots[i] = { command, rc, output: String(r?.output ?? ""), ms: Math.max(0, now() - t) };
37
+ }
38
+ }
39
+ await Promise.all(Array.from({ length: Math.min(workers, list.length) }, worker));
40
+ const results = [], deferred = [];
41
+ slots.forEach((s, i) => { if (s?.deferred) deferred.push(list[i]); else if (s) results.push(s); });
42
+ return { results, deferred, ms: Math.max(0, now() - start) };
43
+ }
44
+
45
+ /**
46
+ * #4331 batch-report line: pre-PR check wall time summed over the rows' `precheck_ms`, and the commands deferred to CI
47
+ * (`precheck_deferred`). Null when no lane ran pre-PR checks.
48
+ */
49
+ export function formatPrecheckSummary(rows) {
50
+ const ran = (rows ?? []).filter((r) => Number.isFinite(r?.precheck_ms) && r.precheck_ms >= 0);
51
+ if (!ran.length) return null;
52
+ const ms = ran.reduce((a, r) => a + r.precheck_ms, 0);
53
+ const deferred = ran.reduce((a, r) => a + (Number.isInteger(r.precheck_deferred) ? r.precheck_deferred : 0), 0);
54
+ return `precheck: ${(ms / 60000).toFixed(1)}m total over ${ran.length} lane${ran.length === 1 ? "" : "s"}${deferred ? `, ${deferred} deferred to CI` : ""}`;
55
+ }
@@ -0,0 +1,93 @@
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 { writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { runChecks, formatPrecheckSummary } from "./precheck.mjs";
9
+ import { readPrecheckBudget } from "../engine-cli.mjs";
10
+ import { orchestrate } from "./orchestrate.mjs";
11
+
12
+ /** A fake run: each call takes `cost[command]` ms of the shared clock (default 1000) and returns rc[command] (default 0). */
13
+ function harness({ cost = {}, rc = {} } = {}) {
14
+ const h = { t: 0, live: 0, peak: 0, calls: [] };
15
+ h.now = () => h.t;
16
+ h.run = async ({ command, timeoutMs }) => {
17
+ h.calls.push([command, timeoutMs]); h.peak = Math.max(h.peak, ++h.live);
18
+ await new Promise((r) => setImmediate(r));
19
+ const c = cost[command] ?? 1000;
20
+ h.t += Math.min(c, timeoutMs); h.live--;
21
+ return c > timeoutMs ? { rc: 124, output: "timed out" } : { rc: rc[command] ?? 0, output: `${command} out` };
22
+ };
23
+ return h;
24
+ }
25
+
26
+ describe("runChecks (#4331)", () => {
27
+ it("runs up to `parallelism` commands at once and returns results in input order with ms", async () => {
28
+ const h = harness();
29
+ const r = await runChecks({ commands: ["a", "b", "c", "d", "e"], parallelism: 3, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
30
+ assert.equal(h.peak, 3);
31
+ assert.deepEqual(r.results.map((x) => x.command), ["a", "b", "c", "d", "e"]);
32
+ assert.ok(r.results.every((x) => x.rc === 0 && Number.isFinite(x.ms) && x.ms >= 0));
33
+ assert.deepEqual(r.deferred, []);
34
+ assert.ok(r.ms > 0);
35
+ });
36
+
37
+ it("parallelism 1 is serial; 0, NaN or missing fall back to 1", async () => {
38
+ for (const p of [1, 0, NaN, undefined, "x"]) {
39
+ const h = harness();
40
+ await runChecks({ commands: ["a", "b", "c"], parallelism: p, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
41
+ assert.equal(h.peak, 1, String(p));
42
+ }
43
+ });
44
+
45
+ it("past the budget the remaining commands are deferred to CI, not failed; a running one gets what is left", async () => {
46
+ const h = harness();
47
+ const r = await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 1500, perCommandMs: 300000, now: h.now, run: h.run });
48
+ assert.deepEqual(h.calls, [["a", 1500], ["b", 500]]);
49
+ assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["a", 0]], "b was cut by the budget (rc 124): deferred, not a failure");
50
+ assert.deepEqual(r.deferred, ["b", "c"]);
51
+ });
52
+
53
+ it("a command's own timeout (not the budget) stays a failure; a throw is rc 127", async () => {
54
+ const h = harness({ cost: { slow: 5000 } });
55
+ const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now,
56
+ run: (a) => (a.command === "boom" ? Promise.reject(new Error("spawn failed")) : a.command === "bad" ? Promise.resolve({ rc: 2, output: "lint" }) : h.run(a)) });
57
+ assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["slow", 124], ["boom", 127], ["bad", 2]]);
58
+ assert.match(r.results[1].output, /spawn failed/);
59
+ assert.deepEqual(r.deferred, []);
60
+ });
61
+
62
+ it("no commands: nothing runs", async () => {
63
+ let n = 0;
64
+ const r = await runChecks({ commands: [], parallelism: 4, budgetMs: 1000, perCommandMs: 1000, now: () => 0, run: async () => { n++; return { rc: 0 }; } });
65
+ assert.deepEqual([n, r.results, r.deferred, r.ms], [0, [], [], 0]);
66
+ });
67
+ });
68
+
69
+ describe("formatPrecheckSummary (#4331)", () => {
70
+ it("sums precheck_ms over the lanes that ran checks and counts deferrals; null when none did", () => {
71
+ assert.equal(formatPrecheckSummary([{ precheck_ms: 90000 }, { precheck_ms: 30000, precheck_deferred: 2 }, { issue: 3 }]), "precheck: 2.0m total over 2 lanes, 2 deferred to CI");
72
+ assert.equal(formatPrecheckSummary([{ precheck_ms: 6000 }]), "precheck: 0.1m total over 1 lane");
73
+ assert.equal(formatPrecheckSummary([{ issue: 1 }]), null);
74
+ assert.equal(formatPrecheckSummary(undefined), null);
75
+ });
76
+ it("orchestrate rows carry the lane's precheckMs/precheckDeferred as precheck_ms/precheck_deferred for the report line", async () => {
77
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
78
+ runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { precheckMs: 90000, precheckDeferred: 1 }, 2: { precheckMs: 30000, precheckDeferred: 0 } })[n] }) });
79
+ assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
80
+ assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
81
+ });
82
+ });
83
+
84
+ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
85
+ const withYaml = (text, fn) => { const d = tmpDir("precheck-"); writeFileSync(join(d, "forge.yaml"), text); return fn(d); };
86
+ it("reads the nested block and the flat key; invalid or absent is undefined", () => {
87
+ assert.equal(withYaml("lean:\n precheck:\n max_minutes: 7\n", readPrecheckBudget), 7);
88
+ assert.equal(withYaml("lean:\n precheck_max_minutes: 15\n repro_test: off\n", readPrecheckBudget), 15);
89
+ for (const bad of ["0", "-1", "abc", "121"]) assert.equal(withYaml(`lean:\n precheck_max_minutes: ${bad}\n`, readPrecheckBudget), undefined, bad);
90
+ assert.equal(withYaml("verify:\n mode: local\n", readPrecheckBudget), undefined);
91
+ assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
92
+ });
93
+ });