forgedock 1.43.1 → 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.
- package/bin/engine/outcome-codes.mjs +2 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +44 -8
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.test.mjs +46 -0
- package/bin/lean/conformance.mjs +55 -14
- package/bin/lean/conformance.test.mjs +69 -10
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +22 -6
- package/bin/lean/lean.test.mjs +31 -0
- package/bin/lean/orchestrate-blocked.test.mjs +12 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +58 -11
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/selftest-live.mjs +56 -31
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +7 -3
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +5 -3
- package/bin/lean/signoff.test.mjs +21 -0
- package/bin/pool/cli.mjs +29 -1
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +9 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +2 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
|
@@ -18,7 +18,7 @@ async function run({ mode, list, pollS = 0.001, elsewhere = [], epics = [], bodi
|
|
|
18
18
|
runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; }, ...extra });
|
|
19
19
|
return { started, log, decisions, calls: () => calls, open, done };
|
|
20
20
|
}
|
|
21
|
-
const L = (...kids) => [{ number: 1, body: "", labels: [] }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [] }))];
|
|
21
|
+
const L = (...kids) => [{ number: 1, body: "", labels: [], authorAssociation: "MEMBER" }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" }))];
|
|
22
22
|
|
|
23
23
|
describe("#4965 auto_add_children", () => {
|
|
24
24
|
it("epic: adds open children of a batch issue, one decision and one added row listing them", async () => {
|
|
@@ -40,8 +40,20 @@ describe("#4965 auto_add_children", () => {
|
|
|
40
40
|
assert.deepEqual(r.started, [1]);
|
|
41
41
|
}
|
|
42
42
|
});
|
|
43
|
+
it("#4982 an untrusted author's child is not added and logs one skipped row", async () => {
|
|
44
|
+
const list = [...L(2), { number: 3, body: "**Parent**: #1", labels: [], authorAssociation: "NONE" }];
|
|
45
|
+
const r = await run({ mode: "epic", list });
|
|
46
|
+
await until(() => r.started.includes(2) && r.log.some((x) => x.outcome === "skipped"));
|
|
47
|
+
await new Promise((x) => setTimeout(x, 40));
|
|
48
|
+
r.open(); await r.done;
|
|
49
|
+
assert.ok(!r.started.includes(3));
|
|
50
|
+
const sk = r.log.filter((x) => x.outcome === "skipped");
|
|
51
|
+
assert.equal(sk.length, 1);
|
|
52
|
+
assert.equal(sk[0].issue, 3);
|
|
53
|
+
assert.match(sk[0].detail, /#3 .*untrusted author/);
|
|
54
|
+
});
|
|
43
55
|
it("needs-human children and children live in another batch are not added", async () => {
|
|
44
|
-
const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] }];
|
|
56
|
+
const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }], authorAssociation: "MEMBER" }];
|
|
45
57
|
const r = await run({ mode: "all", list, elsewhere: [3] });
|
|
46
58
|
await until(() => r.started.includes(2));
|
|
47
59
|
await new Promise((x) => setTimeout(x, 40));
|
|
@@ -49,10 +61,10 @@ describe("#4965 auto_add_children", () => {
|
|
|
49
61
|
assert.deepEqual([...r.started].sort(), [1, 2]);
|
|
50
62
|
});
|
|
51
63
|
it("an --epic root outside the batch adds its children; late children join on a later pass", async () => {
|
|
52
|
-
let list = [{ number: 50, body: "", labels: [] }, { number: 51, body: "**Parent**: #50", labels: [] }];
|
|
64
|
+
let list = [{ number: 50, body: "", labels: [], authorAssociation: "MEMBER" }, { number: 51, body: "**Parent**: #50", labels: [], authorAssociation: "MEMBER" }];
|
|
53
65
|
const r = await run({ mode: "epic", epics: [50], list: () => list });
|
|
54
66
|
await until(() => r.started.includes(51));
|
|
55
|
-
list = [...list, { number: 52, body: "**Parent**: #50", labels: [] }];
|
|
67
|
+
list = [...list, { number: 52, body: "**Parent**: #50", labels: [], authorAssociation: "MEMBER" }];
|
|
56
68
|
await until(() => r.started.includes(52));
|
|
57
69
|
r.open(); await r.done;
|
|
58
70
|
assert.equal(r.log.filter((x) => x.outcome === "added").length, 2);
|
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
|
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);
|
|
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
|
+
});
|
|
@@ -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, "<!--");
|
|
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 <!-- 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
|
+
});
|
package/bin/lean/prodwatch.mjs
CHANGED
|
@@ -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, "<!--");
|
|
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.
|
|
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\.
|
|
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/);
|
package/bin/lean/retry.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/sched.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/lean/sched.test.mjs
CHANGED
|
@@ -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
|
+
});
|