forgedock 1.43.1 → 1.45.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 (57) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +50 -11
  4. package/bin/forgedock.mjs +50 -24
  5. package/bin/gc.mjs +3 -1
  6. package/bin/hooks/pre-tool-use.mjs +54 -3
  7. package/bin/lean/autochildren.mjs +13 -5
  8. package/bin/lean/autochildren.test.mjs +36 -18
  9. package/bin/lean/batch.test.mjs +46 -0
  10. package/bin/lean/conformance.mjs +107 -16
  11. package/bin/lean/conformance.test.mjs +126 -13
  12. package/bin/lean/decision-engine.mjs +1 -1
  13. package/bin/lean/decision-engine.test.mjs +11 -0
  14. package/bin/lean/execution.mjs +7 -4
  15. package/bin/lean/execution.test.mjs +21 -0
  16. package/bin/lean/gatewatch.test.mjs +5 -5
  17. package/bin/lean/lane.mjs +22 -4
  18. package/bin/lean/lane.test.mjs +33 -1
  19. package/bin/lean/lean.mjs +49 -16
  20. package/bin/lean/lean.test.mjs +66 -0
  21. package/bin/lean/orchestrate-blocked.test.mjs +35 -0
  22. package/bin/lean/orchestrate-children.test.mjs +16 -4
  23. package/bin/lean/orchestrate.mjs +61 -13
  24. package/bin/lean/pmap.mjs +17 -0
  25. package/bin/lean/pmap.test.mjs +27 -0
  26. package/bin/lean/postdeploy-check.test.mjs +3 -1
  27. package/bin/lean/preflight.mjs +8 -3
  28. package/bin/lean/preflight.test.mjs +13 -1
  29. package/bin/lean/prodwatch-triage.mjs +6 -0
  30. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  31. package/bin/lean/prodwatch.mjs +1 -3
  32. package/bin/lean/prompts.mjs +1 -1
  33. package/bin/lean/recovery.test.mjs +21 -4
  34. package/bin/lean/retry.mjs +11 -0
  35. package/bin/lean/retry.test.mjs +16 -0
  36. package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
  37. package/bin/lean/sandbox-remote.mjs +19 -7
  38. package/bin/lean/sched.mjs +25 -2
  39. package/bin/lean/sched.test.mjs +58 -1
  40. package/bin/lean/selftest-live.mjs +56 -31
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +7 -3
  43. package/bin/lean/selftest-nightly.test.mjs +24 -2
  44. package/bin/lean/selftest-static.mjs +18 -11
  45. package/bin/lean/selftest-static.test.mjs +8 -0
  46. package/bin/lean/signoff.mjs +16 -3
  47. package/bin/lean/signoff.test.mjs +52 -1
  48. package/bin/pool/agent.mjs +3 -3
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/pool/router.mjs +17 -6
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +10 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +2 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/plan-validate-live.mjs +15 -8
@@ -115,6 +115,7 @@ import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
115
115
  import { laneSize, SIZES } from "./budget.mjs";
116
116
  import { requeueKey, REQUEUABLE } from "./batch.mjs";
117
117
  import { makeCapWait } from "./capwait.mjs";
118
+ import { mapLimit } from "./pmap.mjs";
118
119
  import { joinsDecomposed, addsEpicChildren, selectChildren, edgeText, DEFAULT_POLL_S } from "./autochildren.mjs";
119
120
 
120
121
  // A dependency that finished like this no longer blocks its dependents.
@@ -144,6 +145,8 @@ export const EST_DEFAULT_MIN = Object.freeze({ S: 10, M: 25, L: 50 });
144
145
  export const POST_DEPLOY_EST_MIN = 2;
145
146
  // A ready issue queued longer than this joins the head band (starvation guard; only with an estimator).
146
147
  export const DEFAULT_STARVE_MS = 30 * 60000;
148
+ /** #4963: issues read at once while a batch sets up. */
149
+ export const DEFAULT_SETUP_CONCURRENCY = 6;
147
150
  // Merged runs of one repo and size needed before their median replaces the default.
148
151
  export const HISTORY_MIN_SAMPLES = 3;
149
152
 
@@ -253,8 +256,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
253
256
  store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
254
257
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
255
258
  recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
256
- estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
257
- getChildren = null, autoChildren = null }) {
259
+ estimate = null, starveMs = DEFAULT_STARVE_MS, setupConcurrency = DEFAULT_SETUP_CONCURRENCY, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
260
+ getChildren = null, autoChildren = null, baseWait = null }) {
258
261
  const saved = store?.load?.() ?? null;
259
262
  const order = saved ? [...saved.order] : [];
260
263
  const set = new Set(order);
@@ -336,10 +339,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
336
339
  const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
337
340
  const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
338
341
 
342
+ let setup = null; // #4963: {read, total} while the initial per-issue reads run; written with phase "setup" so a slow start is visible
339
343
  let halted = false; // #4454: stopped by `signal`; the batch's state is no longer this process's to write
340
344
  const save = () => {
341
345
  if (!store || halted) return;
342
- store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
346
+ store.save({ v: 1, ...(setup ? { phase: "setup", setup: { ...setup } } : {}), order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
343
347
  pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
344
348
  prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
345
349
  ...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
@@ -377,11 +381,13 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
377
381
  let body = "";
378
382
  try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
379
383
  if (drift?.holds) bodies.set(n, body);
380
- deps.set(n, await declared(n, body));
384
+ const d = await declared(n, body);
381
385
  await readEstimate(n, body);
382
386
  await classifyOne(n);
383
387
  await readPriority(n);
384
388
  if (resolveLane) { let l; try { l = await resolveLane(n); } catch (e) { l = { ok: false, code: "lane.unresolved", detail: e?.message ?? String(e), transient: true }; } lanes.set(n, l); }
389
+ // deps marks the issue read (a resume skips it): set it last, so a concurrent setup save never persists a half-read issue (#4963)
390
+ deps.set(n, d);
385
391
  };
386
392
  const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
387
393
  // A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
@@ -412,7 +418,14 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
412
418
  // New issues (a fresh batch, or extra issues passed to a resume) join the queue.
413
419
  for (const n of issues) if (!set.has(n)) { set.add(n); order.push(n); pending.push(n); }
414
420
  for (const n of pending) if (!enqueued.has(n)) enqueued.set(n, now());
415
- for (const n of order) if (!deps.has(n)) await readDeps(n);
421
+ // #4963: bounded-concurrency setup reads; each issue's result lands in Maps keyed by issue, so `order` and the edges are unchanged.
422
+ const unread = order.filter((n) => !deps.has(n));
423
+ if (unread.length) {
424
+ setup = { read: 0, total: unread.length };
425
+ save();
426
+ await mapLimit(unread, setupConcurrency, async (n) => { await readDeps(n); setup.read++; save(); });
427
+ setup = null;
428
+ }
416
429
  if (estimate) for (const n of order) { // a resume that enabled the estimator
417
430
  if (estimates.has(n) || results.has(n)) continue;
418
431
  let b = ""; try { b = await readBody(n); } catch { b = ""; }
@@ -446,6 +459,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
446
459
  .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);
447
460
  };
448
461
  const entryOf = (n) => ({ key: keyOf(n), batch: sched?.batch ?? batchId ?? null, repo: sched?.repo ?? null, issue: n, priority: prioOf(n), ...(sched?.exclusive ? { exclusive: true } : {}) });
462
+ // #4964: remote lanes are published to the registry for `sched show` only; they never take a slot
463
+ const endRemote = (n) => { if (remoteRun.delete(n)) reg?.dropRemote?.(keyOf(n)); };
464
+ const noteRemote = (n, h) => { reg?.noteRemote?.(keyOf(n), { ...entryOf(n), sandbox: h?.id ?? null, agent: h?.agent ?? null }); };
465
+ let poolDown = false;
466
+ const notePool = (down, reason) => { if (down === poolDown) return; poolDown = down; reg?.notePool?.(sched?.batch ?? batchId ?? "?", down ? { reason } : null); };
449
467
  const releaseSlot = (n) => { if (reg && slotted.delete(n)) reg.release(keyOf(n)); };
450
468
  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); };
451
469
  const withdrawExcept = (keep) => {
@@ -498,8 +516,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
498
516
  const adm = admitRemote();
499
517
  if (adm.ok) {
500
518
  // #4540: the provider's free slots (`remote.capacity() -> {free, at}`, sync, null = unknown) cap admissions per sample `at`; the issue queues here instead of a 503.
501
- let cap = null;
502
- try { cap = remote.capacity?.() ?? null; } catch { cap = null; }
519
+ let cap = null, probeFailed = false;
520
+ try { cap = remote.capacity?.() ?? null; } catch { probeFailed = true; }
521
+ // #5048: only a failed probe (a throw) marks the pool down; null is unknown (first pass, no capacity()) and leaves the state; a sample clears it.
522
+ if (probeFailed) notePool(true, "capacity probe failed"); else if (cap !== null) notePool(false);
503
523
  // #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
504
524
  if (cap && cap.online === 0) {
505
525
  poolOfflineSince ??= now();
@@ -585,7 +605,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
585
605
  try { remote?.onUsage?.(n, r.sandbox, rec); } catch { /* the batch total still counts it */ }
586
606
  }
587
607
  if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n), ...(sandboxUse.get(n).by_agent ? { by_agent: { ...sandboxUse.get(n).by_agent } } : {}) };
588
- const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); remoteRun.delete(n); };
608
+ const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); endRemote(n); };
589
609
  if (row.outcome === "dependency-open" && depList.length) {
590
610
  // #4387: a parked dependency is not pulled in again (fail-open on an unreadable label).
591
611
  let parked;
@@ -633,6 +653,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
633
653
  if (row.outcome === "deferred") {
634
654
  const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
635
655
  let capGated = false; // #4909: the pool reports free slots, so the slot (not the retry-in timer) gates the relaunch
656
+ if (r?.code === "sandbox.unreachable") notePool(true, r.detail ?? "sandbox unreachable");
636
657
  if (r?.code === "sandbox.capacity") {
637
658
  capWait.refused(n);
638
659
  try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
@@ -781,6 +802,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
781
802
  try {
782
803
  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 >= (recovering.get(n).max ?? recoveryMax) } } : {}), onStart: (info) => {
783
804
  Object.assign(rec, info ?? {}); save();
805
+ if (rec.remote && remoteRun.has(n)) noteRemote(n, rec.handle);
784
806
  if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
785
807
  if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
786
808
  } });
@@ -805,18 +827,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
805
827
  for (const a of adopting) a.live = a.first ? await a.first : false;
806
828
  for (const { n, rec, p, live } of adopting) {
807
829
  if (live) {
808
- if (rec.remote) remoteRun.add(n);
830
+ if (rec.remote) { remoteRun.add(n); noteRemote(n, rec.handle); }
809
831
  else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
810
832
  track(n, p.then((r) => {
811
833
  if (r) return settle(n, r, rec, checked.get(n) ?? null);
812
834
  if (halted) return; // #4454: kept for the next resume
813
- running.delete(n); releaseSlot(n); remoteRun.delete(n); requeueLost(n); save(); // ended without a result after all
835
+ running.delete(n); releaseSlot(n); endRemote(n); requeueLost(n); save(); // ended without a result after all
814
836
  }));
815
837
  continue;
816
838
  }
817
839
  const r = await p;
818
840
  if (!r) { requeueLost(n); continue; }
819
- if (rec.remote) remoteRun.add(n);
841
+ if (rec.remote) { remoteRun.add(n); noteRemote(n, rec.handle); }
820
842
  else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); } // released by the settle
821
843
  track(n, Promise.resolve(r).then((x) => settle(n, x, rec, checked.get(n) ?? null)));
822
844
  await running.get(n); // settled before scheduling
@@ -832,7 +854,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
832
854
  }
833
855
  save();
834
856
 
835
- 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"));
857
+ const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : baseHeld.has(n) ? baseHeld.get(n) : 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"));
836
858
  // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
837
859
  // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
838
860
  // #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
@@ -875,6 +897,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
875
897
  };
876
898
  // #4965: epic and late children (`orchestration.auto_add_children` epic|all): at most one `getChildren()` list call per poll window.
877
899
  let nextChildScan = 0, childScanFailed = false;
900
+ const skippedKids = new Set(); // #4982: one row per untrusted child, not one per scan
901
+ const skipChild = ({ number, reason }) => {
902
+ if (skippedKids.has(number)) return;
903
+ skippedKids.add(number);
904
+ update({ issue: number, outcome: "skipped", detail: `#${number} not auto-added: ${reason} (orchestration.auto_add_children)` });
905
+ };
878
906
  const drainChildren = async () => {
879
907
  if (!getChildren || !addsEpicChildren(autoChildren?.mode)) return 0;
880
908
  const t = now();
@@ -886,7 +914,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
886
914
  return 0;
887
915
  }
888
916
  childScanFailed = false;
889
- const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])] })
917
+ const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])], onSkip: skipChild })
890
918
  .filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
891
919
  if (!fresh.length) return 0;
892
920
  for (const n of pending) {
@@ -970,6 +998,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
970
998
  // #4773: re-read the issues carrying the blocked label; one decision and row per change. A failed read keeps the last set.
971
999
  let blocked = new Set();
972
1000
  const blockedHit = new Set(); // blocked issues that were pending this pass
1001
+ // #5031: `baseWait(n)` (async, default off) returns a detail string while a dependency's merge commit is not yet on n's base, else null.
1002
+ // The issue is admitted but not dispatched; it is re-checked every pass until released (a commit that landed stays landed). Fail-open.
1003
+ const baseHeld = new Map(), baseHit = new Set(), baseDone = new Set();
1004
+ const refreshBaseWaits = async () => {
1005
+ if (!baseWait) return;
1006
+ for (const n of pending) {
1007
+ if (baseDone.has(n) || results.has(n)) continue;
1008
+ let why = null;
1009
+ try { why = (await baseWait(n)) ?? null; } catch { why = null; }
1010
+ if (why && !baseHeld.has(n)) update({ issue: n, outcome: "held", detail: why });
1011
+ if (why) { baseHeld.set(n, why); continue; }
1012
+ if (baseHeld.delete(n)) { decided({ issue: n, what: "released: dependency commit is on the base", why: "the awaited merge commit landed", alternative: "keep waiting" }); update({ issue: n, outcome: "unblocked", detail: "dependency commit landed" }); }
1013
+ baseDone.add(n);
1014
+ }
1015
+ };
973
1016
  const refreshBlocked = async () => {
974
1017
  if (!getBlocked) return;
975
1018
  let got;
@@ -1106,8 +1149,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1106
1149
  integHeld.clear();
1107
1150
  opHeldHit.clear();
1108
1151
  blockedHit.clear();
1152
+ baseHit.clear();
1109
1153
  refreshHeld();
1110
1154
  await refreshBlocked();
1155
+ await refreshBaseWaits();
1111
1156
  checkGates();
1112
1157
  // #4866: lane preflight. A failing check holds admission (all lanes, or only remote ones by its scope), says why once per batch,
1113
1158
  // and is re-checked every pass so the batch resumes on its own; it is not an engine-error, so the breaker never sees it.
@@ -1125,6 +1170,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1125
1170
  if (pfAll || (pfRemote && isRemote(n))) return false;
1126
1171
  if (opHeld.has(n)) { opHeldHit.add(n); return false; }
1127
1172
  if (blocked.has(n)) { blockedHit.add(n); return false; } // #4773
1173
+ if (baseHeld.has(n)) { baseHit.add(n); return false; } // #5031
1128
1174
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
1129
1175
  if (integration?.holds) {
1130
1176
  let why = null;
@@ -1204,6 +1250,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1204
1250
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1205
1251
  if ((getAdded || getRequeued || (getChildren && addsEpicChildren(autoChildren?.mode))) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
1206
1252
  if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
1253
+ if (baseWait && pending.length && baseHeld.size) wake = Math.min(wake ?? pollMs, pollMs); // #5031: a landed commit releases on the next tick
1207
1254
  if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
1208
1255
  if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
1209
1256
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
@@ -1217,6 +1264,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1217
1264
  else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
1218
1265
  else if (pending.length && (pfAll || pfRemote)) await nap(Math.min(wake ?? pollMs, pollMs)); // #4866: a failing lane preflight is re-checked on the next poll
1219
1266
  else if (pending.length && blockedHit.size) await nap(wake ?? pollMs); // #4773: only blocked issues wait: poll for the label's removal
1267
+ else if (pending.length && baseHit.size) await nap(wake ?? pollMs); // #5031: only issues waiting for a base commit: poll for it
1220
1268
  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
1221
1269
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
1222
1270
  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
@@ -0,0 +1,17 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** Map `items` through async `fn` with at most `limit` in flight; results keep input order. A throw rejects after the in-flight calls settle. */
4
+ export async function mapLimit(items, limit, fn) {
5
+ const list = [...items], out = new Array(list.length);
6
+ const width = Math.max(1, Math.min(Math.floor(Number(limit)) || 1, list.length));
7
+ let next = 0, failed = false, error;
8
+ const worker = async () => {
9
+ while (!failed && next < list.length) {
10
+ const i = next++;
11
+ try { out[i] = await fn(list[i], i); } catch (e) { if (!failed) { failed = true; error = e; } }
12
+ }
13
+ };
14
+ await Promise.all(Array.from({ length: width }, worker));
15
+ if (failed) throw error;
16
+ return out;
17
+ }
@@ -0,0 +1,27 @@
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 { mapLimit } from "./pmap.mjs";
6
+
7
+ describe("mapLimit", () => {
8
+ it("bounds concurrency and keeps input order", async () => {
9
+ let live = 0, peak = 0;
10
+ const out = await mapLimit([5, 1, 4, 2, 3, 6], 3, async (n) => {
11
+ live++; peak = Math.max(peak, live);
12
+ await new Promise((r) => setTimeout(r, n * 3));
13
+ live--; return n * 10;
14
+ });
15
+ assert.deepEqual(out, [50, 10, 40, 20, 30, 60]);
16
+ assert.equal(peak, 3);
17
+ });
18
+ it("handles empty input and a bad limit as serial", async () => {
19
+ assert.deepEqual(await mapLimit([], 4, async () => 1), []);
20
+ let live = 0, peak = 0;
21
+ await mapLimit([1, 2, 3], 0, async () => { live++; peak = Math.max(peak, live); await new Promise((r) => setTimeout(r, 2)); live--; });
22
+ assert.equal(peak, 1);
23
+ });
24
+ it("rejects with the first error", async () => {
25
+ await assert.rejects(mapLimit([1, 2, 3], 2, async (n) => { if (n === 2) throw new Error("boom"); return n; }), /boom/);
26
+ });
27
+ });
@@ -96,7 +96,9 @@ describe("post-deploy named checks (#4626)", () => {
96
96
  assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
97
97
  assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
98
98
  const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
99
- assert.ok(applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q).problems.some((p) => /check id/.test(p)));
99
+ const deferred = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q); // #5020: no check id defers to the orchestrator
100
+ assert.deepEqual(deferred.problems, []);
101
+ assert.equal(deferred.contract.requirements[0].disposition, "defer");
100
102
  const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
101
103
  assert.equal(ok.contract.requirements[0].check, "smoke");
102
104
  const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
@@ -101,6 +101,12 @@ function moduleNeeds(exe, args) {
101
101
  * probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
102
102
  * or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
103
103
  */
104
+ /** #5020: the contract's runnable commands: each validate entry plus the repro_test, which fails the same ways. */
105
+ export function contractCommands(contract) {
106
+ const out = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string");
107
+ const t = contract?.repro_test;
108
+ return t && typeof t === "object" && typeof t.command === "string" && t.command.trim() ? [...out, { id: "repro_test", command: t.command }] : out;
109
+ }
104
110
  export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
105
111
  const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
106
112
  const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
@@ -115,8 +121,7 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
115
121
  : `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
116
122
  byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
117
123
  };
118
- for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
119
- if (!v?.id || typeof v.command !== "string") continue;
124
+ for (const v of contractCommands(contract)) {
120
125
  for (const seg of shellSegments(v.command)) {
121
126
  let i = 0;
122
127
  while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
@@ -238,7 +243,7 @@ const porcelain = (out) => new Map(String(out ?? "").split("\n").filter((l) => l
238
243
  */
239
244
  export async function runPreflight({ contract, run, git, env = {}, repoHas = () => false, knownPath = () => false, skipIds = [], now = () => Date.now(), perCommandMs = RUN_PROBE_MS, budgetMs = RUN_BUDGET_MS }) {
240
245
  const misses = [], skipped = [], reverted = [];
241
- const todo = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string" && !skipIds.includes(v.id));
246
+ const todo = contractCommands(contract).filter((v) => !skipIds.includes(v.id));
242
247
  if (!todo.length) return { misses, skipped, reverted };
243
248
  const status = async () => porcelain(await git(["status", "--porcelain", "--untracked-files=all"]).catch(() => ""));
244
249
  const before = await status(), deadline = now() + budgetMs;
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
7
+ import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract, contractCommands } from "./preflight.mjs";
8
8
  import { runCheckCommand } from "./effects.mjs";
9
9
  import { repoTracks } from "./conformance.mjs";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
@@ -120,3 +120,15 @@ describe("testRunnerProblems (#4857)", () => {
120
120
  assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
121
121
  });
122
122
  });
123
+
124
+ describe("#5020 repro_test is probed like a validate command", () => {
125
+ const c = { validate: [{ id: "V1", command: "node --test a.test.mjs" }], repro_test: { path: "t.py", id: "t", command: "madeuptool --run t.py" } };
126
+ it("contractCommands lists validate entries then the repro_test", () => {
127
+ assert.deepEqual(contractCommands(c).map((x) => x.id), ["V1", "repro_test"]);
128
+ assert.deepEqual(contractCommands({ repro_test: "none" }), []);
129
+ });
130
+ it("preflightProbes probes the repro command's tool", () => {
131
+ const { probes } = preflightProbes(c);
132
+ assert.ok(probes.some((p) => p.tool === "madeuptool" && p.ids.includes("repro_test")));
133
+ });
134
+ });
@@ -37,6 +37,12 @@ export const signalRef = (fp) => `<!-- FORGE:SIGNAL_REF fp=${fp} -->`;
37
37
  /** The run of FORGE marker comments at the very end of a body: the only place the engine writes markers, so the only place they are trusted. */
38
38
  export const trailerOf = (body) => /(?:<!--\s*FORGE:[^>]*?-->\s*)+$/.exec(String(body ?? ""))?.[0] ?? "";
39
39
 
40
+ /** Untrusted text is escaped so a literal comment opener cannot forge a FORGE marker in a body or prompt. */
41
+ export const neutralize = (s) => String(s ?? "").replace(/<!--/g, "&lt;!--");
42
+
43
+ /** The first issue whose trailing marker run holds `marker` and that passes `accept`; a marker quoted mid-body is not trusted. */
44
+ export const findMarked = (issues, marker, accept = () => true) => (issues ?? []).find((i) => trailerOf(i?.body).includes(marker) && accept(i));
45
+
40
46
  /** fingerprint -> issue number for every open issue carrying a SIGNAL_REF marker in its trailer (first issue wins). */
41
47
  function refsOf(openIssues) {
42
48
  const map = new Map();
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { readFileSync } from "node:fs";
6
- import { fingerprint, signalRef, cluster, parseAlertRules, classifyCluster, validateCluster, tallyClusters, CLASSES, DEFAULT_RULES, trailerOf } from "./prodwatch-triage.mjs";
6
+ import { fingerprint, signalRef, cluster, parseAlertRules, classifyCluster, validateCluster, tallyClusters, CLASSES, DEFAULT_RULES, trailerOf, neutralize, findMarked } from "./prodwatch-triage.mjs";
7
7
  import { parseSources, normalizeMemo } from "./prodwatch-sources.mjs";
8
8
  import { loadPolicy } from "./decision-engine.mjs";
9
9
 
@@ -132,3 +132,16 @@ describe("trailer-only markers (#4879)", () => {
132
132
  assert.equal(cluster(sig, [real])[0].duplicateOf, 10);
133
133
  });
134
134
  });
135
+
136
+ describe("neutralize and findMarked", () => {
137
+ it("neutralize escapes a comment opener so a marker cannot be forged", () => {
138
+ assert.equal(neutralize("a <!-- FORGE:X -->"), "a &lt;!-- FORGE:X -->");
139
+ assert.equal(neutralize(null), "");
140
+ });
141
+ it("findMarked trusts the trailer only and honours accept", () => {
142
+ const m = "<!-- FORGE:M -->";
143
+ const issues = [{ number: 1, body: `quote ${m}\nmore` }, { number: 2, body: `text\n${m}` }];
144
+ assert.equal(findMarked(issues, m)?.number, 2);
145
+ assert.equal(findMarked(issues, m, () => false), undefined);
146
+ });
147
+ });
@@ -15,7 +15,7 @@ import { writeAtomic } from "./batch.mjs";
15
15
  import { acquireLock, watchDir, loadWatches } from "./issuewatch.mjs";
16
16
  import { loadPolicy } from "./decision-engine.mjs";
17
17
  import { parseSources, runAdapter } from "./prodwatch-sources.mjs";
18
- import { cluster, fingerprint, classifyCluster, validateCluster, tallyClusters, parseAlertRules, signalRef, trailerOf } from "./prodwatch-triage.mjs";
18
+ import { cluster, fingerprint, classifyCluster, validateCluster, tallyClusters, parseAlertRules, signalRef, trailerOf, neutralize } from "./prodwatch-triage.mjs";
19
19
  import { admit } from "./prodwatch-governor.mjs";
20
20
  import { computeMetrics, sourceMode, emitMetrics } from "./prodwatch-metrics.mjs";
21
21
 
@@ -80,8 +80,6 @@ export function targetLabel(cfg, raw = "") {
80
80
  // ── pure helpers ───────────────────────────────────────────────────────────────────────────
81
81
  const SOURCE_TAG = /<!--\s*FORGE:PRODWATCH\s+source=(\S+?)\s*-->/;
82
82
  const FP_TAG = /FORGE:SIGNAL_REF\s+fp=([0-9a-f]+)/;
83
- /** Signal text is untrusted: a literal comment opener could forge a FORGE marker, so it is escaped before it reaches a body or prompt. */
84
- const neutralize = (s) => String(s ?? "").replace(/<!--/g, "&lt;!--");
85
83
  const oneLine = (s, n) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
86
84
 
87
85
  /** Rows for computeMetrics from the prod-watch issues (open and closed) that carry our markers: GitHub is the register. */
@@ -246,7 +246,7 @@ export function contextPackNote(pack) {
246
246
  /** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
247
247
  export function unrunnableValidateNote(testRunners = null) {
248
248
  const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
249
- return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
249
+ return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments. A post-deploy requirement needs a registered check id; one that is a manual or production-credential action with no runnable check is disposition "defer" with bucket "owner:<action>" (the engine also converts a check-less post-deploy to a defer to the orchestrator). forge.yaml validate.recipes rewrite matching commands at freeze.`;
250
250
  }
251
251
 
252
252
  export function reproPlanNote(on) {
@@ -9,7 +9,7 @@ import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
10
10
  import { RECOVER_HOW } from "./lean.mjs";
11
11
  import { recoveryEvidence } from "./render.mjs";
12
- import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
12
+ import { RECOVERABLE_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
13
13
  import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
14
14
  // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
15
15
  process.env.FORGEDOCK_SCHED_DIR = tmpDir("recovery-sched-");
@@ -96,12 +96,29 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
96
96
  assert.ok(formatOutcomeLists(rows).some((l) => /^unrecovered: #1 \(budget\.exceeded: Implement turn budget spent/.test(l)));
97
97
  });
98
98
 
99
+ it("conformance gaps are recoverable: one recovery lane each, regression/guard_broken/scope_creep/e2e_unverified stay terminal, notes name the defer route", async () => {
100
+ const codes = ["conformance.unimplemented", "conformance.omission", "conformance.unjustified_deferral"];
101
+ for (const c of codes) {
102
+ assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: c }), "recoverable", c);
103
+ assert.ok(RECOVERABLE_CODES.includes(c) && RECOVER_HOW[c], c);
104
+ }
105
+ for (const c of ["conformance.regression", "conformance.guard_broken", "conformance.scope_creep", "conformance.e2e_unverified"]) {
106
+ assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: c }), "terminal", c);
107
+ }
108
+ for (const c of codes.slice(1)) assert.match(RECOVER_HOW[c], /`defer`[\s\S]*why|why[\s\S]*follow-up/i);
109
+ assert.match(RECOVER_HOW["conformance.omission"], /add-requirement/);
110
+ const { rows, calls } = await batch({ 1: [NH("conformance.omission", "R2 not covered", { branch: "feat/issue-1-lean" }), NH("conformance.omission", "again")] });
111
+ assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover?.code ?? null), [null, "conformance.omission"]);
112
+ assert.equal(calls.filter((c) => c.n === 1).length, 2, "no second recovery beyond max_attempts");
113
+ assert.equal(byIssue(rows)[1].outcome, "needs-human");
114
+ });
115
+
99
116
  it("human codes (product-decision, spend) are never recovered, still skip dependents and are the only escalations, asked once while other lanes keep running", async () => {
100
117
  const { rows, calls, decisions } = await batch({
101
118
  1: [NH("builder.blocked", "Builder is blocked (product-decision): pick a pricing model")],
102
119
  2: [],
103
120
  3: [NH("builder.blocked", "Builder is blocked (spend): needs a paid plan")],
104
- 4: [NH("conformance.omission", "R2 not covered")],
121
+ 4: [NH("conformance.regression", "R2 broke")],
105
122
  5: [{ terminalReason: "merged" }] }, { 2: "**Depends on**: #1" });
106
123
  const r = byIssue(rows);
107
124
  assert.equal(calls.filter((c) => c.n === 1).length, 1); assert.equal(calls.filter((c) => c.n === 3).length, 1);
@@ -114,7 +131,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
114
131
  assert.match(esc, /#1 \(builder\.blocked\): Builder is blocked \(product-decision\)/);
115
132
  assert.match(esc, /#3 \(builder\.blocked\): Builder is blocked \(spend\)/);
116
133
  assert.doesNotMatch(esc, /#4/, "the needs-human list holds only human codes");
117
- assert.ok(lines.some((l) => /^unrecovered: #4 \(conformance\.omission: R2 not covered\)$/.test(l)));
134
+ assert.ok(lines.some((l) => /^unrecovered: #4 \(conformance\.regression: R2 broke\)$/.test(l)));
118
135
  });
119
136
 
120
137
  it("base.conflict: a recovery base-sync with a clean result re-queues the issue; the report lists recovered issues separately", async () => {
@@ -224,7 +241,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
224
241
  it("/orchestrate spec step 3 lists the recoverable codes, asks no confirmation on them, and escalates once at the end", async () => {
225
242
  const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
226
243
  const step3 = spec.slice(spec.indexOf("## 3. Launch lanes"), spec.indexOf("## 4. Report"));
227
- for (const c of ["`ci.red`", "`base.conflict`", "`ci-pending` on a conflicting", "`contract.invalid` caused by an issue edited mid-run"]) assert.ok(step3.includes(c), c);
244
+ for (const c of ["`ci.red`", "`base.conflict`", "`ci-pending` on a conflicting", "`contract.invalid` caused by an issue edited mid-run", "`conformance.unimplemented`", "`conformance.omission`", "`conformance.unjustified_deferral`"]) assert.ok(step3.includes(c), c);
228
245
  assert.match(step3, /\*\*Do not ask for confirmation on them\.\*\*/);
229
246
  assert.match(step3, /FORGE:DECISION/);
230
247
  assert.match(step3, /one question list[\s\S]*once, at the end of the batch/);
@@ -32,6 +32,17 @@ export async function retrying(fn, { transient, permanent = null, attempts = 4,
32
32
  throw last;
33
33
  }
34
34
 
35
+ /** Poll `probe` until it returns a truthy value (returned) or `timeoutMs` has passed (null). The probe runs at least once. */
36
+ export async function pollUntil(probe, { timeoutMs, pollMs, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now() }) {
37
+ const t0 = now();
38
+ for (;;) {
39
+ const v = await probe();
40
+ if (v) return v;
41
+ if (now() - t0 > timeoutMs) return null;
42
+ await sleep(pollMs);
43
+ }
44
+ }
45
+
35
46
  /**
36
47
  * #4507: run `call(env)` with the lane's gh cache env; on a gh parse error (corrupt cache) reset the cache and run it
37
48
  * once more. Without a cache it still retries once. Any other error is rethrown unchanged.
@@ -0,0 +1,16 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { pollUntil } from "./retry.mjs";
4
+
5
+ test("pollUntil returns the first truthy probe value, sleeping between tries", async () => {
6
+ let n = 0, t = 0; const sleeps = [];
7
+ const v = await pollUntil(() => (++n >= 3 ? "ok" : null), { timeoutMs: 100, pollMs: 7, sleep: async (ms) => { sleeps.push(ms); t += ms; }, now: () => t });
8
+ assert.equal(v, "ok"); assert.deepEqual(sleeps, [7, 7]);
9
+ });
10
+
11
+ test("pollUntil returns null once the timeout passes, probing at least once", async () => {
12
+ let t = 0, n = 0;
13
+ const v = await pollUntil(() => { n++; return null; }, { timeoutMs: 20, pollMs: 10, sleep: async (ms) => { t += ms; }, now: () => t });
14
+ assert.equal(v, null); assert.ok(n >= 3);
15
+ assert.equal(await pollUntil(() => null, { timeoutMs: -1, pollMs: 1, sleep: async () => {}, now: () => 0 }), null);
16
+ });
@@ -189,6 +189,16 @@ describe("remote adapter (#4254)", () => {
189
189
  await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
190
190
  });
191
191
 
192
+ it("#5053 httpTransport: a 401 on status is transient and names its hop; a launch 401 is not transient", async () => {
193
+ const reply = (status, body) => ({ status, ok: status < 300, text: async () => JSON.stringify(body), headers: { get: () => null } });
194
+ const t = httpTransport({ endpoint: "https://pool.example", token: TOKEN, fetchFn: async () => reply(401, { error: "unauthorized", hop: "agent", agent: "a1", reason: "agent auth: router token rejected by agent" }) });
195
+ await assert.rejects(t.status("x"), (e) => e.transient === true && e.hop === "agent" && e.reason === "router token rejected by agent" && /agent a1 auth: router token rejected/.test(e.message) && !e.message.includes(TOKEN));
196
+ await assert.rejects(t.result("x"), (e) => e.transient === true && e.status === 401);
197
+ await assert.rejects(t.launch({ id: "x" }), (e) => e.transient !== true && e.status === 401);
198
+ const r = httpTransport({ endpoint: "https://pool.example", fetchFn: async () => reply(401, { hop: "router" }) });
199
+ await assert.rejects(r.status("x"), /router auth/);
200
+ });
201
+
192
202
  it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
193
203
  const calls = [];
194
204
  const reply = (status, body, headers = {}) => ({ status, ok: status >= 200 && status < 300, text: async () => (body === undefined ? "" : JSON.stringify(body)), headers: { get: (k) => headers[k] ?? null } });
@@ -320,10 +330,11 @@ describe("pool provider (#4546)", () => {
320
330
 
321
331
  describe("remote capacity (GET /status, #4540)", () => {
322
332
  const mk = (res) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => { if (res instanceof Error) throw res; if (url.endsWith("/agents")) return { status: 404, ok: false, text: async () => "", headers: { get: () => null } }; assert.equal(url, "https://pool.example/v1/status"); return { status: res.status, ok: res.status < 400, text: async () => res.body ?? "", headers: { get: () => null } }; } });
323
- it("free integer is reported; 404, malformed, negative and transport errors are unknown (null)", async () => {
333
+ it("free integer is reported; 404, malformed and negative are unknown (null); an unreachable router throws (#5048)", async () => {
324
334
  assert.deepEqual(await mk({ status: 200, body: '{"free":3}' }).capacity(), { free: 3 });
325
335
  assert.deepEqual(await mk({ status: 200, body: '{"free":0}' }).capacity(), { free: 0 });
326
- for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }, new Error("down")]) assert.equal(await mk(r).capacity(), null);
336
+ for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }]) assert.equal(await mk(r).capacity(), null);
337
+ await assert.rejects(() => mk(new Error("down")).capacity(), (e) => e.unreachable === true && /pool router unreachable/.test(e.message));
327
338
  });
328
339
  it("#4788: no online on /status: derived from /agents (ok && !paused); an unreadable /agents stays unknown", async () => {
329
340
  const mkA = (status, agents) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => {
@@ -337,12 +348,12 @@ describe("remote capacity (GET /status, #4540)", () => {
337
348
  assert.deepEqual(await mkA(200, down).capacity(), { online: 0, total: 1 });
338
349
  assert.equal(await mkA(404, "nope").capacity(), null);
339
350
  });
340
- it("remoteProvider.capacity delegates, and is null without a transport capability or on a throw", async () => {
351
+ it("remoteProvider.capacity delegates, is null without a transport capability, and passes a transport error on (#5048)", async () => {
341
352
  const batch = openBatch(join(tmpDir("cap-"), "batches"), "bt");
342
353
  const p = (transport) => remoteProvider({ batch, repo: "o/r", transport, env: {}, credentials: () => null });
343
354
  assert.deepEqual(await p({ capacity: async () => ({ free: 1 }) }).capacity(), { free: 1 });
344
355
  assert.equal(await p({}).capacity(), null);
345
- assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
356
+ await assert.rejects(() => p({ capacity: async () => { throw new Error("x"); } }).capacity(), /x/);
346
357
  });
347
358
  });
348
359