forgedock 1.43.1 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +2 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +50 -11
- 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 +107 -16
- package/bin/lean/conformance.test.mjs +126 -13
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -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 +49 -16
- package/bin/lean/lean.test.mjs +66 -0
- package/bin/lean/orchestrate-blocked.test.mjs +35 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +61 -13
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- 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/prompts.mjs +1 -1
- 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/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- 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 +16 -3
- package/bin/lean/signoff.test.mjs +52 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/cli.mjs +29 -1
- package/bin/pool/router.mjs +17 -6
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +10 -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
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) => {
|
|
@@ -498,8 +516,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
498
516
|
const adm = admitRemote();
|
|
499
517
|
if (adm.ok) {
|
|
500
518
|
// #4540: the provider's free slots (`remote.capacity() -> {free, at}`, sync, null = unknown) cap admissions per sample `at`; the issue queues here instead of a 503.
|
|
501
|
-
let cap = null;
|
|
502
|
-
try { cap = remote.capacity?.() ?? null; } catch {
|
|
519
|
+
let cap = null, probeFailed = false;
|
|
520
|
+
try { cap = remote.capacity?.() ?? null; } catch { probeFailed = true; }
|
|
521
|
+
// #5048: only a failed probe (a throw) marks the pool down; null is unknown (first pass, no capacity()) and leaves the state; a sample clears it.
|
|
522
|
+
if (probeFailed) notePool(true, "capacity probe failed"); else if (cap !== null) notePool(false);
|
|
503
523
|
// #4788: zero live agents for longer than `remote.poolOfflineMin` (default 10): one loud batch event, none while online > 0.
|
|
504
524
|
if (cap && cap.online === 0) {
|
|
505
525
|
poolOfflineSince ??= now();
|
|
@@ -585,7 +605,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
585
605
|
try { remote?.onUsage?.(n, r.sandbox, rec); } catch { /* the batch total still counts it */ }
|
|
586
606
|
}
|
|
587
607
|
if (sandboxUse.has(n)) row.sandbox = { ...sandboxUse.get(n), ...(sandboxUse.get(n).by_agent ? { by_agent: { ...sandboxUse.get(n).by_agent } } : {}) };
|
|
588
|
-
const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n);
|
|
608
|
+
const done = () => { running.delete(n); runRecs.delete(n); releaseSlot(n); endRemote(n); };
|
|
589
609
|
if (row.outcome === "dependency-open" && depList.length) {
|
|
590
610
|
// #4387: a parked dependency is not pulled in again (fail-open on an unreadable label).
|
|
591
611
|
let parked;
|
|
@@ -633,6 +653,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
633
653
|
if (row.outcome === "deferred") {
|
|
634
654
|
const pol = deferPolicy(r, { now: now(), deferrals: deferrals.get(n) ?? 0, maxDeferrals });
|
|
635
655
|
let capGated = false; // #4909: the pool reports free slots, so the slot (not the retry-in timer) gates the relaunch
|
|
656
|
+
if (r?.code === "sandbox.unreachable") notePool(true, r.detail ?? "sandbox unreachable");
|
|
636
657
|
if (r?.code === "sandbox.capacity") {
|
|
637
658
|
capWait.refused(n);
|
|
638
659
|
try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
|
|
@@ -781,6 +802,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
781
802
|
try {
|
|
782
803
|
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= (recovering.get(n).max ?? recoveryMax) } } : {}), onStart: (info) => {
|
|
783
804
|
Object.assign(rec, info ?? {}); save();
|
|
805
|
+
if (rec.remote && remoteRun.has(n)) noteRemote(n, rec.handle);
|
|
784
806
|
if (cancelled) stop(n, rec); // #4376: launched while the cancel arrived
|
|
785
807
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
786
808
|
} });
|
|
@@ -805,18 +827,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
805
827
|
for (const a of adopting) a.live = a.first ? await a.first : false;
|
|
806
828
|
for (const { n, rec, p, live } of adopting) {
|
|
807
829
|
if (live) {
|
|
808
|
-
if (rec.remote) remoteRun.add(n);
|
|
830
|
+
if (rec.remote) { remoteRun.add(n); noteRemote(n, rec.handle); }
|
|
809
831
|
else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); }
|
|
810
832
|
track(n, p.then((r) => {
|
|
811
833
|
if (r) return settle(n, r, rec, checked.get(n) ?? null);
|
|
812
834
|
if (halted) return; // #4454: kept for the next resume
|
|
813
|
-
running.delete(n); releaseSlot(n);
|
|
835
|
+
running.delete(n); releaseSlot(n); endRemote(n); requeueLost(n); save(); // ended without a result after all
|
|
814
836
|
}));
|
|
815
837
|
continue;
|
|
816
838
|
}
|
|
817
839
|
const r = await p;
|
|
818
840
|
if (!r) { requeueLost(n); continue; }
|
|
819
|
-
if (rec.remote) remoteRun.add(n);
|
|
841
|
+
if (rec.remote) { remoteRun.add(n); noteRemote(n, rec.handle); }
|
|
820
842
|
else if (reg) { reg.adoptSlot(keyOf(n), { ...entryOf(n), lanePid: rec.pid }); slotted.add(n); } // released by the settle
|
|
821
843
|
track(n, Promise.resolve(r).then((x) => settle(n, x, rec, checked.get(n) ?? null)));
|
|
822
844
|
await running.get(n); // settled before scheduling
|
|
@@ -832,7 +854,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
832
854
|
}
|
|
833
855
|
save();
|
|
834
856
|
|
|
835
|
-
const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (integHeld.has(n) ? `integration: ${integHeld.get(n)}` : (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run"));
|
|
857
|
+
const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : baseHeld.has(n) ? baseHeld.get(n) : tripped?.detail ?? (integHeld.has(n) ? `integration: ${integHeld.get(n)}` : (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run"));
|
|
836
858
|
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
837
859
|
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
838
860
|
// #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
|
|
@@ -875,6 +897,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
875
897
|
};
|
|
876
898
|
// #4965: epic and late children (`orchestration.auto_add_children` epic|all): at most one `getChildren()` list call per poll window.
|
|
877
899
|
let nextChildScan = 0, childScanFailed = false;
|
|
900
|
+
const skippedKids = new Set(); // #4982: one row per untrusted child, not one per scan
|
|
901
|
+
const skipChild = ({ number, reason }) => {
|
|
902
|
+
if (skippedKids.has(number)) return;
|
|
903
|
+
skippedKids.add(number);
|
|
904
|
+
update({ issue: number, outcome: "skipped", detail: `#${number} not auto-added: ${reason} (orchestration.auto_add_children)` });
|
|
905
|
+
};
|
|
878
906
|
const drainChildren = async () => {
|
|
879
907
|
if (!getChildren || !addsEpicChildren(autoChildren?.mode)) return 0;
|
|
880
908
|
const t = now();
|
|
@@ -886,7 +914,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
886
914
|
return 0;
|
|
887
915
|
}
|
|
888
916
|
childScanFailed = false;
|
|
889
|
-
const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])] })
|
|
917
|
+
const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])], onSkip: skipChild })
|
|
890
918
|
.filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
|
|
891
919
|
if (!fresh.length) return 0;
|
|
892
920
|
for (const n of pending) {
|
|
@@ -970,6 +998,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
970
998
|
// #4773: re-read the issues carrying the blocked label; one decision and row per change. A failed read keeps the last set.
|
|
971
999
|
let blocked = new Set();
|
|
972
1000
|
const blockedHit = new Set(); // blocked issues that were pending this pass
|
|
1001
|
+
// #5031: `baseWait(n)` (async, default off) returns a detail string while a dependency's merge commit is not yet on n's base, else null.
|
|
1002
|
+
// The issue is admitted but not dispatched; it is re-checked every pass until released (a commit that landed stays landed). Fail-open.
|
|
1003
|
+
const baseHeld = new Map(), baseHit = new Set(), baseDone = new Set();
|
|
1004
|
+
const refreshBaseWaits = async () => {
|
|
1005
|
+
if (!baseWait) return;
|
|
1006
|
+
for (const n of pending) {
|
|
1007
|
+
if (baseDone.has(n) || results.has(n)) continue;
|
|
1008
|
+
let why = null;
|
|
1009
|
+
try { why = (await baseWait(n)) ?? null; } catch { why = null; }
|
|
1010
|
+
if (why && !baseHeld.has(n)) update({ issue: n, outcome: "held", detail: why });
|
|
1011
|
+
if (why) { baseHeld.set(n, why); continue; }
|
|
1012
|
+
if (baseHeld.delete(n)) { decided({ issue: n, what: "released: dependency commit is on the base", why: "the awaited merge commit landed", alternative: "keep waiting" }); update({ issue: n, outcome: "unblocked", detail: "dependency commit landed" }); }
|
|
1013
|
+
baseDone.add(n);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
973
1016
|
const refreshBlocked = async () => {
|
|
974
1017
|
if (!getBlocked) return;
|
|
975
1018
|
let got;
|
|
@@ -1106,8 +1149,10 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1106
1149
|
integHeld.clear();
|
|
1107
1150
|
opHeldHit.clear();
|
|
1108
1151
|
blockedHit.clear();
|
|
1152
|
+
baseHit.clear();
|
|
1109
1153
|
refreshHeld();
|
|
1110
1154
|
await refreshBlocked();
|
|
1155
|
+
await refreshBaseWaits();
|
|
1111
1156
|
checkGates();
|
|
1112
1157
|
// #4866: lane preflight. A failing check holds admission (all lanes, or only remote ones by its scope), says why once per batch,
|
|
1113
1158
|
// and is re-checked every pass so the batch resumes on its own; it is not an engine-error, so the breaker never sees it.
|
|
@@ -1125,6 +1170,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1125
1170
|
if (pfAll || (pfRemote && isRemote(n))) return false;
|
|
1126
1171
|
if (opHeld.has(n)) { opHeldHit.add(n); return false; }
|
|
1127
1172
|
if (blocked.has(n)) { blockedHit.add(n); return false; } // #4773
|
|
1173
|
+
if (baseHeld.has(n)) { baseHit.add(n); return false; } // #5031
|
|
1128
1174
|
if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
|
|
1129
1175
|
if (integration?.holds) {
|
|
1130
1176
|
let why = null;
|
|
@@ -1204,6 +1250,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1204
1250
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
1205
1251
|
if ((getAdded || getRequeued || (getChildren && addsEpicChildren(autoChildren?.mode))) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
1206
1252
|
if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
|
|
1253
|
+
if (baseWait && pending.length && baseHeld.size) wake = Math.min(wake ?? pollMs, pollMs); // #5031: a landed commit releases on the next tick
|
|
1207
1254
|
if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
|
|
1208
1255
|
if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
|
|
1209
1256
|
if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
|
|
@@ -1217,6 +1264,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1217
1264
|
else if (pending.length && offered.size) await nap(wake); // waiting for a host slot
|
|
1218
1265
|
else if (pending.length && (pfAll || pfRemote)) await nap(Math.min(wake ?? pollMs, pollMs)); // #4866: a failing lane preflight is re-checked on the next poll
|
|
1219
1266
|
else if (pending.length && blockedHit.size) await nap(wake ?? pollMs); // #4773: only blocked issues wait: poll for the label's removal
|
|
1267
|
+
else if (pending.length && baseHit.size) await nap(wake ?? pollMs); // #5031: only issues waiting for a base commit: poll for it
|
|
1220
1268
|
else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
|
|
1221
1269
|
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
1222
1270
|
else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** Map `items` through async `fn` with at most `limit` in flight; results keep input order. A throw rejects after the in-flight calls settle. */
|
|
4
|
+
export async function mapLimit(items, limit, fn) {
|
|
5
|
+
const list = [...items], out = new Array(list.length);
|
|
6
|
+
const width = Math.max(1, Math.min(Math.floor(Number(limit)) || 1, list.length));
|
|
7
|
+
let next = 0, failed = false, error;
|
|
8
|
+
const worker = async () => {
|
|
9
|
+
while (!failed && next < list.length) {
|
|
10
|
+
const i = next++;
|
|
11
|
+
try { out[i] = await fn(list[i], i); } catch (e) { if (!failed) { failed = true; error = e; } }
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
await Promise.all(Array.from({ length: width }, worker));
|
|
15
|
+
if (failed) throw error;
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mapLimit } from "./pmap.mjs";
|
|
6
|
+
|
|
7
|
+
describe("mapLimit", () => {
|
|
8
|
+
it("bounds concurrency and keeps input order", async () => {
|
|
9
|
+
let live = 0, peak = 0;
|
|
10
|
+
const out = await mapLimit([5, 1, 4, 2, 3, 6], 3, async (n) => {
|
|
11
|
+
live++; peak = Math.max(peak, live);
|
|
12
|
+
await new Promise((r) => setTimeout(r, n * 3));
|
|
13
|
+
live--; return n * 10;
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual(out, [50, 10, 40, 20, 30, 60]);
|
|
16
|
+
assert.equal(peak, 3);
|
|
17
|
+
});
|
|
18
|
+
it("handles empty input and a bad limit as serial", async () => {
|
|
19
|
+
assert.deepEqual(await mapLimit([], 4, async () => 1), []);
|
|
20
|
+
let live = 0, peak = 0;
|
|
21
|
+
await mapLimit([1, 2, 3], 0, async () => { live++; peak = Math.max(peak, live); await new Promise((r) => setTimeout(r, 2)); live--; });
|
|
22
|
+
assert.equal(peak, 1);
|
|
23
|
+
});
|
|
24
|
+
it("rejects with the first error", async () => {
|
|
25
|
+
await assert.rejects(mapLimit([1, 2, 3], 2, async (n) => { if (n === 2) throw new Error("boom"); return n; }), /boom/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -96,7 +96,9 @@ describe("post-deploy named checks (#4626)", () => {
|
|
|
96
96
|
assert.ok(validateContract(c, Q).problems.some((p) => /needs a check id/.test(p)));
|
|
97
97
|
assert.ok(!validateContract({ ...c, requirements: [{ ...c.requirements[0], check: "smoke" }] }, Q).problems.some((p) => /check id/.test(p)));
|
|
98
98
|
const frozen = { ...c, requirements: [{ id: "R1", quote: Q, disposition: "deliver" }] };
|
|
99
|
-
|
|
99
|
+
const deferred = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w" }], Q); // #5020: no check id defers to the orchestrator
|
|
100
|
+
assert.deepEqual(deferred.problems, []);
|
|
101
|
+
assert.equal(deferred.contract.requirements[0].disposition, "defer");
|
|
100
102
|
const ok = applyAmendments(frozen, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "smoke" }], Q);
|
|
101
103
|
assert.equal(ok.contract.requirements[0].check, "smoke");
|
|
102
104
|
const pc = precheck({ contract: { ...c, requirements: [{ ...c.requirements[0], check: "ghost" }] }, result: { requirements: [] }, changedFiles: [], checkIds: new Set(["smoke"]) });
|
package/bin/lean/preflight.mjs
CHANGED
|
@@ -101,6 +101,12 @@ function moduleNeeds(exe, args) {
|
|
|
101
101
|
* probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
|
|
102
102
|
* or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
|
|
103
103
|
*/
|
|
104
|
+
/** #5020: the contract's runnable commands: each validate entry plus the repro_test, which fails the same ways. */
|
|
105
|
+
export function contractCommands(contract) {
|
|
106
|
+
const out = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string");
|
|
107
|
+
const t = contract?.repro_test;
|
|
108
|
+
return t && typeof t === "object" && typeof t.command === "string" && t.command.trim() ? [...out, { id: "repro_test", command: t.command }] : out;
|
|
109
|
+
}
|
|
104
110
|
export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
|
|
105
111
|
const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
|
|
106
112
|
const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
|
|
@@ -115,8 +121,7 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
|
|
|
115
121
|
: `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
|
|
116
122
|
byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
|
|
117
123
|
};
|
|
118
|
-
for (const v of
|
|
119
|
-
if (!v?.id || typeof v.command !== "string") continue;
|
|
124
|
+
for (const v of contractCommands(contract)) {
|
|
120
125
|
for (const seg of shellSegments(v.command)) {
|
|
121
126
|
let i = 0;
|
|
122
127
|
while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
|
|
@@ -238,7 +243,7 @@ const porcelain = (out) => new Map(String(out ?? "").split("\n").filter((l) => l
|
|
|
238
243
|
*/
|
|
239
244
|
export async function runPreflight({ contract, run, git, env = {}, repoHas = () => false, knownPath = () => false, skipIds = [], now = () => Date.now(), perCommandMs = RUN_PROBE_MS, budgetMs = RUN_BUDGET_MS }) {
|
|
240
245
|
const misses = [], skipped = [], reverted = [];
|
|
241
|
-
const todo = (
|
|
246
|
+
const todo = contractCommands(contract).filter((v) => !skipIds.includes(v.id));
|
|
242
247
|
if (!todo.length) return { misses, skipped, reverted };
|
|
243
248
|
const status = async () => porcelain(await git(["status", "--porcelain", "--untracked-files=all"]).catch(() => ""));
|
|
244
249
|
const before = await status(), deadline = now() + budgetMs;
|
|
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
7
|
+
import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract, contractCommands } from "./preflight.mjs";
|
|
8
8
|
import { runCheckCommand } from "./effects.mjs";
|
|
9
9
|
import { repoTracks } from "./conformance.mjs";
|
|
10
10
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
@@ -120,3 +120,15 @@ describe("testRunnerProblems (#4857)", () => {
|
|
|
120
120
|
assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
describe("#5020 repro_test is probed like a validate command", () => {
|
|
125
|
+
const c = { validate: [{ id: "V1", command: "node --test a.test.mjs" }], repro_test: { path: "t.py", id: "t", command: "madeuptool --run t.py" } };
|
|
126
|
+
it("contractCommands lists validate entries then the repro_test", () => {
|
|
127
|
+
assert.deepEqual(contractCommands(c).map((x) => x.id), ["V1", "repro_test"]);
|
|
128
|
+
assert.deepEqual(contractCommands({ repro_test: "none" }), []);
|
|
129
|
+
});
|
|
130
|
+
it("preflightProbes probes the repro command's tool", () => {
|
|
131
|
+
const { probes } = preflightProbes(c);
|
|
132
|
+
assert.ok(probes.some((p) => p.tool === "madeuptool" && p.ids.includes("repro_test")));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -37,6 +37,12 @@ export const signalRef = (fp) => `<!-- FORGE:SIGNAL_REF fp=${fp} -->`;
|
|
|
37
37
|
/** The run of FORGE marker comments at the very end of a body: the only place the engine writes markers, so the only place they are trusted. */
|
|
38
38
|
export const trailerOf = (body) => /(?:<!--\s*FORGE:[^>]*?-->\s*)+$/.exec(String(body ?? ""))?.[0] ?? "";
|
|
39
39
|
|
|
40
|
+
/** Untrusted text is escaped so a literal comment opener cannot forge a FORGE marker in a body or prompt. */
|
|
41
|
+
export const neutralize = (s) => String(s ?? "").replace(/<!--/g, "<!--");
|
|
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. */
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -246,7 +246,7 @@ export function contextPackNote(pack) {
|
|
|
246
246
|
/** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
|
|
247
247
|
export function unrunnableValidateNote(testRunners = null) {
|
|
248
248
|
const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
|
|
249
|
-
return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
|
|
249
|
+
return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments. A post-deploy requirement needs a registered check id; one that is a manual or production-credential action with no runnable check is disposition "defer" with bucket "owner:<action>" (the engine also converts a check-less post-deploy to a defer to the orchestrator). forge.yaml validate.recipes rewrite matching commands at freeze.`;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
export function reproPlanNote(on) {
|
|
@@ -9,7 +9,7 @@ import { tmpDir } from "../tests/_tmp.mjs";
|
|
|
9
9
|
import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
|
|
10
10
|
import { RECOVER_HOW } from "./lean.mjs";
|
|
11
11
|
import { recoveryEvidence } from "./render.mjs";
|
|
12
|
-
import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
12
|
+
import { RECOVERABLE_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
|
|
13
13
|
import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
|
|
14
14
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
15
15
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("recovery-sched-");
|
|
@@ -96,12 +96,29 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
96
96
|
assert.ok(formatOutcomeLists(rows).some((l) => /^unrecovered: #1 \(budget\.exceeded: Implement turn budget spent/.test(l)));
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
it("conformance gaps are recoverable: one recovery lane each, regression/guard_broken/scope_creep/e2e_unverified stay terminal, notes name the defer route", async () => {
|
|
100
|
+
const codes = ["conformance.unimplemented", "conformance.omission", "conformance.unjustified_deferral"];
|
|
101
|
+
for (const c of codes) {
|
|
102
|
+
assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: c }), "recoverable", c);
|
|
103
|
+
assert.ok(RECOVERABLE_CODES.includes(c) && RECOVER_HOW[c], c);
|
|
104
|
+
}
|
|
105
|
+
for (const c of ["conformance.regression", "conformance.guard_broken", "conformance.scope_creep", "conformance.e2e_unverified"]) {
|
|
106
|
+
assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: c }), "terminal", c);
|
|
107
|
+
}
|
|
108
|
+
for (const c of codes.slice(1)) assert.match(RECOVER_HOW[c], /`defer`[\s\S]*why|why[\s\S]*follow-up/i);
|
|
109
|
+
assert.match(RECOVER_HOW["conformance.omission"], /add-requirement/);
|
|
110
|
+
const { rows, calls } = await batch({ 1: [NH("conformance.omission", "R2 not covered", { branch: "feat/issue-1-lean" }), NH("conformance.omission", "again")] });
|
|
111
|
+
assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover?.code ?? null), [null, "conformance.omission"]);
|
|
112
|
+
assert.equal(calls.filter((c) => c.n === 1).length, 2, "no second recovery beyond max_attempts");
|
|
113
|
+
assert.equal(byIssue(rows)[1].outcome, "needs-human");
|
|
114
|
+
});
|
|
115
|
+
|
|
99
116
|
it("human codes (product-decision, spend) are never recovered, still skip dependents and are the only escalations, asked once while other lanes keep running", async () => {
|
|
100
117
|
const { rows, calls, decisions } = await batch({
|
|
101
118
|
1: [NH("builder.blocked", "Builder is blocked (product-decision): pick a pricing model")],
|
|
102
119
|
2: [],
|
|
103
120
|
3: [NH("builder.blocked", "Builder is blocked (spend): needs a paid plan")],
|
|
104
|
-
4: [NH("conformance.
|
|
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
|
+
});
|
|
@@ -189,6 +189,16 @@ describe("remote adapter (#4254)", () => {
|
|
|
189
189
|
await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
it("#5053 httpTransport: a 401 on status is transient and names its hop; a launch 401 is not transient", async () => {
|
|
193
|
+
const reply = (status, body) => ({ status, ok: status < 300, text: async () => JSON.stringify(body), headers: { get: () => null } });
|
|
194
|
+
const t = httpTransport({ endpoint: "https://pool.example", token: TOKEN, fetchFn: async () => reply(401, { error: "unauthorized", hop: "agent", agent: "a1", reason: "agent auth: router token rejected by agent" }) });
|
|
195
|
+
await assert.rejects(t.status("x"), (e) => e.transient === true && e.hop === "agent" && e.reason === "router token rejected by agent" && /agent a1 auth: router token rejected/.test(e.message) && !e.message.includes(TOKEN));
|
|
196
|
+
await assert.rejects(t.result("x"), (e) => e.transient === true && e.status === 401);
|
|
197
|
+
await assert.rejects(t.launch({ id: "x" }), (e) => e.transient !== true && e.status === 401);
|
|
198
|
+
const r = httpTransport({ endpoint: "https://pool.example", fetchFn: async () => reply(401, { hop: "router" }) });
|
|
199
|
+
await assert.rejects(r.status("x"), /router auth/);
|
|
200
|
+
});
|
|
201
|
+
|
|
192
202
|
it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
|
|
193
203
|
const calls = [];
|
|
194
204
|
const reply = (status, body, headers = {}) => ({ status, ok: status >= 200 && status < 300, text: async () => (body === undefined ? "" : JSON.stringify(body)), headers: { get: (k) => headers[k] ?? null } });
|
|
@@ -320,10 +330,11 @@ describe("pool provider (#4546)", () => {
|
|
|
320
330
|
|
|
321
331
|
describe("remote capacity (GET /status, #4540)", () => {
|
|
322
332
|
const mk = (res) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => { if (res instanceof Error) throw res; if (url.endsWith("/agents")) return { status: 404, ok: false, text: async () => "", headers: { get: () => null } }; assert.equal(url, "https://pool.example/v1/status"); return { status: res.status, ok: res.status < 400, text: async () => res.body ?? "", headers: { get: () => null } }; } });
|
|
323
|
-
it("free integer is reported; 404, malformed
|
|
333
|
+
it("free integer is reported; 404, malformed and negative are unknown (null); an unreachable router throws (#5048)", async () => {
|
|
324
334
|
assert.deepEqual(await mk({ status: 200, body: '{"free":3}' }).capacity(), { free: 3 });
|
|
325
335
|
assert.deepEqual(await mk({ status: 200, body: '{"free":0}' }).capacity(), { free: 0 });
|
|
326
|
-
for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }
|
|
336
|
+
for (const r of [{ status: 404 }, { status: 200, body: "nope" }, { status: 200, body: '{"free":"2"}' }, { status: 200, body: '{"free":-1}' }]) assert.equal(await mk(r).capacity(), null);
|
|
337
|
+
await assert.rejects(() => mk(new Error("down")).capacity(), (e) => e.unreachable === true && /pool router unreachable/.test(e.message));
|
|
327
338
|
});
|
|
328
339
|
it("#4788: no online on /status: derived from /agents (ok && !paused); an unreadable /agents stays unknown", async () => {
|
|
329
340
|
const mkA = (status, agents) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url) => {
|
|
@@ -337,12 +348,12 @@ describe("remote capacity (GET /status, #4540)", () => {
|
|
|
337
348
|
assert.deepEqual(await mkA(200, down).capacity(), { online: 0, total: 1 });
|
|
338
349
|
assert.equal(await mkA(404, "nope").capacity(), null);
|
|
339
350
|
});
|
|
340
|
-
it("remoteProvider.capacity delegates,
|
|
351
|
+
it("remoteProvider.capacity delegates, is null without a transport capability, and passes a transport error on (#5048)", async () => {
|
|
341
352
|
const batch = openBatch(join(tmpDir("cap-"), "batches"), "bt");
|
|
342
353
|
const p = (transport) => remoteProvider({ batch, repo: "o/r", transport, env: {}, credentials: () => null });
|
|
343
354
|
assert.deepEqual(await p({ capacity: async () => ({ free: 1 }) }).capacity(), { free: 1 });
|
|
344
355
|
assert.equal(await p({}).capacity(), null);
|
|
345
|
-
assert.
|
|
356
|
+
await assert.rejects(() => p({ capacity: async () => { throw new Error("x"); } }).capacity(), /x/);
|
|
346
357
|
});
|
|
347
358
|
});
|
|
348
359
|
|