forgedock 1.48.1 → 1.50.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-cli.mjs +49 -19
- package/bin/forgedock.mjs +1 -1
- package/bin/lean/batch.mjs +9 -0
- package/bin/lean/batch.test.mjs +174 -2
- package/bin/lean/conformance.mjs +38 -9
- package/bin/lean/conformance.test.mjs +33 -1
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +66 -20
- package/bin/lean/execution.test.mjs +133 -1
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +87 -24
- package/bin/lean/lean.test.mjs +90 -7
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +77 -20
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +21 -0
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +20 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/commands/orchestrate.md +2 -1
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +24 -7
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Thin orchestrate: run core work-on for several issues with bounded concurrency, honouring `Depends on` / `Blocked by`
|
|
5
5
|
* among the given issues (wait for the dependency to merge; skip when it ends anywhere but merged/invalid).
|
|
6
6
|
* A `dependency-open` run is requeued behind its dependencies, pulling an out-of-set one into the batch; a
|
|
7
|
-
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124)
|
|
7
|
+
* `decomposed` run adds its sub-issues and its dependents wait on them instead (#4124); an issue read later (`--add`,
|
|
8
|
+
* `--requeue`, a resume, a body re-read) resolves a decomposed dependency to its sub-issues too (#5112). A dependency that is parked
|
|
8
9
|
* (ended needs-human in this batch, or an out-of-set issue labelled needs-human per `issueLabels(n)`) is never pulled in:
|
|
9
10
|
* the issue ends `skipped` (`dependency #N needs-human`) and its dependents with it (#4387).
|
|
10
11
|
* A skipped dependent whose blocker later closes or merges outside its lane is re-admitted (#4595, needs `dispatchCheck`).
|
|
@@ -21,6 +22,8 @@
|
|
|
21
22
|
* `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
|
|
22
23
|
* limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
|
|
23
24
|
* `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
|
|
25
|
+
* #5112: with `remote.offlineFallback` (`execution.fallback_local`), an issue deferred `remote.capacityFallbackAfter` times (default
|
|
26
|
+
* `CAPACITY_FALLBACK_AFTER`) for `sandbox.capacity` runs on the host when `remote.headroom()` (host-busy guard) allows it.
|
|
24
27
|
* Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
|
|
25
28
|
* host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
|
|
26
29
|
* batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset.
|
|
@@ -28,6 +31,8 @@
|
|
|
28
31
|
* runs local in hybrid (`fallbackLocal`, local account not held) or waits for the reset; admission is not held. An `engine-error` is
|
|
29
32
|
* relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
|
|
30
33
|
* rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
|
|
34
|
+
* #5138: `remote.max`, `remote.fallbackLocal` and `remote.place` are read on every grant too (engine-cli puts live getters on
|
|
35
|
+
* them, live-execution.mjs), so `--set-max-remote` / `--set-placement` change a running batch; running lanes are not stopped.
|
|
31
36
|
* `getChildren()` + `autoChildren {mode, pollS, epics}` (#4965, `orchestration.auto_add_children`): with mode `epic`|`all`, at most once per `pollS`
|
|
32
37
|
* it reads one `{list, elsewhere}` and adds the open children (`**Parent**: #P`, sub-issues) of every batch issue and `epics` through the same
|
|
33
38
|
* path as `getAdded` (one decision and one `added` row listing the edges); mode `off` also stops the decomposed-lane join.
|
|
@@ -136,6 +141,8 @@ export const HOST_BUSY_RETRY_S = 60;
|
|
|
136
141
|
// #4788: an unreachable pool is a wait too (the lane already retried for unreachable_window_min); a stall is reported by poolStall.
|
|
137
142
|
const UNCAPPED_DEFERRALS = new Set(["host.busy", "sandbox.capacity", "sandbox.unreachable", "lane.unresolved"]);
|
|
138
143
|
export const POOL_OFFLINE_ALERT_MIN = 10;
|
|
144
|
+
// #5112: `sandbox.capacity` deferrals of one issue after which `execution.fallback_local` runs it on the host (headroom permitting).
|
|
145
|
+
export const CAPACITY_FALLBACK_AFTER = 3;
|
|
139
146
|
export const RATE_LIMIT_FALLBACK_S = 15 * 60;
|
|
140
147
|
export const DEFER_BACKOFF_S = 120;
|
|
141
148
|
export const DEFER_BACKOFF_MAX_S = 30 * 60;
|
|
@@ -274,6 +281,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
274
281
|
const notBefore = toMap(saved?.notBefore);
|
|
275
282
|
const requeues = toMap(saved?.requeues);
|
|
276
283
|
const deferrals = toMap(saved?.deferrals);
|
|
284
|
+
const capDefers = toMap(saved?.capDeferrals); // #5112: issue -> `sandbox.capacity` deferrals (uncapped, so not in `deferrals`)
|
|
285
|
+
const capFellBack = new Set(); // #5112: issues already reported as a capacity fallback (one decision each)
|
|
277
286
|
const engineErrors = toMap(saved?.engineErrors);
|
|
278
287
|
const checked = toMap(saved?.prefilter); // issue -> pre-filter result
|
|
279
288
|
const estimates = toMap(saved?.estimates); // #4409: issue -> estimated lane minutes; only with `estimate`
|
|
@@ -312,7 +321,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
312
321
|
const integHeld = new Map(); // issue -> reason (red integration streak on its milestone lane, #4667) holding it this pass
|
|
313
322
|
const reg = sched?.registry ?? null;
|
|
314
323
|
const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
|
|
315
|
-
|
|
324
|
+
// #5138: read on every grant, so `--set-max-remote` reaches a running batch (a getter on `remote`); running lanes are never stopped.
|
|
325
|
+
const remoteMax = () => { const m = remote?.max; return Number.isInteger(m) && m >= 0 ? m : 0; };
|
|
326
|
+
const remoteCapped = new Set(); // #5138: issues waiting for a max_remote slot; polled so a raised live max_remote admits them
|
|
316
327
|
let poolOfflineSince = null, poolStallNoted = false; // #4788: when sampled online first read 0, and whether the stall was reported
|
|
317
328
|
const capWait = makeCapWait({ now }); // #4909: a lane refused for capacity waits for a free slot, recorded as queue time
|
|
318
329
|
let capAt, capAdmits = 0; // #4540: provider capacity sample and the remote admissions made since it
|
|
@@ -358,7 +369,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
358
369
|
const save = () => {
|
|
359
370
|
if (!store || halted) return;
|
|
360
371
|
store.save({ v: 1, ...(setup ? { phase: "setup", setup: { ...setup } } : {}), order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
361
|
-
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}),
|
|
372
|
+
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors), ...(quota.size ? { quota: toObj(quota) } : {}), ...(capDefers.size ? { capDeferrals: toObj(capDefers) } : {}),
|
|
362
373
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
363
374
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
364
375
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering), recoveryClasses: toObj(classUsed) } : {}),
|
|
@@ -391,6 +402,26 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
391
402
|
if (typeof trusted === "function") { let ok = false; try { ok = (await trusted(n)) === true; } catch { ok = false; } if (!ok) return []; }
|
|
392
403
|
return parseDeps(body, n);
|
|
393
404
|
};
|
|
405
|
+
// #5112: a dependency that ended `decomposed` (sub-issues joined, mode not off) is its sub-issues, to a fixpoint; used on every edge write.
|
|
406
|
+
const throughDecomposed = (ds) => {
|
|
407
|
+
if (!joinsDecomposed(autoChildren?.mode ?? "decomposed")) return [...new Set(ds)];
|
|
408
|
+
const out = [], seen = new Set(), stack = [...ds].reverse();
|
|
409
|
+
while (stack.length) {
|
|
410
|
+
const d = stack.pop();
|
|
411
|
+
if (seen.has(d)) continue;
|
|
412
|
+
seen.add(d);
|
|
413
|
+
const r = results.get(d), subs = r?.outcome === "decomposed" && Array.isArray(r.subissues) ? r.subissues.filter((x) => Number.isSafeInteger(x) && x > 0) : [];
|
|
414
|
+
if (subs.length) stack.push(...[...subs].reverse()); else out.push(d);
|
|
415
|
+
}
|
|
416
|
+
return [...new Set(out)];
|
|
417
|
+
};
|
|
418
|
+
// #5112: the issue's declared edges re-read from its body (null when unreadable: the caller keeps what it has).
|
|
419
|
+
const reread = async (n) => {
|
|
420
|
+
let body;
|
|
421
|
+
try { body = await readBody(n); } catch { return null; }
|
|
422
|
+
if (drift?.holds) bodies.set(n, body);
|
|
423
|
+
return declared(n, body);
|
|
424
|
+
};
|
|
394
425
|
const readDeps = async (n) => {
|
|
395
426
|
let body = "";
|
|
396
427
|
try { body = await readBody(n); } catch { /* unreadable: no ordering info, work-on's own check still applies */ }
|
|
@@ -401,7 +432,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
401
432
|
await readPriority(n);
|
|
402
433
|
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); }
|
|
403
434
|
// deps marks the issue read (a resume skips it): set it last, so a concurrent setup save never persists a half-read issue (#4963)
|
|
404
|
-
deps.set(n, d);
|
|
435
|
+
deps.set(n, throughDecomposed(d));
|
|
405
436
|
};
|
|
406
437
|
const edges = (n) => (deps.get(n) ?? []).filter((d) => set.has(d));
|
|
407
438
|
// A milestone issue waiting on a staging fix (or the reverse) never sees that fix on its own base: surface the edge.
|
|
@@ -516,6 +547,15 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
516
547
|
}
|
|
517
548
|
return "busy";
|
|
518
549
|
};
|
|
550
|
+
// #5112: opt-in (`execution.fallback_local`): an issue the pool refused `capacityFallbackAfter` times (default CAPACITY_FALLBACK_AFTER)
|
|
551
|
+
// for `sandbox.capacity` runs on the host when the host-busy guard (`remote.headroom()`, absent = allowed) passes; not ok or a throw waits.
|
|
552
|
+
const capacityFallback = (n) => {
|
|
553
|
+
if (!remote?.offlineFallback) return false;
|
|
554
|
+
const after = Number.isInteger(remote.capacityFallbackAfter) && remote.capacityFallbackAfter > 0 ? remote.capacityFallbackAfter : CAPACITY_FALLBACK_AFTER;
|
|
555
|
+
if ((capDefers.get(n) ?? 0) < after) return false;
|
|
556
|
+
if (typeof remote.headroom !== "function") return true;
|
|
557
|
+
try { return remote.headroom()?.ok !== false; } catch { return false; }
|
|
558
|
+
};
|
|
519
559
|
// A host slot for `n` (a lane-refused issue runs nothing and needs none); refused: `n` stays queued in the registry.
|
|
520
560
|
// true: start it; false: no capacity now; "busy" (#4481): another batch runs `n`, try the next issue.
|
|
521
561
|
const grant = (n, wanted) => {
|
|
@@ -555,14 +595,21 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
555
595
|
// #4547: opt-in (`execution.fallback_local`): every pool agent offline or asleep (sampled online 0) runs the lane on the host.
|
|
556
596
|
if (remote.offlineFallback && cap && cap.online === 0) {
|
|
557
597
|
p = { where: "local", reason: `fallback to local: no pool agent online (${p.reason})` };
|
|
598
|
+
} else if (capacityFallback(n)) {
|
|
599
|
+
p = { where: "local", reason: `fallback to local: pool at capacity after ${capDefers.get(n)} deferrals (${p.reason})` };
|
|
600
|
+
if (!capFellBack.has(n)) {
|
|
601
|
+
capFellBack.add(n);
|
|
602
|
+
decided({ issue: n, what: `run #${n} on the host: the pool refused it ${capDefers.get(n)} times for capacity (execution.fallback_local)`, why: "sustained sandbox.capacity and the host has headroom",
|
|
603
|
+
alternative: "keep deferring until a pool slot frees" });
|
|
604
|
+
}
|
|
558
605
|
} else if (cap && Number.isInteger(cap.free)) {
|
|
559
606
|
if (cap.at !== capAt) { capAt = cap.at; capAdmits = 0; }
|
|
560
607
|
if (capWait.blocked(n, cap)) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
|
|
561
|
-
if (capAdmits >= cap.free && remoteRun.size < remoteMax) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
|
|
608
|
+
if (capAdmits >= cap.free && remoteRun.size < remoteMax()) { notBefore.set(n, now() + Math.max(pollMs, 1)); return false; }
|
|
562
609
|
}
|
|
563
|
-
if (p.where === "remote" && remoteRun.size < remoteMax) { capWait.admitted(n); remoteRun.add(n); capAdmits++; placed.set(n, p); return true; }
|
|
564
|
-
if (p.where === "remote" && !remote.fallbackLocal) return false;
|
|
565
|
-
if (p.where === "remote") p = { where: "local", reason: `fallback to local: max_remote ${remoteMax} reached (${p.reason})` }; // #4256: hybrid runs it on the host
|
|
610
|
+
if (p.where === "remote" && remoteRun.size < remoteMax()) { remoteCapped.delete(n); capWait.admitted(n); remoteRun.add(n); capAdmits++; placed.set(n, p); return true; }
|
|
611
|
+
if (p.where === "remote" && !remote.fallbackLocal) { remoteCapped.add(n); return false; }
|
|
612
|
+
if (p.where === "remote") p = { where: "local", reason: `fallback to local: max_remote ${remoteMax()} reached (${p.reason})` }; // #4256: hybrid runs it on the host
|
|
566
613
|
} else {
|
|
567
614
|
noteBudget(adm.reason);
|
|
568
615
|
if (remote.onBudget !== "local" && !remote.fallbackLocal) { budgetStop = adm.reason; return false; }
|
|
@@ -664,7 +711,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
664
711
|
if (row.outcome === "dependency-open" && depList.length && (requeues.get(n) ?? 0) < MAX_REQUEUES) {
|
|
665
712
|
// A scheduling fact, not a failure: add the edge, pull the dependency in, and run this issue again after it.
|
|
666
713
|
requeues.set(n, (requeues.get(n) ?? 0) + 1);
|
|
667
|
-
deps.set(n,
|
|
714
|
+
deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...depList]));
|
|
668
715
|
await addIssues(depList);
|
|
669
716
|
// Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
|
|
670
717
|
const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
|
|
@@ -686,6 +733,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
686
733
|
if (r?.code === "sandbox.unreachable") notePool(true, r.detail ?? "sandbox unreachable");
|
|
687
734
|
if (r?.code === "sandbox.capacity") {
|
|
688
735
|
capWait.refused(n);
|
|
736
|
+
capDefers.set(n, (capDefers.get(n) ?? 0) + 1); // #5112: counted for the fallback_local capacity fallback
|
|
689
737
|
try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
|
|
690
738
|
}
|
|
691
739
|
if (!pol.capped) {
|
|
@@ -929,10 +977,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
929
977
|
if (!fresh.length) return kids;
|
|
930
978
|
// An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
|
|
931
979
|
for (const n of pending) {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (drift?.holds) bodies.set(n, body);
|
|
935
|
-
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
980
|
+
const d = await reread(n);
|
|
981
|
+
if (d) deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...d]));
|
|
936
982
|
}
|
|
937
983
|
await addIssues(fresh);
|
|
938
984
|
save();
|
|
@@ -964,10 +1010,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
964
1010
|
.filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
|
|
965
1011
|
if (!fresh.length) return 0;
|
|
966
1012
|
for (const n of pending) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if (drift?.holds) bodies.set(n, body);
|
|
970
|
-
deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
|
|
1013
|
+
const d = await reread(n);
|
|
1014
|
+
if (d) deps.set(n, throughDecomposed([...(deps.get(n) ?? []), ...d]));
|
|
971
1015
|
}
|
|
972
1016
|
await addIssues(fresh);
|
|
973
1017
|
save();
|
|
@@ -980,7 +1024,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
980
1024
|
return fresh.length;
|
|
981
1025
|
};
|
|
982
1026
|
// #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
|
|
983
|
-
// return to pending, the prior rows kept in history;
|
|
1027
|
+
// return to pending, the prior rows kept in history; their `Depends on` edges are re-read from the body and replaced (#5112).
|
|
984
1028
|
const drainRequeues = async () => {
|
|
985
1029
|
if (!getRequeued) return 0;
|
|
986
1030
|
let got = [];
|
|
@@ -1000,14 +1044,25 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1000
1044
|
}
|
|
1001
1045
|
}
|
|
1002
1046
|
for (const m of moved) {
|
|
1003
|
-
for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
|
|
1047
|
+
for (const x of [requeues, deferrals, capDefers, engineErrors, recoveries, recovering]) x.delete(m);
|
|
1004
1048
|
relabelled.delete(m);
|
|
1005
1049
|
if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
|
|
1006
1050
|
if (!pending.includes(m)) requeue(m, 0);
|
|
1007
1051
|
}
|
|
1052
|
+
// #5112: the requeued issues' `Depends on` edges are re-read and replaced, so a body edit drops or repoints an edge
|
|
1053
|
+
// (a decomposed dependency resolves to its sub-issues); an unreadable body keeps the edges it had.
|
|
1054
|
+
const edited = [];
|
|
1055
|
+
for (const m of moved) {
|
|
1056
|
+
const d = await reread(m);
|
|
1057
|
+
if (!d) continue;
|
|
1058
|
+
const before = deps.get(m) ?? [], after = throughDecomposed(d);
|
|
1059
|
+
deps.set(m, after);
|
|
1060
|
+
if (before.length !== after.length || before.some((x) => !after.includes(x))) edited.push(`#${m} -> ${after.length ? after.map((x) => `#${x}`).join(", ") : "none"}`);
|
|
1061
|
+
}
|
|
1062
|
+
noteCrossLane();
|
|
1008
1063
|
save();
|
|
1009
1064
|
const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
|
|
1010
|
-
decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
|
|
1065
|
+
decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}${edited.length ? `; edges re-read: ${edited.join("; ")}` : ""}`,
|
|
1011
1066
|
why: e.reason || "operator decision", alternative: "start a second batch" });
|
|
1012
1067
|
update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
|
|
1013
1068
|
for (const m of moved) if (m !== e.issue) update({ issue: m, outcome: "requeued", detail: `dependency #${e.issue} requeued`, trigger: e.issue }); // #4687
|
|
@@ -1294,6 +1349,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1294
1349
|
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
1295
1350
|
if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
1296
1351
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
1352
|
+
if (pending.some((n) => remoteCapped.has(n))) wake = Math.min(wake ?? pollMs, pollMs); // #5138: a live --set-max-remote raise
|
|
1297
1353
|
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
|
|
1298
1354
|
if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
|
|
1299
1355
|
if (baseWait && pending.length && baseHeld.size) wake = Math.min(wake ?? pollMs, pollMs); // #5031: a landed commit releases on the next tick
|
|
@@ -1315,6 +1371,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
1315
1371
|
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
1316
1372
|
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
|
|
1317
1373
|
else if (pending.length && (driftHeld.size || integHeld.size)) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch) or a red integration streak (#4667): pending for a later batch
|
|
1374
|
+
else if (pending.some((n) => remoteCapped.has(n))) await nap(wake ?? pollMs); // #5138: live max_remote 0: wait for a raise or another placement
|
|
1318
1375
|
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
1319
1376
|
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
1320
1377
|
save();
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -463,12 +463,25 @@ export function decisionNote(comments = []) {
|
|
|
463
463
|
return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
|
|
466
|
+
/** #5099: one check line per review domain (domains from review-trigger.mjs pathDomains); mirrors the /review-pr personas. */
|
|
467
|
+
export const DOMAIN_CHECKS = Object.freeze({
|
|
468
|
+
frontend: "Dynamic API in a static/ISR tree: a headers(), cookies(), connection() or searchParams read in a layout, a not-found/error boundary or a module they import, when the route tree is static or ISR (revalidate, generateStaticParams, dynamic = 'force-static'), opts the route out of static rendering or throws at render and turns pages into HTTP 500.",
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
/** #5099: the <domain_checks> block for the domains the diff touches; "" when none. */
|
|
472
|
+
export function domainChecksBlock(domains = []) {
|
|
473
|
+
const ds = [...new Set((domains ?? []).map(String).filter(Boolean))].sort();
|
|
474
|
+
if (!ds.length) return "";
|
|
475
|
+
const lines = ds.map((d) => `- ${d}: check the diff for breakage in this domain.${DOMAIN_CHECKS[d] ? ` ${DOMAIN_CHECKS[d]}` : ""}`);
|
|
476
|
+
return `\n<domain_checks>\nThis diff touches these review domains. Report every finding below as a regressions row with path:line (it blocks the merge).\n${lines.join("\n")}\n</domain_checks>`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null, domains = [] }) {
|
|
467
480
|
const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
|
|
468
481
|
const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
|
|
469
482
|
notes.push(...decisions);
|
|
470
483
|
const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
|
|
471
|
-
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
484
|
+
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}${domainChecksBlock(domains)}`;
|
|
472
485
|
}
|
|
473
486
|
|
|
474
487
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* log REVIEW_TRIGGER, always review), enforce (skip the reviewer when not triggered), off (today's behaviour).
|
|
8
8
|
* Run directly, it reports per repo how many would-skip lanes the reviewer flagged as nonconforming.
|
|
9
9
|
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
10
11
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
|
12
13
|
import { join } from "node:path";
|
|
@@ -43,6 +44,30 @@ export function parseRiskPaths(v) {
|
|
|
43
44
|
return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** #5099: the review domains a lane can touch, from scripts/review-roster.sh path triggers. */
|
|
48
|
+
export const DOMAINS = Object.freeze(["api", "auth", "database", "frontend", "infra"]);
|
|
49
|
+
const DOMAIN_OF_TRIGGER = Object.freeze({ "frontend-path": "frontend", "auth-path": "auth", "infra-path": "infra", "route-handler": "infra", "migration-path": "database", "sdk-path": "api" });
|
|
50
|
+
const ROSTER = fileURLToPath(new URL("../../scripts/review-roster.sh", import.meta.url));
|
|
51
|
+
|
|
52
|
+
/** forge.yaml review.enforce_domains (#5099): a comma-separated string or a list -> known domains, lowercased, unique. */
|
|
53
|
+
export function parseEnforceDomains(v) {
|
|
54
|
+
return [...new Set(parseRiskPaths(v).map((x) => x.toLowerCase()).filter((x) => DOMAINS.includes(x)))].sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* #5099: the domains the changed files touch, by the path triggers of the bundled scripts/review-roster.sh (the one
|
|
59
|
+
* source of the domain path rules). Sorted, unique; [] without files; null when the script fails (the caller logs it).
|
|
60
|
+
*/
|
|
61
|
+
export function pathDomains(files = [], { script = ROSTER } = {}) {
|
|
62
|
+
const list = [...new Set((files ?? []).map(String).filter(Boolean))];
|
|
63
|
+
if (!list.length) return [];
|
|
64
|
+
let out;
|
|
65
|
+
try { out = execFileSync("bash", [script, "--files-file", "-", "--changed-lines", "1"], { input: `${list.join("\n")}\n`, encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "ignore"] }); } catch { return null; }
|
|
66
|
+
const m = /^REASON=.*?triggers:([^\n]*)$/m.exec(String(out));
|
|
67
|
+
if (!/^SELECTED_AGENTS=/m.test(String(out))) return null;
|
|
68
|
+
return [...new Set((m ? m[1].trim().split(/\s+/) : []).map((t) => DOMAIN_OF_TRIGGER[t]).filter(Boolean))].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
/**
|
|
47
72
|
* #4398: the changed files matching a declared forge.yaml review.risk_paths glob (sorted, unique). Only declared globs
|
|
48
73
|
* count, never the built-in risk words: a hit makes the conformance gate enforce for that lane. [] without globs.
|
|
@@ -7,7 +7,8 @@ import { join } from "node:path";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
-
import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
10
|
+
import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport, pathDomains, parseEnforceDomains } from "./review-trigger.mjs";
|
|
11
|
+
import { reviewerPrompt, domainChecksBlock } from "./prompts.mjs";
|
|
11
12
|
|
|
12
13
|
const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
|
|
13
14
|
const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
|
|
@@ -118,3 +119,29 @@ describe("triggerReport (#4273)", () => {
|
|
|
118
119
|
assert.deepEqual(JSON.parse(cli.stdout)["o/a"].would_skip_flagged, 1);
|
|
119
120
|
});
|
|
120
121
|
});
|
|
122
|
+
|
|
123
|
+
describe("pathDomains / domain checks (#5099)", () => {
|
|
124
|
+
it("maps review-roster.sh path triggers to domains: web/ is frontend", () => {
|
|
125
|
+
assert.deepEqual(pathDomains(["web/src/app/(site)/not-found.tsx"]), ["frontend"]);
|
|
126
|
+
assert.deepEqual(pathDomains(["src/auth/x.js", "app/api/route.ts", "db/migrations/0001.sql", "sdk/client.py"]), ["api", "auth", "database", "frontend", "infra"]);
|
|
127
|
+
assert.deepEqual(pathDomains(["src/foo.js"]), []);
|
|
128
|
+
assert.deepEqual(pathDomains([]), []);
|
|
129
|
+
});
|
|
130
|
+
it("a failing roster script is null (never throws)", () => {
|
|
131
|
+
assert.equal(pathDomains(["web/a.tsx"], { script: "/nonexistent/review-roster.sh" }), null);
|
|
132
|
+
});
|
|
133
|
+
it("parseEnforceDomains keeps known domains only", () => {
|
|
134
|
+
assert.deepEqual(parseEnforceDomains("Frontend, bogus , auth"), ["auth", "frontend"]);
|
|
135
|
+
assert.deepEqual(parseEnforceDomains(["infra", "infra"]), ["infra"]);
|
|
136
|
+
assert.deepEqual(parseEnforceDomains(undefined), []);
|
|
137
|
+
});
|
|
138
|
+
it("the reviewer prompt gains a frontend check only when frontend is touched, and is unchanged without domains", () => {
|
|
139
|
+
const args = { issue: { number: 7, title: "t", body: "b" }, contract: {}, diff: "" };
|
|
140
|
+
assert.equal(reviewerPrompt(args), reviewerPrompt({ ...args, domains: [] }));
|
|
141
|
+
assert.equal(domainChecksBlock([]), "");
|
|
142
|
+
const p = reviewerPrompt({ ...args, domains: ["frontend"] });
|
|
143
|
+
assert.ok(p.endsWith("</domain_checks>"));
|
|
144
|
+
assert.match(p, /- frontend: .*headers\(\), cookies\(\).*ISR/);
|
|
145
|
+
assert.match(p, /regressions row with path:line/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -208,6 +208,27 @@ describe("remote adapter (#4254)", () => {
|
|
|
208
208
|
await assert.rejects(r.status("x"), /router auth/);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
it("5144 httpTransport: a function token is resolved per call", async () => {
|
|
212
|
+
let cur = null;
|
|
213
|
+
const seen = [];
|
|
214
|
+
const t = httpTransport({ endpoint: "https://pool.example", token: () => cur, fetchFn: async (url, init) => { seen.push(init.headers.authorization ?? null); return { status: 200, ok: true, text: async () => JSON.stringify({ state: "running" }), headers: { get: () => null } }; } });
|
|
215
|
+
await t.status("x"); cur = "tok-new"; await t.status("x"); cur = "tok-rotated"; await t.status("x"); cur = null; await t.status("x");
|
|
216
|
+
assert.deepEqual(seen, [null, "Bearer tok-new", "Bearer tok-rotated", null]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("5144 remoteProvider: a token rotated after construction is redacted from pulled logs", async () => {
|
|
220
|
+
const batch = openBatch(join(tmpDir("remote-5144-"), "batches"), "bt");
|
|
221
|
+
const transport = fakeTransport();
|
|
222
|
+
let tok = null;
|
|
223
|
+
const p = remoteProvider({ batch, repo: "o/r", settings: { tokenEnv: "FD_TOKEN" }, transport, env: {}, credentials: (n) => (n === "FD_TOKEN" ? tok : null), gitIdentity: () => ({}) });
|
|
224
|
+
tok = "rotated-secret-789";
|
|
225
|
+
const h = await p.start({ issue: 3, args: [], env: {}, secrets: [] });
|
|
226
|
+
transport.boxes.get(h.id).log = `leak ${tok}\n`;
|
|
227
|
+
await p.status(h);
|
|
228
|
+
const log = readFileSync(batch.logFile(3), "utf8");
|
|
229
|
+
assert.equal(log.includes(tok), false);
|
|
230
|
+
});
|
|
231
|
+
|
|
211
232
|
it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
|
|
212
233
|
const calls = [];
|
|
213
234
|
const reply = (status, body, headers = {}) => ({ status, ok: status >= 200 && status < 300, text: async () => (body === undefined ? "" : JSON.stringify(body)), headers: { get: (k) => headers[k] ?? null } });
|
|
@@ -43,7 +43,8 @@ export function isActionsEndpoint(endpoint) {
|
|
|
43
43
|
/**
|
|
44
44
|
* Generic HTTP transport: POST {endpoint}/sandboxes (body = launch spec), GET /sandboxes/:id -> {state},
|
|
45
45
|
* GET /sandboxes/:id/logs?cursor=N -> {text, cursor}, GET /sandboxes/:id/result -> lane result (404 = none yet),
|
|
46
|
-
* GET /sandboxes/:id/usage -> {minutes, agent?} (agent: the pool agent that ran it, #4549), DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token
|
|
46
|
+
* GET /sandboxes/:id/usage -> {minutes, agent?} (agent: the pool agent that ran it, #4549), DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token>`
|
|
47
|
+
* (`token` is a string, or a function called per request, #5144).
|
|
47
48
|
* 429/503 on launch is a retryable capacity error (`Retry-After` seconds). Error text never carries the token.
|
|
48
49
|
* Optional `POST /sandboxes/:id/stop` (#4612) asks the pool to SIGTERM the lane so its teardown checkpoints and it reports `deferred run.interrupted`; 2xx = sent, 404/405 = unsupported or gone (never a DELETE fallback).
|
|
49
50
|
* Optional relay (#4778): `GET /sandboxes/:id/relay -> {requests: [{rid, argv}]}` lists a sandbox's pending read-only capability
|
|
@@ -53,7 +54,9 @@ export function isActionsEndpoint(endpoint) {
|
|
|
53
54
|
export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fetch }) {
|
|
54
55
|
const root = String(endpoint ?? "").replace(/\/+$/, "");
|
|
55
56
|
const call = async (method, path, body) => {
|
|
56
|
-
|
|
57
|
+
// #5144: `token` may be a function, resolved on every request so a token fixed or rotated mid-batch is the next bearer.
|
|
58
|
+
const bearer = typeof token === "function" ? token() : token;
|
|
59
|
+
const res = await fetchFn(`${root}${path}`, { method, headers: { accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
|
|
57
60
|
...(body ? { body: JSON.stringify(body) } : {}) });
|
|
58
61
|
let data = null;
|
|
59
62
|
try { const t = await res.text(); data = t ? JSON.parse(t) : null; } catch { data = null; }
|
|
@@ -195,6 +198,7 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
|
|
|
195
198
|
const id = idFor(issue);
|
|
196
199
|
// Each allowlisted secret from the env, else the keychain (#4254); nothing else from the host.
|
|
197
200
|
const resolved = {};
|
|
201
|
+
if (settings.tokenEnv) remember(credentials(settings.tokenEnv)); // #5144: a token rotated since construction is redacted too
|
|
198
202
|
for (const k of secrets) { const v = laneEnv?.[k] !== undefined && laneEnv[k] !== "" ? String(laneEnv[k]) : credentials(k); if (v) { resolved[k] = v; remember(v); } }
|
|
199
203
|
const li = args.indexOf("--lane");
|
|
200
204
|
const resultFile = `/result/${issue}.json`;
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* {op:"attach", id, resume?} → (`resume` also clears a pending cancel, #4592) log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
|
|
12
12
|
* ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
|
|
13
13
|
* {op:"set-parallel", id, k} → {type:"ok", parallel}
|
|
14
|
+
* {op:"set-execution", id, maxRemote?, mode?} → {type:"ok", execution} (#5138): live max_remote / placement (remote|hybrid|local); remote/hybrid refused for a batch with no provider
|
|
14
15
|
* {op:"requeue", id, issues, reason, by} → {type:"ok", requeued, refused, hosted, reopened} (#4550): see batch.requeue; a finished batch is hosted again
|
|
15
16
|
* {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
|
|
16
17
|
* hosted runner reads on its next pass and an unhosted batch on its next resume
|
|
@@ -42,6 +43,7 @@ import { fileURLToPath } from "node:url";
|
|
|
42
43
|
import { schedDir, pidAlive } from "./sched.mjs";
|
|
43
44
|
import { HEARTBEAT_MS, writeHeartbeat, clearHeartbeat } from "./schedd-health.mjs";
|
|
44
45
|
import { batchRoot, listBatches, openBatch, SECRET_ENV } from "./batch.mjs";
|
|
46
|
+
import { applySetExecution } from "./live-execution.mjs";
|
|
45
47
|
|
|
46
48
|
export const IDLE_MS = 10 * 60 * 1000;
|
|
47
49
|
export const STOP_MS = 10 * 1000;
|
|
@@ -87,10 +89,11 @@ export function sanitizePath(path, { exclude = [], exists = existsSync, tmpRoots
|
|
|
87
89
|
/**
|
|
88
90
|
* The allowlisted part of `env`, with PATH sanitised. `passthrough` names extra keys to keep (`orchestration.env_passthrough`,
|
|
89
91
|
* the configured `execution.token_env`); `pathExclude` is `orchestration.path_exclude`. Secret-looking names are dropped unless named.
|
|
92
|
+
* An empty value is never pinned, passthrough keys included (#5131: an empty pin would mask the daemon's secret).
|
|
90
93
|
*/
|
|
91
94
|
export function batchEnv(env = process.env, { passthrough = [], pathExclude = [], exists = existsSync, tmpRoots } = {}) {
|
|
92
95
|
const extra = new Set(passthrough);
|
|
93
|
-
const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
|
|
96
|
+
const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && v !== "" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
|
|
94
97
|
if (typeof out.PATH === "string") out.PATH = sanitizePath(out.PATH, { exclude: pathExclude, exists, ...(tmpRoots ? { tmpRoots } : {}) });
|
|
95
98
|
return out;
|
|
96
99
|
}
|
|
@@ -105,10 +108,17 @@ export function redactEnv(env) {
|
|
|
105
108
|
return Object.fromEntries(Object.entries(env ?? {}).map(([k, v]) => [k, SECRET_ENV.test(k) ? "<redacted>" : v]));
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* `base` without its allowlisted non-secret keys, plus `kept`: an account selector the client did not set never leaks in from
|
|
113
|
+
* the daemon, and a pinned key wins. A secret-named key is only pinned when named (`env_passthrough`, `token_env`): a non-empty
|
|
114
|
+
* pin wins, as before #5131 (the operator chose that account); an empty secret pin never overrides the daemon's value (#5131).
|
|
115
|
+
*/
|
|
109
116
|
export function mergeEnv(base, kept) {
|
|
110
117
|
const out = Object.fromEntries(Object.entries(base).filter(([k]) => k in kept || !ENV_KEEP.test(k) || SECRET_ENV.test(k)));
|
|
111
|
-
|
|
118
|
+
for (const [k, v] of Object.entries(kept)) {
|
|
119
|
+
if (!SECRET_ENV.test(k) || (typeof v === "string" && v !== "")) out[k] = v;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
/** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */
|
|
@@ -242,6 +252,13 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
|
|
|
242
252
|
b.setParallel(Number(req.k));
|
|
243
253
|
return send({ type: "ok", parallel: Number(req.k) }, true);
|
|
244
254
|
}
|
|
255
|
+
if (req.op === "set-execution") {
|
|
256
|
+
// #5138: live max_remote / placement mode; the hosted runner re-reads the batch's `execution` file on every admission.
|
|
257
|
+
const b = openBatch(root, req.id);
|
|
258
|
+
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
|
259
|
+
const execution = applySetExecution(b, { ...(req.maxRemote !== undefined ? { maxRemote: req.maxRemote } : {}), ...(req.mode !== undefined ? { mode: req.mode } : {}) });
|
|
260
|
+
return send({ type: "ok", execution }, true);
|
|
261
|
+
}
|
|
245
262
|
if (req.op === "add") {
|
|
246
263
|
const b = openBatch(root, req.id);
|
|
247
264
|
if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -76,6 +76,10 @@ await d.closed;
|
|
|
76
76
|
return child;
|
|
77
77
|
};
|
|
78
78
|
h.client = { scheddDir: h.sched, spawnDaemon: null };
|
|
79
|
+
// A forge.yaml with host admission off, so a test that needs N live lanes does not depend on this host's free memory/cores
|
|
80
|
+
// (liveLaneCap derives the cap from them). Run the clients from h.work via h.inWork to use it.
|
|
81
|
+
writeFileSync(join(work, "forge.yaml"), "engine:\n admission_enabled: false\n");
|
|
82
|
+
h.inWork = async (fn) => { const prev = process.cwd(); process.chdir(work); try { return await fn(); } finally { process.chdir(prev); } };
|
|
79
83
|
h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
|
|
80
84
|
h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
|
|
81
85
|
h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
|
|
@@ -119,7 +123,7 @@ describe("schedd host scheduler (#4192)", () => {
|
|
|
119
123
|
const d = await h.daemon();
|
|
120
124
|
const submit = (issues) => orchestrateFromCli([...issues, "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
121
125
|
// One capture for the three concurrent clients (console.log is process-wide).
|
|
122
|
-
const all = capture(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])]));
|
|
126
|
+
const all = capture(() => h.inWork(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])])));
|
|
123
127
|
await until(() => h.launched().length === 4 && h.lanePids().length === 4);
|
|
124
128
|
const reg = JSON.parse(readFileSync(join(h.sched, "registry.json"), "utf8"));
|
|
125
129
|
assert.equal(Object.keys(reg.slots).length, 4);
|
|
@@ -197,7 +201,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
197
201
|
try {
|
|
198
202
|
await h.daemon();
|
|
199
203
|
const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: a[2] === "9" ? "CLOSED" : "OPEN" }) : "o/r\n") } };
|
|
200
|
-
const { res } = await withRoot(h, () => capture(async () => {
|
|
204
|
+
const { res } = await withRoot(h, () => h.inWork(() => capture(async () => {
|
|
201
205
|
const first = orchestrateFromCli(["1", "--parallel", "3", "--repo", "o/r", "--no-prefilter"], h.client);
|
|
202
206
|
await until(() => h.launched().length === 1 && h.lanePids().length === 1);
|
|
203
207
|
const [id] = listBatches(h.root);
|
|
@@ -217,7 +221,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
217
221
|
const again = await orchestrateFromCli(["--resume", id], client);
|
|
218
222
|
assert.deepEqual(again.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"], [6, "merged"]]);
|
|
219
223
|
return done;
|
|
220
|
-
}));
|
|
224
|
+
})));
|
|
221
225
|
assert.equal(res.rows.length, 3);
|
|
222
226
|
assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
|
|
223
227
|
} finally { await h.cleanup(); }
|
|
@@ -644,6 +648,19 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
|
|
|
644
648
|
assert.equal(env.COMPUTESHARE_TOKEN, "daemon-tok");
|
|
645
649
|
});
|
|
646
650
|
|
|
651
|
+
it("5131: a daemon secret wins over an empty pin, a non-empty pin wins (REG-1 #5142), batchEnv pins no empty value", () => {
|
|
652
|
+
assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "daemon-tok" }, { COMPUTESHARE_TOKEN: "" }).COMPUTESHARE_TOKEN, "daemon-tok");
|
|
653
|
+
assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "daemon-tok" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok", "a non-empty passthrough pin wins (release review #5142 REG-1)");
|
|
654
|
+
assert.equal(mergeEnv({ GH_TOKEN: "daemon-acct" }, { GH_TOKEN: "pinned-acct" }).GH_TOKEN, "pinned-acct", "an env_passthrough GH_TOKEN keeps the batch's account");
|
|
655
|
+
assert.equal(mergeEnv({ PATH: "/d" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok");
|
|
656
|
+
assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok");
|
|
657
|
+
assert.equal(mergeEnv({}, { COMPUTESHARE_TOKEN: "" }).COMPUTESHARE_TOKEN, undefined, "an empty pin never overrides");
|
|
658
|
+
assert.equal(mergeEnv({ CLAUDE_CONFIG_DIR: "/d" }, { CLAUDE_CONFIG_DIR: "/c" }).CLAUDE_CONFIG_DIR, "/c", "non-secret pins keep winning");
|
|
659
|
+
const kept = batchEnv({ PATH: "/", COMPUTESHARE_TOKEN: "", GH_HOST: "" }, { passthrough: ["COMPUTESHARE_TOKEN"], exists: () => true });
|
|
660
|
+
assert.deepEqual(Object.keys(kept), ["PATH"]);
|
|
661
|
+
assert.deepEqual(batchEnv({ COMPUTESHARE_TOKEN: "client-tok" }, { passthrough: ["COMPUTESHARE_TOKEN"] }), { COMPUTESHARE_TOKEN: "client-tok" });
|
|
662
|
+
});
|
|
663
|
+
|
|
647
664
|
it("#4734: the pin keeps non-secret provider selectors, drops secrets and session ids, and droppedEnv names what was left out", () => {
|
|
648
665
|
const client = { PATH: "/", GH_TOKEN: "fresh", CLAUDE_CODE_USE_BEDROCK: "1", ANTHROPIC_BASE_URL: "http://proxy", ANTHROPIC_MODEL: "m", ANTHROPIC_API_KEY: "k",
|
|
649
666
|
FORGEDOCK_POOL_TOKEN: "p", CLAUDE_CONFIG_DIR: "/acct", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1" };
|
package/commands/orchestrate.md
CHANGED
|
@@ -100,7 +100,8 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
|
|
|
100
100
|
(live lanes are re-adopted, nothing is launched twice). The runner is hosted by the detached `forgedock schedd` daemon,
|
|
101
101
|
which resumes every daemon-hosted batch that has no report yet when it starts, so a closed session needs no manual
|
|
102
102
|
`--resume`; a batch started without the daemon still needs `forgedock orchestrate --resume <id>`. Change K of a running batch with
|
|
103
|
-
`forgedock orchestrate --set-parallel <id> K`.
|
|
103
|
+
`forgedock orchestrate --set-parallel <id> K`. Change its remote lane cap or placement with `forgedock orchestrate --set-max-remote <id> N` and
|
|
104
|
+
`--set-placement <id> remote|hybrid|local` (remote/hybrid only for a batch started with a remote provider; running lanes keep running). Issues filed while a batch runs join it with
|
|
104
105
|
`forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
|
|
105
106
|
for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
|
|
106
107
|
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`, `deferred`) returns to it with
|
package/docs/CONFIG.md
CHANGED
|
@@ -1030,7 +1030,8 @@ execution:
|
|
|
1030
1030
|
| `allow_actions` | boolean | No | `true` accepts a GitHub Actions endpoint. Default `false`. |
|
|
1031
1031
|
| `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
|
|
1032
1032
|
| `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
|
|
1033
|
-
| `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
|
|
1033
|
+
| `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. It also covers a sustained `sandbox.capacity`: an issue the pool refused `capacity_fallback_after` times (default 3) for capacity runs on the host when the host has headroom (the host-busy guard; a busy host or a failing probe keeps it waiting remote) (forge#5112). |
|
|
1034
|
+
| `capacity_fallback_after` | integer | No | With `fallback_local: true`: how many `sandbox.capacity` deferrals of one issue make it run on the host, subject to the host-busy guard (forge#5112). Default: 3. A missing, non-integer or non-positive value uses the default. |
|
|
1034
1035
|
| `account_rotator` | string | No | `tokenbaton`: the `claude` on PATH is the tokenbaton shim, which runs each call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`) (forge#5094). forgedock reads `~/.tokenbaton/current` (the account number) and `~/.tokenbaton/live.<n>` (`<5h used %> <5h reset s> <weekly used %> <weekly reset s> <updated s>`), re-read on every check and never written. An account has headroom when its 5-hour use is under 95% and its weekly use under 100% (a window whose reset has passed counts as 0). While any account has headroom no host-wide usage hold is recorded and admissions continue; they stop only when every account is exhausted and resume when the first one resets. A lane whose call hits a usage limit retries that same call (same session) on the next account at once, at most once per account, before it pauses or defers. `forgedock limits show` lists each account's 5-hour use. Any other value, or unreadable files: off (per-account holds as before). |
|
|
1035
1036
|
| `warm_sandboxes` | integer | No | Warm sandboxes per repo the pool agent keeps ready (checkout at the base ref, dependency cache primed). When `N > 0` the launch spec carries `warm: N` and the lane first calls `POST {endpoint}/sandboxes/claim`; a ready warm sandbox takes the lane (refreshed to the lane's base ref with an incremental fetch before work starts), and when none is ready, the claim fails or the endpoint lacks the route the lane starts cold. Default `0` (off): no claim call and the launch spec is unchanged (forge#4912). |
|
|
1036
1037
|
| `unreachable_window_min` | number | No | Remote providers: how long a lane keeps polling (with backoff, up to 30s) after the provider's transport fails (connection refused/reset, `fetch failed`, timeout, 502/503/504) before it ends `deferred` with `sandbox.unreachable` (forge#4672). Inside the window the lane never ends and the sandbox is never torn down; after it the sandbox is kept and adopted on the next attempt or `--resume`; the engine-error breaker does not count it. `adopt` applies the same window. Default: 5 |
|
|
@@ -1271,6 +1272,7 @@ review:
|
|
|
1271
1272
|
| `domains.*` | string | No | Domain-specific notes keyed by review agent name (`billing`, `auth`, `database`, `frontend`, `api`, `infra`, `security`) |
|
|
1272
1273
|
| `max_filed_findings` | integer >= 0 | No | Per-review cap on ISSUE dispositions (`scripts/cap-findings.sh`, applied by `review-pr` §6B.5 and `review-pr-staging` §7B (via `review-finalize.sh classify`) after classification). Default `2`; `1` when the reviewed PR is itself a fix for a `review-finding` issue, where every filed HIGH/CRITICAL consumes that budget. ISSUE findings are ranked by severity, then confidence, then safety-domain agent (security, auth, billing, concurrency, database), then input order; an unparseable severity or confidence ranks as MEDIUM/LIKELY. The overflow becomes `NOTE capped`: listed in the PR body and counted as `notes_capped` in `FORGE:NOTE_DISPOSITION`, never filed and never dropped. **HIGH/CRITICAL findings are never capped** and still block; `ISSUE fix-efficacy-*` findings (the linked issue is not fixed, or the PR re-introduces its defect) are never capped either and count against the review-finding budget like HIGH. `0` is valid (cap every MEDIUM/LOW finding); a non-numeric or negative value falls back to the default with a warning. An explicit value overrides both defaults. |
|
|
1273
1274
|
| `key_paths` | map(string→list of strings) | No | Domain-to-file mapping used by `/work-on` investigation and `/review-pr` agents to quickly locate relevant files. Keys are domain names matching issue labels; values are lists of file path patterns (glob-style, relative to repo root). When present, agents use this table instead of inferring files from labels and issue body. |
|
|
1275
|
+
| `enforce_domains` | string or list | No | Lean lanes (#5099). Review domains whose touch makes the conformance gate enforce for the whole lane (sticky, like a `risk_paths` hit): any of `frontend`, `auth`, `infra`, `database`, `api`, as a comma-separated string or a block list. Unknown names are dropped. Default empty: lanes stay in shadow (ADR `devdocs/decisions/4398-risk-path-enforce.md`: enforce a domain only once its shadow false-block rate is near 0). Independently of this key, the lean reviewer always gets a `<domain_checks>` block naming the domains the diff touches, using `scripts/review-roster.sh` path triggers (for example `web/`, `app/`, `*.tsx`, `next.config.*` → frontend, with the dynamic-API-in-static/ISR-tree check). It reports domain findings as `regressions` rows, which block even in shadow. The run log gets `REVIEW_DOMAINS {domains, enforced}`. |
|
|
1274
1276
|
|
|
1275
1277
|
**Commands that use this section**: `review-pr`, `review-pr-agents` (all domain agents), `work-on` (Phase 1B investigation)
|
|
1276
1278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.0",
|
|
4
4
|
"description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|