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