forgedock 1.32.0 → 1.33.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.
Files changed (69) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +29 -8
  3. package/bin/engine-cli.mjs +84 -24
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.mjs +9 -3
  8. package/bin/lean/batch.test.mjs +134 -3
  9. package/bin/lean/budget.mjs +7 -1
  10. package/bin/lean/budget.test.mjs +9 -1
  11. package/bin/lean/conformance.mjs +20 -7
  12. package/bin/lean/conformance.test.mjs +29 -2
  13. package/bin/lean/depcache.mjs +23 -3
  14. package/bin/lean/depcache.test.mjs +42 -1
  15. package/bin/lean/effects.mjs +29 -3
  16. package/bin/lean/execution.mjs +106 -14
  17. package/bin/lean/execution.test.mjs +101 -1
  18. package/bin/lean/hostkill.mjs +8 -0
  19. package/bin/lean/lean.mjs +65 -24
  20. package/bin/lean/lean.test.mjs +221 -10
  21. package/bin/lean/lightlane.mjs +38 -13
  22. package/bin/lean/lightlane.test.mjs +35 -1
  23. package/bin/lean/orchestrate.mjs +93 -26
  24. package/bin/lean/prompts.mjs +17 -7
  25. package/bin/lean/recovery.test.mjs +26 -0
  26. package/bin/lean/retry.mjs +15 -1
  27. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  28. package/bin/lean/sandbox-remote.mjs +40 -7
  29. package/bin/lean/schedd.mjs +50 -14
  30. package/bin/lean/schedd.test.mjs +133 -21
  31. package/bin/lean/seqlease.mjs +49 -2
  32. package/bin/lean/seqlease.test.mjs +14 -0
  33. package/bin/lean/sequence.mjs +54 -6
  34. package/bin/lean/sequence.test.mjs +83 -1
  35. package/bin/pool/agent.mjs +435 -0
  36. package/bin/pool/cli.mjs +110 -0
  37. package/bin/pool/docker.mjs +56 -0
  38. package/bin/pool/http.mjs +55 -0
  39. package/bin/pool/router.mjs +299 -0
  40. package/bin/pool/store.mjs +25 -0
  41. package/bin/pool/version.mjs +44 -0
  42. package/bin/runner.mjs +1 -0
  43. package/bin/stats/aggregate.mjs +6 -0
  44. package/bin/stats/index.mjs +14 -2
  45. package/bin/stats/local-source.mjs +1 -1
  46. package/bin/stats/pool.mjs +51 -0
  47. package/bin/stats/pool.test.mjs +39 -0
  48. package/bin/stats/query.mjs +2 -2
  49. package/bin/stats/stats.test.mjs +15 -1
  50. package/commands/orchestrate.md +3 -3
  51. package/docs/CONFIG.md +23 -9
  52. package/docs/OUTCOME-CODES.md +5 -3
  53. package/docs/STATS.md +7 -0
  54. package/package.json +2 -1
  55. package/pool/README.md +13 -0
  56. package/pool/image/Dockerfile +46 -0
  57. package/pool/image/claude-shim +8 -0
  58. package/pool/image/cs-cred +12 -0
  59. package/pool/image/entrypoint.sh +61 -0
  60. package/pool/image/gh-shim +6 -0
  61. package/pool/install/install-agent-linux.sh +53 -0
  62. package/pool/install/install-agent.ps1 +225 -0
  63. package/pool/install/install-agent.sh +101 -0
  64. package/pool/install/install-router.sh +67 -0
  65. package/pool/install/keepalive.ps1 +36 -0
  66. package/pool/install/uninstall-agent.ps1 +20 -0
  67. package/scripts/hotspots.sh +33 -21
  68. package/scripts/hotspots.test.sh +22 -3
  69. package/scripts/refresh-bot-token.test.sh +56 -0
@@ -38,7 +38,7 @@ const FIXTURES = {
38
38
  const LOCKFILE = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", uv: "uv.lock", poetry: "poetry.lock" };
39
39
 
40
40
  /** Fake package managers: write what a real install leaves behind, including the absolute paths that need rewriting. */
41
- function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
41
+ function fakeExec({ failInstall = false, failRepair = false, installDelayMs = 0 } = {}) {
42
42
  const calls = [];
43
43
  const venv = (cwd, lock) => {
44
44
  const v = join(cwd, ".venv");
@@ -65,6 +65,10 @@ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
65
65
  if (full && installDelayMs) await new Promise((r) => setTimeout(r, installDelayMs));
66
66
  if (full && failInstall) { put(join(cwd, cmd === "uv" || cmd === "poetry" ? ".venv/partial" : "node_modules/partial"), "x"); throw Object.assign(new Error("install failed"), { stderr: "ERR_PNPM_FROZEN_LOCKFILE" }); }
67
67
  if (cmd === "pnpm") {
68
+ if (args.includes("--offline") && failRepair) {
69
+ // pnpm prints its errors on stdout; execFile's error then carries an empty stderr (#4522).
70
+ throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile --offline"), { stderr: "", stdout: "Lockfile is up to date\n ERR_PNPM_TEST_REPAIR some cause\n" });
71
+ }
68
72
  if (args.includes("--offline")) return ""; // warm repair pass: nothing to do
69
73
  const lock = readFileSync(join(cwd, "pnpm-lock.yaml"), "utf8");
70
74
  put(join(cwd, "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js"), `module.exports = ${JSON.stringify(lock)};\n`);
@@ -222,6 +226,43 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
222
226
  assert.equal(r.published, true, "the stale lock was replaced");
223
227
  });
224
228
 
229
+ it("#4522 a snapshot whose repair fails falls back to a fresh install outside the lock, keeps the snapshot and records pnpm's stdout error", async () => {
230
+ const s = setup("pnpm");
231
+ const [ra] = await s.cache.prepare({ wt: s.lane("a"), repoKey: "r" });
232
+ assert.equal(ra.status, "miss-installed", ra.reason);
233
+ const [snap] = snapDirs(s.root, "pnpm");
234
+ const before = snapshotOf(snap);
235
+ const broken = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true }).exec, env: {}, pollMs: 5 });
236
+ const wt = s.lane("b");
237
+ const [rb] = await broken.prepare({ wt, repoKey: "r" });
238
+ assert.equal(rb.status, "materialize-fallback", rb.reason);
239
+ assert.equal(rb.published, false);
240
+ assert.match(rb.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR {2}some cause/);
241
+ assert.ok(existsSync(join(wt, "node_modules/lodash/index.js")), "the lane has node_modules");
242
+ const after = snapshotOf(snap);
243
+ delete before["last-used"]; delete after["last-used"];
244
+ assert.deepEqual(after, before, "the snapshot is untouched");
245
+ assert.deepEqual(readdirSync(join(s.root, "r")).filter((n) => !/^pnpm-[0-9a-f]{16}$/.test(n)), [], "no lock or tmp left");
246
+
247
+ const both = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true, failInstall: true }).exec, env: {}, pollMs: 5 });
248
+ const wt2 = s.lane("c");
249
+ const [rc] = await both.prepare({ wt: wt2, repoKey: "r" });
250
+ assert.equal(rc.status, "failed");
251
+ assert.match(rc.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR.*; install failed: ERR_PNPM_FROZEN_LOCKFILE/);
252
+ assert.ok(!existsSync(join(wt2, "node_modules")), "no partial tree");
253
+ });
254
+
255
+ it("#4522 a failed pnpm install records the ERR_PNPM line it printed on stdout", async () => {
256
+ const s = setup("pnpm");
257
+ const exec = async (cmd, args, o) => {
258
+ if (cmd === "pnpm" && !args.includes("--offline")) throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile"), { stderr: "", stdout: "Progress: resolved 1\n ERR_PNPM_OUTDATED_LOCKFILE Cannot install with frozen-lockfile\n\nNote that in CI environments this setting is true by default.\nA\nB\nC\n" });
259
+ return s.f.exec(cmd, args, o);
260
+ };
261
+ const [r] = await makeDepCache({ root: s.root, exec, env: {} }).prepare({ wt: s.lane("x"), repoKey: "r" });
262
+ assert.equal(r.status, "failed");
263
+ assert.match(r.reason, /^install failed: ERR_PNPM_OUTDATED_LOCKFILE {2}Cannot install with frozen-lockfile A B C$/);
264
+ });
265
+
225
266
  it("a failed install, a missing tool or another filesystem leaves no snapshot and no partial tree", async () => {
226
267
  const s = setup("pnpm", { failInstall: true });
227
268
  const wt = s.lane("x");
@@ -3,7 +3,7 @@
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
4
  import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
6
+ import { readFileSync, appendFileSync, mkdirSync, mkdtempSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
@@ -234,10 +234,36 @@ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }
234
234
  });
235
235
  }
236
236
 
237
+ /**
238
+ * #4507: a per-run gh HTTP cache (XDG_CACHE_HOME on the gh child only), so concurrent lanes never share a cache entry.
239
+ * `reset()` drops the current dir (only one this cache created) and switches to a fresh one; with no `root` the dirs
240
+ * are mkdtemp dirs under `tmp`, removed by `dispose()`.
241
+ */
242
+ export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
243
+ let n = 0, dir = root ? join(root, "gh-cache") : null, disposed = false;
244
+ const made = new Set();
245
+ const drop = (d) => { if (d && made.has(d)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } made.delete(d); } };
246
+ return {
247
+ env() {
248
+ if (!dir || disposed) return undefined; // after dispose (run teardown) gh falls back to its default cache
249
+ if (!made.has(dir)) { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.add(dir); } catch { return undefined; } }
250
+ return { XDG_CACHE_HOME: dir };
251
+ },
252
+ reset() {
253
+ drop(dir);
254
+ if (disposed) return;
255
+ if (root) { dir = join(root, `gh-cache-${++n}`); return; }
256
+ try { dir = mkdtempSync(join(tmp, "forge-gh-cache-")); made.add(dir); } catch { dir = null; }
257
+ },
258
+ dispose() { disposed = true; for (const d of [...made]) drop(d); },
259
+ };
260
+ }
261
+
237
262
  export function makeLeanIo({ repo }) {
238
263
  const base = makeIo();
239
- const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
264
+ const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
240
265
  return {
266
+ ghCache: (root) => makeGhCache({ root }),
241
267
  gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
242
268
  /** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
243
269
  deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
@@ -344,7 +370,7 @@ export function makeLeanIo({ repo }) {
344
370
  sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
345
371
  sequenceVerify: ({ cwd, base, before, moves }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before, moves }),
346
372
  /** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
347
- sequenceRenumber: ({ cwd, base, laneRef, regenerate }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate }),
373
+ sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
348
374
  sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
349
375
  sequenceLease: makeSequenceLeaseIo(),
350
376
  /** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
@@ -32,10 +32,15 @@
32
32
  *
33
33
  * Hybrid (#4256): `placeLane` places each lane from facts (lane kind, validation weight, host headroom, verify.mode);
34
34
  * orchestrate runs a remote-placed lane locally when `max_remote` or the execution budget is exhausted.
35
+ *
36
+ * Host capabilities (#4542): a lane that needs something only the dispatching host has (the SOPS/age key, scoped ops
37
+ * credentials, read-only prod monitor SSH) is placed local in `remote` and `hybrid` mode, because a sandbox relays none of
38
+ * them (`detectNeeds`, `placeLane` `needs`). forge.yaml `execution.host_capabilities` declares which the host has; a
39
+ * remote lane that blocks on one of them ends `env.capability_remote` and is recovered locally (`hostCapabilityBlock`).
35
40
  */
36
41
  import { execFile } from "node:child_process";
37
42
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
38
- import { readFile } from "node:fs/promises";
43
+ import { readFile, readdir } from "node:fs/promises";
39
44
  import { cpus, homedir, loadavg } from "node:os";
40
45
  import { dirname, join } from "node:path";
41
46
  import { promisify } from "node:util";
@@ -50,8 +55,10 @@ import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
50
55
  import { detectPins } from "./toolchain.mjs";
51
56
 
52
57
  export const EXECUTION_MODES = Object.freeze(["local", "remote", "hybrid"]);
53
- export const SANDBOX_PROVIDERS = Object.freeze(["local", "container", "remote"]);
58
+ export const SANDBOX_PROVIDERS = Object.freeze(["local", "container", "remote", "pool"]);
54
59
  const NAME_RE = /^[a-z][a-z0-9-]*$/;
60
+ const CAPABILITY_RE = /^[a-z][a-z0-9_-]*$/;
61
+ const safeRegExp = (src) => { try { return new RegExp(src, "i"); } catch { return null; } };
55
62
  const ENV_RE = /^[A-Z_][A-Z0-9_]*$/;
56
63
  const IMAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._\/:@-]*$/;
57
64
  const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
@@ -79,11 +86,19 @@ export function parseExecution(sec = {}) {
79
86
  const tokenEnv = ENV_RE.test(str(s.token_env)) ? str(s.token_env) : null;
80
87
  const cacheStore = str(s.cache_store) || null;
81
88
  const allowActions = str(s.allow_actions).toLowerCase() === "true";
89
+ const fallbackLocal = str(s.fallback_local).toLowerCase() === "true";
90
+ const heavyRaw = str(s.heavy).toLowerCase();
91
+ const heavy = heavyRaw === "true" ? true : heavyRaw === "false" ? false : null;
82
92
  const cap = (v) => { const t = str(v), x = Number(t); return t !== "" && Number.isFinite(x) && x >= 0 ? x : null; };
83
93
  const cpm = cap(s.cost_per_minute);
84
94
  const budget = { batchMinutes: cap(s.budget_minutes), batchUsd: cap(s.budget_usd), dayMinutes: cap(s.day_budget_minutes), dayUsd: cap(s.day_budget_usd),
85
95
  onBudget: str(s.on_budget) === "local" ? "local" : "stop", costPerMinute: cpm ?? 0 };
86
- return { mode, maxRemote, provider, image, secrets, budget, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}) };
96
+ const capList = (v) => (Array.isArray(v) ? v : str(v) ? str(v).split(",") : []).map((x) => str(x).toLowerCase()).filter((x) => CAPABILITY_RE.test(x));
97
+ const hostCapabilities = [...new Set(capList(s.host_capabilities))];
98
+ const patRaw = Array.isArray(s.host_capability_patterns) ? s.host_capability_patterns : str(s.host_capability_patterns) ? str(s.host_capability_patterns).split(",") : [];
99
+ const hostCapabilityPatterns = patRaw.map((x) => { const t = str(x), i = t.indexOf(":"); return { name: t.slice(0, i).trim().toLowerCase(), pattern: t.slice(i + 1).trim() }; })
100
+ .filter((x) => CAPABILITY_RE.test(x.name) && x.pattern && safeRegExp(x.pattern));
101
+ return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}) };
87
102
  }
88
103
 
89
104
  const round1 = (x) => Math.round(x * 10) / 10;
@@ -157,6 +172,33 @@ export function placement(exec) {
157
172
  return exec?.mode === "remote" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1 ? "remote" : "local";
158
173
  }
159
174
 
175
+ /** Host-only capabilities and the issue-text signals that mean a lane needs them (#4542). */
176
+ const CAPABILITY_SIGNALS = Object.freeze({
177
+ sops: /\.secrets\/[^\s`'")]*\.enc\.ya?ml|\bsops\b|\bage key\b|\.age\//i,
178
+ prod_monitor: /\bssh\s+[\w.-]*-monitor\b|\b(?:ovh|netcup)-monitor\b|\bobs-psql\b|\bprod(?:uction)? monitor/i,
179
+ ops_credentials: /\bcredentials\.yaml\b|\bops[ _-]credentials\b/i,
180
+ });
181
+
182
+ /**
183
+ * Host-only capabilities named by `text` (issue body, contract or a builder summary), zero LLM, sorted by name: the
184
+ * built-in signals above, a declared name written out (`prod_monitor`, `prod monitor`) and `exec.hostCapabilityPatterns`.
185
+ */
186
+ export function detectNeeds(text, exec = null) {
187
+ const t = String(text ?? "");
188
+ const found = new Set();
189
+ for (const [name, re] of Object.entries(CAPABILITY_SIGNALS)) if (re.test(t)) found.add(name);
190
+ for (const name of exec?.hostCapabilities ?? []) if (new RegExp(`\\b${name.replace(/[_-]/g, "[ _-]")}\\b`, "i").test(t)) found.add(name);
191
+ for (const { name, pattern } of exec?.hostCapabilityPatterns ?? []) { const re = safeRegExp(pattern); if (re?.test(t)) found.add(name); }
192
+ return [...found].sort();
193
+ }
194
+
195
+ /** Declared host capabilities a remote lane's block summary names (#4542); empty when it names none the host has. */
196
+ export function hostCapabilityBlock(summary, caps = null) {
197
+ const have = new Set(caps?.hostCapabilities ?? []);
198
+ const named = detectNeeds(summary, caps);
199
+ return named.length && named.every((n) => have.has(n)) ? named : [];
200
+ }
201
+
160
202
  const DOCS_LABELS = new Set(["docs", "documentation", "type:docs"]);
161
203
 
162
204
  /**
@@ -165,16 +207,22 @@ const DOCS_LABELS = new Set(["docs", "documentation", "type:docs"]);
165
207
  * `validationWeight`, `headroom` from `hostHeadroom`. In order: a non-hybrid mode keeps `placement()`; `max_remote` 0 is
166
208
  * local; `verify.mode: ci` is local (heavy validation already runs, and is billed, on CI); a docs-only lane is local; a
167
209
  * heavy-validation lane is remote; a host without headroom sends the lane remote; anything else stays on the host.
210
+ * A `needs` entry (host-only capability, #4542) the provider does not `relays` is local first, in `remote` mode too.
168
211
  * Capacity (`max_remote` reached, the execution budget) is applied at admission by orchestrate, which falls back to local.
169
212
  */
170
- export function placeLane({ exec = null, verifyMode = "local", labels = [], weight = null, headroom = null } = {}) {
213
+ export function placeLane({ exec = null, verifyMode = "local", labels = [], weight = null, headroom = null, needs = [], relays = [] } = {}) {
171
214
  const mode = exec?.mode;
215
+ if (mode === "remote" || mode === "hybrid") {
216
+ const missing = (Array.isArray(needs) ? needs : []).map(String).filter((n) => n && !(Array.isArray(relays) ? relays : []).includes(n));
217
+ if (missing.length) return { where: "local", reason: `host capability: ${missing.join(", ")}` };
218
+ }
172
219
  if (mode !== "hybrid") { const where = placement(exec); return { where, reason: `mode ${EXECUTION_MODES.includes(mode) ? mode : "local"}` }; }
173
220
  if (!(Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1)) return { where: "local", reason: "max_remote 0" };
174
221
  if (verifyMode === "ci") return { where: "local", reason: "verify.mode ci: validation runs on CI, not billed twice" };
175
222
  const names = (Array.isArray(labels) ? labels : []).map((l) => String(l ?? "").trim().toLowerCase());
176
223
  if (names.some((l) => DOCS_LABELS.has(l))) return { where: "local", reason: "docs-only lane" };
177
- if (weight?.heavy === true) {
224
+ if (exec.heavy === true) return { where: "remote", reason: "execution.heavy: true" };
225
+ if (exec.heavy !== false && weight?.heavy === true) {
178
226
  const sig = (Array.isArray(weight.signals) ? weight.signals : []).map(String).filter(Boolean);
179
227
  return { where: "remote", reason: `heavy validation${sig.length ? `: ${sig.join(", ")}` : ""}` };
180
228
  }
@@ -188,6 +236,9 @@ const HEAVY_MARKERS = Object.freeze([
188
236
  ["pom.xml", "maven"], ["build.gradle", "gradle"], ["build.gradle.kts", "gradle"],
189
237
  ["eslint.config.js", "eslint"], ["eslint.config.mjs", "eslint"], ["eslint.config.cjs", "eslint"], [".eslintrc", "eslint"], [".eslintrc.js", "eslint"], [".eslintrc.cjs", "eslint"], [".eslintrc.json", "eslint"], [".eslintrc.yml", "eslint"],
190
238
  ]);
239
+ const SKIP_DIRS = new Set(["node_modules", "vendor", "dist", "build", "target", "venv", "__pycache__"]);
240
+ const MAX_SCAN_DIRS = 200;
241
+ const defaultList = async (d) => (await readdir(d, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
191
242
  const HEAVY_SCRIPTS = Object.freeze(["test", "lint", "typecheck", "type-check", "build"]);
192
243
 
193
244
  /**
@@ -195,7 +246,7 @@ const HEAVY_SCRIPTS = Object.freeze(["test", "lint", "typecheck", "type-check",
195
246
  * classifies heavy (heavy.mjs `classifyStep`), or a heavy toolchain marker or package.json test/lint/typecheck/build script
196
247
  * exists. The toolchain pin (toolchain.mjs `detectPins`) names the package manager among the signals. Zero LLM.
197
248
  */
198
- export async function validationWeight({ cwd = process.cwd(), precheckCommands = [], read = (p) => readFile(p, "utf8") } = {}) {
249
+ export async function validationWeight({ cwd = process.cwd(), precheckCommands = [], read = (p) => readFile(p, "utf8"), list = defaultList } = {}) {
199
250
  const signals = [];
200
251
  const add = (s) => { if (!signals.includes(s)) signals.push(s); };
201
252
  for (const cmd of Array.isArray(precheckCommands) ? precheckCommands : []) {
@@ -203,11 +254,23 @@ export async function validationWeight({ cwd = process.cwd(), precheckCommands =
203
254
  if (name && classifyStep(name, args) === "heavy") add(name.split("/").pop());
204
255
  }
205
256
  const exists = async (f) => { try { await read(join(cwd, f)); return true; } catch { return false; } };
206
- for (const [f, sig] of HEAVY_MARKERS) if (await exists(f)) add(sig);
207
- let pkg = null;
208
- try { pkg = JSON.parse(String(await read(join(cwd, "package.json")))); } catch { pkg = null; }
209
- const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
210
- for (const k of HEAVY_SCRIPTS) if (typeof scripts[k] === "string" && scripts[k].trim()) add(`${k} script`);
257
+ const scan = async (dir) => {
258
+ for (const [f, sig] of HEAVY_MARKERS) if (await exists(join(dir, f))) add(sig);
259
+ let pkg = null;
260
+ try { pkg = JSON.parse(String(await read(join(cwd, dir, "package.json")))); } catch { pkg = null; }
261
+ const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
262
+ for (const k of HEAVY_SCRIPTS) if (typeof scripts[k] === "string" && scripts[k].trim()) add(`${k} script`);
263
+ };
264
+ await scan("");
265
+ // Monorepo (#4523): markers one and two levels down (`web/tsconfig.json`, `services/x/pyproject.toml`), bounded.
266
+ const subdirs = async (dir) => { try { return (await list(join(cwd, dir))).filter((d) => !SKIP_DIRS.has(d) && !d.startsWith(".")).sort(); } catch { return []; } };
267
+ let scanned = 0;
268
+ outer: for (const d1 of await subdirs("")) {
269
+ for (const dir of [d1, ...(await subdirs(d1)).map((d2) => join(d1, d2))]) {
270
+ if (scanned++ >= MAX_SCAN_DIRS) break outer;
271
+ await scan(dir);
272
+ }
273
+ }
211
274
  if (signals.length) {
212
275
  let pins = null;
213
276
  try { pins = await detectPins(cwd, { read: (p) => read(p) }); } catch { pins = null; }
@@ -335,6 +398,18 @@ export function remoteTransportFor(exec = {}, { transport = null, env = process.
335
398
  return httpTransport({ endpoint: exec.endpoint, token: exec.tokenEnv ? credentials(exec.tokenEnv) : null });
336
399
  }
337
400
 
401
+ /**
402
+ * The local pool router's endpoint and token (#4546): `COMPUTESHARE_CONFIG` or `~/.config/computeshare/router.json`
403
+ * (`listen.host`/`listen.port`), token from `forgedock_token_file` or the sibling `forgedock.token`. Null when unreadable.
404
+ */
405
+ export function poolRouterConfig({ env = process.env, path = env.COMPUTESHARE_CONFIG || join(homedir(), ".config/computeshare/router.json") } = {}) {
406
+ const c = readJson(path);
407
+ if (!c?.listen?.host || !c.listen.port) return null;
408
+ let token = null;
409
+ try { token = readFileSync(c.forgedock_token_file ?? join(dirname(path), "forgedock.token"), "utf8").trim() || null; } catch { token = null; }
410
+ return { endpoint: `http://${c.listen.host}:${c.listen.port}`, token };
411
+ }
412
+
338
413
  /** A provider by name; throws a typed error for an unknown one. `remote` reads `opts.exec` (parseExecution output). */
339
414
  export function providerByName(name, opts) {
340
415
  if (name === "local") return localProvider(opts);
@@ -344,6 +419,20 @@ export function providerByName(name, opts) {
344
419
  const env = opts?.env ?? process.env;
345
420
  return remoteProvider({ batch: opts.batch, repo: opts.repo, settings: exec, env, transport: remoteTransportFor(exec, { transport: opts?.transport, env }), ...(opts?.now ? { now: opts.now } : {}) });
346
421
  }
422
+ if (name === "pool") {
423
+ // #4546: `remote` with the endpoint and token defaulted to the local router, and the repo's forge.yaml sent along.
424
+ const env = opts?.env ?? process.env;
425
+ const exec = opts?.exec ?? {};
426
+ const router = opts?.transport || exec.endpoint ? null : poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
427
+ if (!opts?.transport && !exec.endpoint && !router) throw typedError("execution.provider.misconfigured", "execution.provider pool needs a local router config (forgedock pool router) or execution.endpoint");
428
+ let forgeYaml = null;
429
+ try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; }
430
+ // The router's token is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
431
+ const tokenEnv = exec.tokenEnv ?? (router?.token ? "FORGEDOCK_POOL_TOKEN" : undefined);
432
+ const credentials = (n) => (n === "FORGEDOCK_POOL_TOKEN" && !exec.tokenEnv ? router?.token ?? null : resolveCredential(n, { env }));
433
+ const settings = { ...exec, ...(exec.endpoint ? {} : router ? { endpoint: router.endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}) };
434
+ return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, name: "pool", env, credentials, transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials }), ...(opts?.now ? { now: opts.now } : {}) });
435
+ }
347
436
  const e = new Error(`unknown execution.provider ${JSON.stringify(name)} (known: ${SANDBOX_PROVIDERS.join(", ")})`);
348
437
  e.code = "execution.provider.unknown";
349
438
  throw e;
@@ -365,7 +454,8 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
365
454
  try { u = typeof provider.usage === "function" ? await provider.usage(h, { startedAt, endedAt }) : null; } catch { u = null; }
366
455
  const minutes = nonNeg(u?.minutes) ?? nonNeg(h?.minutes) ?? round1(Math.max(0, endedAt - startedAt) / 60000);
367
456
  const cost = nonNeg(u?.cost_usd) ?? minutes * cpm;
368
- return { ...r, sandbox: { provider: provider.name, id: h.id, minutes: round1(minutes), cost_usd: round6(cost) } };
457
+ const agent = typeof u?.agent === "string" && u.agent ? u.agent : typeof h?.agent === "string" && h.agent ? h.agent : null; // #4549: the pool agent
458
+ return { ...r, sandbox: { provider: provider.name, id: h.id, minutes: round1(minutes), cost_usd: round6(cost), ...(agent ? { agent } : {}) } };
369
459
  };
370
460
  const tear = async (led, h) => {
371
461
  let ok = false;
@@ -411,7 +501,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
411
501
  await tear(led, h); // every path, after the result is in (tear never throws)
412
502
  return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
413
503
  },
414
- adopt: async (n, rec) => {
504
+ adopt: async (n, rec, ctx = {}) => {
415
505
  const saved = rec?.handle;
416
506
  if (!saved?.id || saved.provider !== provider.name) return null;
417
507
  // Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
@@ -420,7 +510,9 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
420
510
  led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
421
511
  let r;
422
512
  try {
423
- r = !(await provider.fetchResult(h)) && (await provider.status(h)) !== "running" ? null : await wait(h); // null: relaunched
513
+ const ended = await provider.fetchResult(h);
514
+ if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
515
+ else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
424
516
  } catch (e) {
425
517
  r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
426
518
  }
@@ -6,7 +6,7 @@ import assert from "node:assert/strict";
6
6
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { parseExecution, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
9
+ import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
10
10
  import { makeLedger, countLiveLanes } from "./ledger.mjs";
11
11
  import { openBatch } from "./batch.mjs";
12
12
  import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
@@ -42,6 +42,11 @@ const NO_BUDGET = { batchMinutes: null, batchUsd: null, dayMinutes: null, dayUsd
42
42
  const ledgerIn = (root, { pid = process.pid, host = HOST } = {}) => (n) => makeLedger({ root, batch: "bt", lane: `sandbox-${n}`, owner: "orch_test", issue: n, host, pid });
43
43
 
44
44
  describe("execution config (#4253)", () => {
45
+ it("pool is a known sandbox provider (#4546)", () => {
46
+ assert.ok(SANDBOX_PROVIDERS.includes("pool"));
47
+ assert.equal(parseExecution({ mode: "hybrid", max_remote: "1", provider: "pool" }).provider, "pool");
48
+ });
49
+
45
50
  it("absent, empty or invalid config is local; max_remote and secrets are validated", () => {
46
51
  assert.deepEqual(parseExecution(undefined), { mode: "local", maxRemote: 0, provider: "local", image: null, secrets: [], budget: NO_BUDGET });
47
52
  assert.deepEqual(parseExecution({ mode: "cloud", max_remote: "-1" }), { mode: "local", maxRemote: 0, provider: "local", image: null, secrets: [], budget: NO_BUDGET });
@@ -440,6 +445,19 @@ describe("execution budget and sandbox cost (#4255)", () => {
440
445
  "sandbox: 1 lane, 12.5 min, $0.25 (token cost $1.50); budget: batch 10 min, day $20; host today 12.5 min, $0.25; cap reached, 1 held");
441
446
  });
442
447
 
448
+ it("report: sandbox usage agent becomes per-agent minutes on the row and a by-agent summary suffix (#4549)", async () => {
449
+ const lines = formatSandboxSummary([
450
+ { issue: 1, outcome: "merged", cost_usd: 1, sandbox: { minutes: 10, cost_usd: 0, attempts: 1, by_agent: { legion: 10 } } },
451
+ { issue: 2, outcome: "merged", cost_usd: 1, sandbox: { minutes: 30.5, cost_usd: 0, attempts: 2, by_agent: { legion: 20.5, box2: 10 } } }]);
452
+ assert.match(lines, /; by agent: legion 30\.5 min, box2 10\.0 min$/);
453
+ assert.doesNotMatch(formatSandboxSummary([{ issue: 1, outcome: "merged", cost_usd: 1, sandbox: { minutes: 1, cost_usd: 0 } }]), /by agent/);
454
+ const prov = fakeProvider({ finish: () => ({ terminalReason: "merged", pr: 2 }), usage: async () => ({ minutes: 7, agent: "legion" }) });
455
+ const lanes = providerLanes({ provider: prov, laneArgs: () => [], ledgerFor: ledgerIn(tmpDir("exec-agent-")), pollMs: 1, now: () => 1000 });
456
+ const rows = await orchestrate({ issues: [5], parallel: 1, store: null, readBody: async () => "", now: () => 0, remote: { max: 1, isRemote: () => true },
457
+ runOne: (n, l, ctx) => lanes.runOne(n, l, ctx) });
458
+ assert.deepEqual(rows[0].sandbox.by_agent, { legion: 7 });
459
+ });
460
+
443
461
  it("runBatch: rows, report.json and FORGE:TELEMETRY carry each lane's sandbox usage; the host-day store records it", async () => {
444
462
  const work = tmpDir("exec-cli-cost-");
445
463
  const ledRoot = join(work, "ledger");
@@ -502,6 +520,28 @@ describe("hybrid placement (#4256)", () => {
502
520
  assert.deepEqual(await validationWeight({ cwd: repoWith({}), precheckCommands: ["echo ok", "git diff --check"] }), { heavy: false, signals: [] });
503
521
  });
504
522
 
523
+ it("validationWeight sees monorepo subdirectory markers (#4523)", async () => {
524
+ const repo = (files) => { const d = tmpDir("vw-mono-"); for (const [f, t] of Object.entries(files)) { mkdirSync(join(d, f, ".."), { recursive: true }); writeFileSync(join(d, f), t); } return d; };
525
+ const mono = repo({ "package.json": "{}", "services/x/pyproject.toml": "[project]", "web/tsconfig.json": "{}" });
526
+ assert.deepEqual(await validationWeight({ cwd: mono }), { heavy: true, signals: ["python", "tsc"] });
527
+ assert.equal((await validationWeight({ cwd: repo({ "web/package.json": JSON.stringify({ scripts: { lint: "eslint ." } }) }) })).heavy, true);
528
+ assert.deepEqual(await validationWeight({ cwd: repo({ "node_modules/a/tsconfig.json": "{}", ".git/x/go.mod": "m", "a/b/c/Cargo.toml": "x" }) }), { heavy: false, signals: [] });
529
+ });
530
+
531
+ it("execution.heavy overrides placement only (#4523)", () => {
532
+ const hy = (heavy) => parseExecution({ mode: "hybrid", max_remote: "2", ...(heavy === undefined ? {} : { heavy }) });
533
+ assert.equal(hy().heavy, undefined);
534
+ assert.equal(hy("TRUE").heavy, true);
535
+ assert.equal(hy("false").heavy, false);
536
+ assert.equal(hy("maybe").heavy, undefined);
537
+ const LIGHT = { heavy: false, signals: [] }, HEAVY = { heavy: true, signals: ["tsc"] };
538
+ assert.deepEqual(placeLane({ exec: hy("true"), weight: LIGHT, headroom: { ok: true } }), { where: "remote", reason: "execution.heavy: true" });
539
+ assert.deepEqual(placeLane({ exec: hy("false"), weight: HEAVY, headroom: { ok: true } }), { where: "local", reason: "light lane, host has headroom" });
540
+ assert.equal(placeLane({ exec: hy("false"), weight: HEAVY, headroom: { ok: false, detail: "mem" } }).where, "remote");
541
+ assert.equal(placeLane({ exec: hy("true"), verifyMode: "ci", weight: LIGHT }).where, "local");
542
+ assert.equal(placeLane({ exec: parseExecution({ mode: "hybrid", max_remote: "0", heavy: "true" }), weight: LIGHT }).where, "local");
543
+ });
544
+
505
545
  it("hostHeadroom: memory below the admission floor plus one heavy step, or a configured load ceiling", () => {
506
546
  const host = (mem, load = 1, cores = 4) => ({ memAvailableMb: () => mem, loadAvg1: () => load, cores: () => cores });
507
547
  assert.deepEqual(hostHeadroom({ host: host(8000) }), { ok: true, detail: "" });
@@ -581,3 +621,63 @@ describe("hybrid placement (#4256)", () => {
581
621
  assert.deepEqual(res.rows[0].placement, { where: "remote", reason: "heavy validation: tsc" });
582
622
  });
583
623
  });
624
+
625
+ describe("#4547: fallback_local and agent liveness", () => {
626
+ it("parseExecution: fallback_local is opt-in", () => {
627
+ assert.equal(parseExecution({ mode: "remote", max_remote: "2" }).fallbackLocal, undefined);
628
+ assert.equal(parseExecution({ mode: "remote", max_remote: "2", fallback_local: "true" }).fallbackLocal, true);
629
+ assert.equal(parseExecution({ mode: "remote", fallback_local: "yes" }).fallbackLocal, undefined);
630
+ });
631
+ it("transport capacity carries free and liveness; none valid is unknown", async () => {
632
+ const { httpTransport } = await import("./sandbox-remote.mjs");
633
+ const cap = async (data, ok = true) => httpTransport({ endpoint: "http://pool.test", token: "t", fetchFn: async () => ({ ok, status: ok ? 200 : 500, headers: new Headers(), text: async () => JSON.stringify(data) }) }).capacity();
634
+ assert.deepEqual(await cap({ online: 0, total: 2, agents: [] }), { online: 0, total: 2 });
635
+ assert.deepEqual(await cap({ free: 3 }), { free: 3 });
636
+ assert.equal(await cap({ online: -1 }), null);
637
+ assert.equal(await cap({ online: 1 }, false), null);
638
+ });
639
+ });
640
+
641
+ describe("host capabilities in placement (#4542)", () => {
642
+ it("placeLane: a need the provider does not relay is local in remote and hybrid mode, with the reason", () => {
643
+ assert.deepEqual(placeLane({ exec: { mode: "remote" }, needs: ["sops"] }), { where: "local", reason: "host capability: sops" });
644
+ assert.deepEqual(placeLane({ exec: { mode: "hybrid", maxRemote: 2, heavy: true }, needs: ["prod_monitor", "sops"] }), { where: "local", reason: "host capability: prod_monitor, sops" });
645
+ assert.equal(placeLane({ exec: { mode: "remote", maxRemote: 2 }, needs: ["sops"], relays: ["sops"] }).where, "remote");
646
+ });
647
+
648
+ it("no needs: placement is unchanged", () => {
649
+ assert.deepEqual(placeLane({ exec: { mode: "remote", maxRemote: 2 }, needs: [] }), { where: "remote", reason: "mode remote" });
650
+ assert.deepEqual(placeLane({ exec: { mode: "remote", maxRemote: 2 } }), { where: "remote", reason: "mode remote" });
651
+ assert.equal(placeLane({ exec: { mode: "hybrid", maxRemote: 2, heavy: true } }).where, "remote");
652
+ });
653
+
654
+ it("detectNeeds: built-in signals, declared names and declared patterns, zero LLM", () => {
655
+ assert.deepEqual(detectNeeds("Edit `.secrets/prod.enc.yaml` and run ssh ovh-monitor; see credentials.yaml"), ["ops_credentials", "prod_monitor", "sops"]);
656
+ assert.deepEqual(detectNeeds("plain change to src/a.js"), []);
657
+ const exec = parseExecution({ mode: "remote", max_remote: "2", host_capabilities: "vault, Bad Name", host_capability_patterns: "vault:vault\\s+kv, x:(" });
658
+ assert.deepEqual(exec.hostCapabilities, ["vault"]);
659
+ assert.deepEqual(exec.hostCapabilityPatterns, [{ name: "vault", pattern: "vault\\s+kv" }]);
660
+ assert.deepEqual(detectNeeds("run vault kv get", exec), ["vault"]);
661
+ assert.deepEqual(detectNeeds("needs the Vault", exec), ["vault"]);
662
+ });
663
+
664
+ it("hostCapabilityBlock: only declared capabilities count; a mixed or unknown need stays a human block", () => {
665
+ const caps = { hostCapabilities: ["sops"] };
666
+ assert.deepEqual(hostCapabilityBlock("this lane has no sops key", caps), ["sops"]);
667
+ assert.deepEqual(hostCapabilityBlock("this lane has no sops key", null), []);
668
+ assert.deepEqual(hostCapabilityBlock("no sops key and no ssh ovh-monitor", caps), []);
669
+ assert.deepEqual(hostCapabilityBlock("owner must create an account", caps), []);
670
+ });
671
+
672
+ it("orchestrate: a remote lane that ends env.capability_remote is relaunched locally once", async () => {
673
+ const calls = [];
674
+ const rows = await orchestrate({ issues: [5], parallel: 2, recovery: { maxAttempts: 1 }, readBody: async () => "",
675
+ remote: { max: 2, isRemote: () => true, place: () => ({ where: "remote", reason: "mode remote" }), admit: () => ({ ok: true }), onBudget: "stop", costPerMinute: 0 },
676
+ runOne: async (n, _l, ctx = {}) => {
677
+ calls.push({ remote: !!ctx.remote, placement: ctx.placement?.where, recover: ctx.recover?.code ?? null });
678
+ return ctx.recover ? { terminalReason: "merged", pr: 9 } : { terminalReason: "needs-human", code: "env.capability_remote", detail: "no sops" };
679
+ } });
680
+ assert.deepEqual(calls, [{ remote: true, placement: "remote", recover: null }, { remote: false, placement: "local", recover: "env.capability_remote" }]);
681
+ assert.equal(rows.find((r) => r.issue === 5).outcome, "merged");
682
+ });
683
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * #4518: a builder CLI killed from outside (host or OOM guard, scope stop, operator SIGTERM) exits with SIGTERM/SIGKILL
3
+ * or status 143/137 and no timer of ours fired (our own kills throw CLI_TIMEOUT / CLI_IDLE / CLI_CONTEXT / CLI_TURNS).
4
+ */
5
+ export function isExternalKill(e) {
6
+ if (e?.code !== "CLI_BACKEND_FAILED") return false;
7
+ return e.signal === "SIGTERM" || e.signal === "SIGKILL" || e.status === 143 || e.status === 137;
8
+ }