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.
@@ -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). A dependency that is parked
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
- const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
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, [...new Set([...(deps.get(n) ?? []), ...depList])]);
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
- let body;
933
- try { body = await readBody(n); } catch { continue; }
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
- let body;
968
- try { body = await readBody(n); } catch { continue; }
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; edges stay, so a dependent still waits for the issue to merge.
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();
@@ -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
- export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null }) {
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
- const res = await fetchFn(`${root}${path}`, { method, headers: { accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
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`;
@@ -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
- /** `base` without its allowlisted non-secret keys (secret-named ones, e.g. GH_TOKEN, stay from the daemon: never pinned), plus `kept`: an account selector the client did not set never leaks in from the daemon. */
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
- return { ...out, ...kept };
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}`);
@@ -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" };
@@ -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`. Issues filed while a batch runs join it with
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.48.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"