forgedock 1.43.0 → 1.44.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 (58) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +59 -10
  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/journey.mjs +2 -2
  8. package/bin/lean/autochildren.mjs +13 -5
  9. package/bin/lean/autochildren.test.mjs +36 -18
  10. package/bin/lean/batch.mjs +3 -1
  11. package/bin/lean/batch.test.mjs +83 -0
  12. package/bin/lean/conformance.mjs +72 -13
  13. package/bin/lean/conformance.test.mjs +100 -9
  14. package/bin/lean/decision-engine.mjs +1 -1
  15. package/bin/lean/decision-engine.test.mjs +11 -0
  16. package/bin/lean/effects.mjs +1 -1
  17. package/bin/lean/execution.test.mjs +9 -6
  18. package/bin/lean/gatewatch.test.mjs +5 -5
  19. package/bin/lean/issuewatch.mjs +2 -2
  20. package/bin/lean/lane.mjs +22 -4
  21. package/bin/lean/lane.test.mjs +33 -1
  22. package/bin/lean/lean.mjs +65 -17
  23. package/bin/lean/lean.test.mjs +84 -2
  24. package/bin/lean/orchestrate-blocked.test.mjs +28 -0
  25. package/bin/lean/orchestrate-children.test.mjs +16 -4
  26. package/bin/lean/orchestrate.mjs +58 -11
  27. package/bin/lean/pmap.mjs +17 -0
  28. package/bin/lean/pmap.test.mjs +27 -0
  29. package/bin/lean/probe.mjs +1 -1
  30. package/bin/lean/prodwatch-triage.mjs +6 -0
  31. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  32. package/bin/lean/prodwatch.mjs +1 -3
  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/sched.mjs +25 -2
  37. package/bin/lean/sched.test.mjs +58 -1
  38. package/bin/lean/schedd.mjs +38 -5
  39. package/bin/lean/schedd.test.mjs +109 -0
  40. package/bin/lean/selftest-live.mjs +57 -32
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +8 -4
  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 +14 -2
  47. package/bin/lean/signoff.test.mjs +45 -1
  48. package/bin/pool/agent.mjs +1 -1
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/runner.mjs +1 -1
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +9 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +3 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/ci-local/run.mjs +1 -1
  58. 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) => {
@@ -500,6 +518,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
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
519
  let cap = null;
502
520
  try { cap = remote.capacity?.() ?? null; } catch { cap = null; }
521
+ if (typeof remote.capacity === "function") notePool(cap === null, "capacity probe failed");
503
522
  // #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
504
523
  if (cap && cap.online === 0) {
505
524
  poolOfflineSince ??= now();
@@ -585,7 +604,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
585
604
  try { remote?.onUsage?.(n, r.sandbox, rec); } catch { /* the batch total still counts it */ }
586
605
  }
587
606
  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); };
607
+ const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); endRemote(n); };
589
608
  if (row.outcome === "dependency-open" && depList.length) {
590
609
  // #4387: a parked dependency is not pulled in again (fail-open on an unreadable label).
591
610
  let parked;
@@ -633,6 +652,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
633
652
  if (row.outcome === "deferred") {
634
653
  const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
635
654
  let capGated = false; // #4909: the pool reports free slots, so the slot (not the retry-in timer) gates the relaunch
655
+ if (r?.code === "sandbox.unreachable") notePool(true, r.detail ?? "sandbox unreachable");
636
656
  if (r?.code === "sandbox.capacity") {
637
657
  capWait.refused(n);
638
658
  try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
@@ -781,6 +801,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
781
801
  try {
782
802
  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
803
  Object.assign(rec, info ?? {}); save();
804
+ if (rec.remote && remoteRun.has(n)) noteRemote(n, rec.handle);
784
805
  if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
785
806
  if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
786
807
  } });
@@ -805,18 +826,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
805
826
  for (const a of adopting) a.live = a.first ? await a.first : false;
806
827
  for (const { n, rec, p, live } of adopting) {
807
828
  if (live) {
808
- if (rec.remote) remoteRun.add(n);
829
+ if (rec.remote) { remoteRun.add(n); noteRemote(n, rec.handle); }
809
830
  else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
810
831
  track(n, p.then((r) => {
811
832
  if (r) return settle(n, r, rec, checked.get(n) ?? null);
812
833
  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
834
+ running.delete(n); releaseSlot(n); endRemote(n); requeueLost(n); save(); // ended without a result after all
814
835
  }));
815
836
  continue;
816
837
  }
817
838
  const r = await p;
818
839
  if (!r) { requeueLost(n); continue; }
819
- if (rec.remote) remoteRun.add(n);
840
+ if (rec.remote) { remoteRun.add(n); noteRemote(n, rec.handle); }
820
841
  else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); } // released by the settle
821
842
  track(n, Promise.resolve(r).then((x) => settle(n, x, rec, checked.get(n) ?? null)));
822
843
  await running.get(n); // settled before scheduling
@@ -832,7 +853,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
832
853
  }
833
854
  save();
834
855
 
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"));
856
+ 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
857
  // #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
837
858
  // lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
838
859
  // #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 +896,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
875
896
  };
876
897
  // #4965: epic and late children (`orchestration.auto_add_children` epic|all): at most one `getChildren()` list call per poll window.
877
898
  let nextChildScan = 0, childScanFailed = false;
899
+ const skippedKids = new Set(); // #4982: one row per untrusted child, not one per scan
900
+ const skipChild = ({ number, reason }) => {
901
+ if (skippedKids.has(number)) return;
902
+ skippedKids.add(number);
903
+ update({ issue: number, outcome: "skipped", detail: `#${number} not auto-added: ${reason} (orchestration.auto_add_children)` });
904
+ };
878
905
  const drainChildren = async () => {
879
906
  if (!getChildren || !addsEpicChildren(autoChildren?.mode)) return 0;
880
907
  const t = now();
@@ -886,7 +913,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
886
913
  return 0;
887
914
  }
888
915
  childScanFailed = false;
889
- const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])] })
916
+ const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])], onSkip: skipChild })
890
917
  .filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
891
918
  if (!fresh.length) return 0;
892
919
  for (const n of pending) {
@@ -970,6 +997,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
970
997
  // #4773: re-read the issues carrying the blocked label; one decision and row per change. A failed read keeps the last set.
971
998
  let blocked = new Set();
972
999
  const blockedHit = new Set(); // blocked issues that were pending this pass
1000
+ // #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.
1001
+ // The issue is admitted but not dispatched; it is re-checked every pass until released (a commit that landed stays landed). Fail-open.
1002
+ const baseHeld = new Map(), baseHit = new Set(), baseDone = new Set();
1003
+ const refreshBaseWaits = async () => {
1004
+ if (!baseWait) return;
1005
+ for (const n of pending) {
1006
+ if (baseDone.has(n) || results.has(n)) continue;
1007
+ let why = null;
1008
+ try { why = (await baseWait(n)) ?? null; } catch { why = null; }
1009
+ if (why && !baseHeld.has(n)) update({ issue: n, outcome: "held", detail: why });
1010
+ if (why) { baseHeld.set(n, why); continue; }
1011
+ 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" }); }
1012
+ baseDone.add(n);
1013
+ }
1014
+ };
973
1015
  const refreshBlocked = async () => {
974
1016
  if (!getBlocked) return;
975
1017
  let got;
@@ -1106,8 +1148,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1106
1148
  integHeld.clear();
1107
1149
  opHeldHit.clear();
1108
1150
  blockedHit.clear();
1151
+ baseHit.clear();
1109
1152
  refreshHeld();
1110
1153
  await refreshBlocked();
1154
+ await refreshBaseWaits();
1111
1155
  checkGates();
1112
1156
  // #4866: lane preflight. A failing check holds admission (all lanes, or only remote ones by its scope), says why once per batch,
1113
1157
  // 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 +1169,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1125
1169
  if (pfAll || (pfRemote && isRemote(n))) return false;
1126
1170
  if (opHeld.has(n)) { opHeldHit.add(n); return false; }
1127
1171
  if (blocked.has(n)) { blockedHit.add(n); return false; } // #4773
1172
+ if (baseHeld.has(n)) { baseHit.add(n); return false; } // #5031
1128
1173
  if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
1129
1174
  if (integration?.holds) {
1130
1175
  let why = null;
@@ -1204,6 +1249,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1204
1249
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1205
1250
  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
1251
  if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
1252
+ if (baseWait && pending.length && baseHeld.size) wake = Math.min(wake ?? pollMs, pollMs); // #5031: a landed commit releases on the next tick
1207
1253
  if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
1208
1254
  if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
1209
1255
  if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
@@ -1217,6 +1263,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1217
1263
  else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
1218
1264
  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
1265
  else if (pending.length && blockedHit.size) await nap(wake ?? pollMs); // #4773: only blocked issues wait: poll for the label's removal
1266
+ else if (pending.length && baseHit.size) await nap(wake ?? pollMs); // #5031: only issues waiting for a base commit: poll for it
1220
1267
  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
1268
  else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
1222
1269
  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
+ });
@@ -179,7 +179,7 @@ export function probeEnv(allow = [], env = process.env) {
179
179
  export function spawnProbe({ run, cwd, env, timeoutMs }) {
180
180
  return new Promise((resolve) => {
181
181
  let stdout = "", stderr = "", timedOut = false;
182
- const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
182
+ const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
183
183
  const cap = (s, d) => (s + d).slice(-OUTPUT_TAIL * 4);
184
184
  child.stdout.on("data", (d) => { stdout = cap(stdout, d); });
185
185
  child.stderr.on("data", (d) => { stderr = cap(stderr, d); });
@@ -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. */
@@ -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
+ });
@@ -144,12 +144,14 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
144
144
  }
145
145
  const obj = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : {});
146
146
  const weights = Object.fromEntries(Object.entries(obj(o?.weights)).filter(([, n]) => posInt(n)));
147
- return { v: 1, cap: posInt(o?.cap), weights, seq: Number.isInteger(o?.seq) ? o.seq : 0, slots: obj(o?.slots), queue: obj(o?.queue) };
147
+ return { v: 1, cap: posInt(o?.cap), weights, seq: Number.isInteger(o?.seq) ? o.seq : 0, slots: obj(o?.slots), queue: obj(o?.queue), remote: obj(o?.remote), pool: obj(o?.pool) };
148
148
  };
149
149
  // Drop queued entries of dead dispatchers, and slots whose dispatcher and lane are both gone.
150
150
  const reclaim = (s) => {
151
151
  for (const [k, e] of Object.entries(s.queue)) if (!alive(e.pid)) delete s.queue[k];
152
152
  for (const [k, e] of Object.entries(s.slots)) if (!alive(e.pid) && !(e.lanePid && alive(e.lanePid))) delete s.slots[k];
153
+ for (const [k, e] of Object.entries(s.remote)) if (!alive(e?.pid)) delete s.remote[k]; // #4964
154
+ for (const [k, e] of Object.entries(s.pool)) if (!alive(e?.pid)) delete s.pool[k];
153
155
  };
154
156
  const mutate = (fn) => {
155
157
  lock();
@@ -200,7 +202,18 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
200
202
  /** Queue entries `[{key, batch, repo, issue, priority}]` (their FIFO position is kept on a re-offer). */
201
203
  offer: (entries) => { try { mutate((s) => { for (const e of entries) enqueue(s, e.key, e); }); } catch { /* fail-open */ } },
202
204
  withdraw: (keys) => { if (!keys.length) return; try { mutate((s) => { for (const k of keys) delete s.queue[k]; }); } catch { /* fail-open */ } },
203
- release: (key) => { try { mutate((s) => { delete s.slots[key]; delete s.queue[key]; }); } catch { /* fail-open */ } },
205
+ release: (key) => { try { mutate((s) => { delete s.slots[key]; delete s.queue[key]; delete s.remote[key]; }); } catch { /* fail-open */ } },
206
+ /** #4964: publish a running remote (pool) lane for `sched show`; it never takes a host slot. */
207
+ noteRemote: (key, entry = {}) => {
208
+ try {
209
+ mutate((s) => { s.remote[key] = { pid, batch: entry.batch ?? null, repo: entry.repo ?? null, issue: entry.issue ?? null, sandbox: entry.sandbox ?? null, agent: entry.agent ?? null, at: now() }; });
210
+ } catch { /* fail-open */ }
211
+ },
212
+ dropRemote: (key) => { try { mutate((s) => { delete s.remote[key]; }); } catch { /* fail-open */ } },
213
+ /** #4964: this batch's pool is unreachable (`{reason}`), or null to clear it. */
214
+ notePool: (batch, state) => {
215
+ try { mutate((s) => { if (state) s.pool[batch] = { pid, state: "unreachable", reason: state.reason ?? null, at: now() }; else delete s.pool[batch]; }); } catch { /* fail-open */ }
216
+ },
204
217
  setLanePid: (key, lanePid) => { try { mutate((s) => { if (s.slots[key] && posInt(lanePid)) s.slots[key].lanePid = lanePid; }); } catch { /* fail-open */ } },
205
218
  /** Record a lane this dispatcher re-attached to: it runs already, so the cap does not gate it. */
206
219
  adoptSlot: (key, entry = {}) => {
@@ -231,6 +244,16 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
231
244
  weights: { ...s.weights },
232
245
  slots: Object.entries(s.slots).map(([key, e]) => ({ key, ...e })),
233
246
  queue: grantOrder(s).map(([key, e]) => ({ key, ...e })),
247
+ remote: Object.entries(s.remote).map(([key, e]) => ({ key, ...e })),
248
+ pool: Object.entries(s.pool).map(([batch, e]) => ({ batch, ...e })),
234
249
  })),
235
250
  };
236
251
  }
252
+
253
+ /** #4964: `sched show` lines for remote (pool) lanes and an unreachable pool; empty when there are none. */
254
+ export function remoteLines(st) {
255
+ const out = (st?.remote ?? []).map((e) => ` remote #${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} sandbox ${e.sandbox ?? "-"} agent ${e.agent ?? "-"} dispatcher ${e.pid}`);
256
+ const down = st?.pool ?? [];
257
+ if (down.length) out.push(`pool: unreachable (${down.map((e) => `batch ${e.batch}${e.reason ? `: ${e.reason}` : ""}`).join("; ")})`);
258
+ return out;
259
+ }
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { grantOrder, openSched, priorityFromLabels } from "./sched.mjs";
8
+ import { grantOrder, openSched, priorityFromLabels, remoteLines } from "./sched.mjs";
9
9
  import { orchestrate, estimateLaneMinutes, laneHistory, formatEstimateSummary, EST_DEFAULT_MIN, POST_DEPLOY_EST_MIN } from "./orchestrate.mjs";
10
10
  import { schedFromCli } from "../engine-cli.mjs";
11
11
 
@@ -512,3 +512,60 @@ describe("live local lane cap (#4777)", () => {
512
512
  assert.equal(peak, 2);
513
513
  });
514
514
  });
515
+
516
+ describe("sched show lists remote (pool) lanes (#4964)", () => {
517
+ const show = async (sched) => { const lines = []; const l = console.log; console.log = (m) => lines.push(String(m)); try { const st = await schedFromCli(["show"], { sched }); return { st, lines }; } finally { console.log = l; } };
518
+
519
+ it("remote lanes get their own rows and a remote N count, and take no host slot or cap", async () => {
520
+ const { handle } = world();
521
+ const a = handle(501), b = handle(502);
522
+ a.setCap(1);
523
+ a.noteRemote("B#2", { batch: "B", repo: "o/r", issue: 2, sandbox: "fdrsbx-B-2", agent: "legion" });
524
+ a.noteRemote("B#3", { batch: "B", repo: "o/r", issue: 3, sandbox: "fdrsbx-B-3" });
525
+ const { st, lines } = await show(b);
526
+ assert.equal(st.slots.length, 0);
527
+ assert.equal(st.remote.length, 2);
528
+ assert.ok(lines.some((m) => /running 0, queued 0, remote 2$/.test(m)), lines.join("\n"));
529
+ assert.ok(lines.includes(" remote #2 o/r batch B sandbox fdrsbx-B-2 agent legion dispatcher 501"), lines.join("\n"));
530
+ assert.equal(b.tryAcquire("C#1", {}, null).ok, true, "cap 1 is still free: remote lanes hold no slot");
531
+ a.dropRemote("B#2");
532
+ assert.deepEqual(b.list().remote.map((r) => r.key), ["B#3"]);
533
+ a.release("B#3");
534
+ assert.equal(b.list().remote.length, 0);
535
+ });
536
+
537
+ it("a dead dispatcher's remote lane and pool notice are reclaimed", () => {
538
+ const { handle, live } = world();
539
+ const a = handle(511), b = handle(512);
540
+ a.noteRemote("B#2", { batch: "B", issue: 2 });
541
+ a.notePool("B", { reason: "capacity probe failed" });
542
+ assert.equal(b.list().remote.length, 1);
543
+ live.delete(511);
544
+ assert.deepEqual([b.list().remote.length, b.list().pool.length], [0, 0]);
545
+ });
546
+
547
+ it("an unreachable pool is one line; none once cleared", async () => {
548
+ const { handle } = world();
549
+ const a = handle(521);
550
+ a.notePool("B", { reason: "capacity probe failed" });
551
+ const down = (await show(a)).lines.filter((m) => m.startsWith("pool:"));
552
+ assert.deepEqual(down, ["pool: unreachable (batch B: capacity probe failed)"]);
553
+ a.notePool("B", null);
554
+ assert.equal((await show(a)).lines.filter((m) => m.startsWith("pool:")).length, 0);
555
+ assert.deepEqual(remoteLines({}), []);
556
+ });
557
+
558
+ it("orchestrate publishes a remote lane while it runs, drops it after, and never acquires a slot", async () => {
559
+ const { handle } = world();
560
+ const reg = handle(531);
561
+ let seen = null, acquired = 0;
562
+ const spy = { ...reg, tryAcquire: (...x) => { acquired++; return reg.tryAcquire(...x); } };
563
+ await orchestrate({ issues: [7], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry: spy, batch: "B", repo: "o/r" },
564
+ remote: { max: 1, isRemote: () => true, capacity: () => ({ free: 1, online: 1 }) },
565
+ runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: "fdrsbx-B-7", agent: "legion" }, remote: true }); seen = reg.list(); return { terminalReason: "merged" }; } });
566
+ assert.equal(acquired, 0);
567
+ assert.deepEqual(seen.remote.map((r) => [r.issue, r.sandbox, r.agent]), [[7, "fdrsbx-B-7", "legion"]]);
568
+ assert.equal(seen.slots.length, 0);
569
+ assert.equal(reg.list().remote.length, 0);
570
+ });
571
+ });