forgedock 1.32.1 → 1.34.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 (77) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +3 -3
  3. package/bin/engine-cli.mjs +56 -11
  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.test.mjs +103 -2
  8. package/bin/lean/budget.mjs +7 -1
  9. package/bin/lean/budget.test.mjs +9 -1
  10. package/bin/lean/conformance.mjs +46 -10
  11. package/bin/lean/conformance.test.mjs +50 -2
  12. package/bin/lean/depcache.mjs +23 -3
  13. package/bin/lean/depcache.test.mjs +42 -1
  14. package/bin/lean/effects.mjs +1 -1
  15. package/bin/lean/execution.mjs +102 -12
  16. package/bin/lean/execution.test.mjs +101 -1
  17. package/bin/lean/hostkill.mjs +8 -0
  18. package/bin/lean/lean.mjs +49 -21
  19. package/bin/lean/lean.test.mjs +133 -7
  20. package/bin/lean/lightlane.mjs +38 -13
  21. package/bin/lean/lightlane.test.mjs +35 -1
  22. package/bin/lean/orchestrate.mjs +63 -17
  23. package/bin/lean/prompts.mjs +19 -9
  24. package/bin/lean/recovery.test.mjs +26 -0
  25. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  26. package/bin/lean/sandbox-remote.mjs +40 -7
  27. package/bin/lean/seqlease.mjs +49 -2
  28. package/bin/lean/seqlease.test.mjs +14 -0
  29. package/bin/lean/sequence.mjs +54 -6
  30. package/bin/lean/sequence.test.mjs +83 -1
  31. package/bin/pool/agent.mjs +435 -0
  32. package/bin/pool/cli.mjs +110 -0
  33. package/bin/pool/docker.mjs +56 -0
  34. package/bin/pool/http.mjs +55 -0
  35. package/bin/pool/router.mjs +299 -0
  36. package/bin/pool/store.mjs +25 -0
  37. package/bin/pool/version.mjs +44 -0
  38. package/bin/review-staging.mjs +14 -2
  39. package/bin/runner.mjs +1 -0
  40. package/bin/stats/aggregate.mjs +6 -0
  41. package/bin/stats/efficiency.mjs +103 -17
  42. package/bin/stats/efficiency.test.mjs +103 -4
  43. package/bin/stats/index.mjs +40 -6
  44. package/bin/stats/local-source.mjs +1 -1
  45. package/bin/stats/pool.mjs +51 -0
  46. package/bin/stats/pool.test.mjs +39 -0
  47. package/bin/stats/query.mjs +2 -2
  48. package/bin/stats/stats.test.mjs +15 -1
  49. package/commands/orchestrate.md +3 -3
  50. package/commands/review-pr-agents/protocols.md +1 -1
  51. package/commands/review-pr-staging.md +1 -1
  52. package/docs/CONFIG.md +23 -9
  53. package/docs/OUTCOME-CODES.md +5 -3
  54. package/docs/STATS.md +36 -1
  55. package/package.json +2 -1
  56. package/pool/README.md +13 -0
  57. package/pool/image/Dockerfile +46 -0
  58. package/pool/image/claude-shim +8 -0
  59. package/pool/image/cs-cred +12 -0
  60. package/pool/image/entrypoint.sh +61 -0
  61. package/pool/image/gh-shim +6 -0
  62. package/pool/install/install-agent-linux.sh +53 -0
  63. package/pool/install/install-agent.ps1 +225 -0
  64. package/pool/install/install-agent.sh +101 -0
  65. package/pool/install/install-router.sh +67 -0
  66. package/pool/install/keepalive.ps1 +36 -0
  67. package/pool/install/uninstall-agent.ps1 +20 -0
  68. package/scripts/hotspots.sh +33 -21
  69. package/scripts/hotspots.test.sh +22 -3
  70. package/scripts/review-finalize.sh +17 -1
  71. package/scripts/review-finalize.test.sh +41 -0
  72. package/scripts/review-pr-domain-map.test.sh +2 -2
  73. package/scripts/review-pr-staging-scope.test.sh +4 -2
  74. package/scripts/review-prepare.sh +62 -27
  75. package/scripts/review-prepare.test.sh +53 -0
  76. package/scripts/review-staging-pre.sh +29 -9
  77. package/scripts/review-staging-pre.test.sh +18 -0
@@ -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;
@@ -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
+ }
package/bin/lean/lean.mjs CHANGED
@@ -29,11 +29,12 @@ import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSI
29
29
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
30
30
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
31
31
  import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
32
+ import { hostCapabilityBlock } from "./execution.mjs";
32
33
  import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt } from "./prompts.mjs";
33
34
  import { postMergeCommands } from "./postmerge.mjs";
34
35
  import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
35
36
  import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
36
- import { makeTurnBudget, laneSize, wrapupGrant, budgetDigest } from "./budget.mjs";
37
+ import { makeTurnBudget, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
37
38
  import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog } from "./pitfalls.mjs";
38
39
  import { unjustified } from "./enumerating.mjs";
39
40
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
@@ -46,6 +47,7 @@ import { priorityFromLabels } from "./sched.mjs";
46
47
  import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
47
48
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
48
49
  import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
50
+ import { isExternalKill } from "./hostkill.mjs";
49
51
  import { buildResumeDigest } from "./resume.mjs";
50
52
  import { lightEligibility } from "./light.mjs";
51
53
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
@@ -61,6 +63,7 @@ export const RECOVER_HOW = Object.freeze({
61
63
  [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
62
64
  [OUTCOME_CODES.BUDGET_EXCEEDED]: "an earlier run spent the implement turn budget for this issue's size; its digest is in the earlier run's detail and the branch holds its checkpointed work. Continue from the checkpoint against the frozen contract without starting over; if the remaining work cannot fit the budget, stop with a typed split-required block naming the sub-issues instead of grinding",
63
65
  [OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
66
+ [OUTCOME_CODES.ENV_CAPABILITY_REMOTE]: "the earlier run ran in a remote sandbox that lacks a host-only capability (SOPS/age key, ops credentials, read-only prod monitor SSH); this host has it. Do the work that needed it now, with the host's own tools and credentials, and never print or commit a secret in the clear",
64
67
  [OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
65
68
  });
66
69
  /** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
@@ -181,6 +184,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
181
184
  * @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
182
185
  * @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
183
186
  * @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
187
+ * @param {object} [o.hostCaps] {hostCapabilities, hostCapabilityPatterns}: forge.yaml execution.host_capabilities (#4542); a remote credentials block naming one is env.capability_remote
184
188
  * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
185
189
  * @param {object} [o.explore] {mode: on|off, model}: forge.yaml lean.explore / lean.explore_model (#4391, default off): the explorer subagent
186
190
  * @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
@@ -290,7 +294,7 @@ export async function runCore(o) {
290
294
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
291
295
  let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
292
296
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
293
- let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (skips structure/hotspot gates); summed session time
297
+ let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (the same structure/hotspot gates as the full lane, #4502); summed session time
294
298
  let lightVerdict = null, lightUpgrade = null; // #4453: the eligibility verdict and the upgrade reason codes, on the run result for the bench
295
299
  let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
296
300
  const runId = `${issue}-${t0}`;
@@ -401,6 +405,7 @@ export async function runCore(o) {
401
405
  async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
402
406
  try { seqLease?.release(); } catch { /* fail-open */ }
403
407
  seqLease = null;
408
+ try { await io.sequenceLease?.unreserve?.({ repo, lane, issue }); } catch { /* fail-open (#4526) */ }
404
409
  if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
405
410
  try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
406
411
  }
@@ -418,7 +423,7 @@ export async function runCore(o) {
418
423
  // decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
419
424
  // (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
420
425
  // either launches the next attempt or relabels in settle.
421
- if (recoverUnlabelled && recover.final && reason === "ci-pending") {
426
+ if (recoverUnlabelled && recover.final && (reason === "ci-pending" || reason === "budget-exceeded")) { // #4505: budget-exceeded too
422
427
  try {
423
428
  await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
424
429
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
@@ -428,8 +433,9 @@ export async function runCore(o) {
428
433
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
429
434
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
430
435
  }
431
- if (["merged", "invalid", "needs-human"].includes(reason)) {
432
- if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
436
+ if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
437
+ if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
438
+ else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
433
439
  else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
434
440
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
435
441
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
@@ -654,13 +660,14 @@ export async function runCore(o) {
654
660
  p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
655
661
  continue;
656
662
  }
657
- if (!(e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
663
+ const hostKilled = isExternalKill(e); // #4518: SIGTERM/143 we did not send restarts like a timeout, and never ends engine-error
664
+ if (!(hostKilled || e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
658
665
  const idle = e.code === "CLI_IDLE";
659
- const reason = idle ? "idle timeout" : "timeout";
660
- ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
666
+ const reason = hostKilled ? "host kill" : idle ? "idle timeout" : "timeout";
667
+ ev({ event: hostKilled ? "CLI_HOST_KILL" : idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
661
668
  ...(idle ? { heavyQueue: e.heavyQueue ?? null, extended: e.extended === true } : {}) });
662
669
  await checkpoint(reason);
663
- if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
670
+ if (timeoutRetries++ >= 1) { e.deferCode = hostKilled ? OUTCOME_CODES.CLI_HOST_KILLED : idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
664
671
  T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
665
672
  T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
666
673
  p = restart(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "timeout_restart";
@@ -722,7 +729,8 @@ export async function runCore(o) {
722
729
  const contextRestartNote = async () => contextRestartPrompt({ ...await restartFacts(), ceiling: contextCeilingTokens });
723
730
  /**
724
731
  * #4392: the implement turn budget is spent. Checkpoint, then one fresh session of `wrapupGrant` turns either finishes
725
- * (its result flows on as usual) or stops with a digest; a stop or an overrun ends needs-human `budget.exceeded`.
732
+ * (its result flows on as usual) or stops with a digest; a stop or an overrun ends the non-human `budget-exceeded` reason
733
+ * (#4505: no label, no NEEDS_HUMAN comment; the batch recovers it at the next size), needs-human only in a final budget.exceeded recovery.
726
734
  */
727
735
  async function budgetWrapup(n, opts) {
728
736
  const size = tb.size, budget = tb.budget(), used = tb.record().build, grant = wrapupGrant(budget);
@@ -794,6 +802,11 @@ export async function runCore(o) {
794
802
  async function endBlocked(b, summary) {
795
803
  const s = summary ?? "(no summary)";
796
804
  const refs = (ns) => ns.map((n) => `#${n}`).join(", ");
805
+ if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote") {
806
+ // #4542: the sandbox relays none of the host's capabilities; a block naming only declared ones is an environment error the host recovers.
807
+ const caps = hostCapabilityBlock(s, o.hostCaps);
808
+ if (caps.length) return await needsHuman(`Remote sandbox lacks host capability ${caps.join(", ")} (Builder is blocked (credentials)): ${s}\n\nThe dispatching host has it; the lane is recovered locally.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
809
+ }
797
810
  if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
798
811
  if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
799
812
  if (b.kind === "dependency") {
@@ -1384,12 +1397,12 @@ export async function runCore(o) {
1384
1397
  let lightDone = false, lightCommits = false;
1385
1398
  if (lightTry) {
1386
1399
  liveKeys = ["implementation"]; stage = "implement";
1387
- tb.setSize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }));
1388
- const lr = await runLightStep({ title: iss.title, issueText, riskPaths, cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
1400
+ tb.setSize(recoverySize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4505
1401
+ const lr = await runLightStep({ title: iss.title, issueText, riskPaths, predictedFiles: namedPaths(iss.body ?? ""), cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
1389
1402
  build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
1390
1403
  readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
1391
- runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
1392
- run: ({ command, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
1404
+ runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
1405
+ run: ({ command, timeoutMs, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, timeoutMs, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
1393
1406
  fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
1394
1407
  apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
1395
1408
  stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
@@ -1485,7 +1498,7 @@ export async function runCore(o) {
1485
1498
  T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
1486
1499
  }
1487
1500
  T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
1488
- tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
1501
+ tb.setSize(recoverySize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4392, #4505: next size on a budget recovery
1489
1502
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1490
1503
  // #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
1491
1504
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
@@ -1585,7 +1598,7 @@ export async function runCore(o) {
1585
1598
  const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
1586
1599
  return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
1587
1600
  };
1588
- let structure = light ? null : await structureOf(); // #4451: no structure delta on a light lane
1601
+ let structure = await structureOf(); // #4502: a light lane gets the same structure gate
1589
1602
  if (structure) {
1590
1603
  const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
1591
1604
  const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
@@ -1655,7 +1668,7 @@ export async function runCore(o) {
1655
1668
  ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
1656
1669
  const taken = collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ");
1657
1670
  if (io.sequenceRenumber) {
1658
- const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
1671
+ const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null, reserve: io.sequenceLease?.reserve ? (x) => io.sequenceLease.reserve({ repo, lane, issue, ...x }) : null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
1659
1672
  if (det.ok && det.moves?.length) {
1660
1673
  ev({ event: "SEQUENCE_RENUMBERED", trigger, mode: "deterministic", moves: det.moves });
1661
1674
  T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; renumbered deterministically (no builder): ${det.moves.map((m) => `${m.from} → ${m.to}`).join(", ")}`);
@@ -1810,8 +1823,10 @@ export async function runCore(o) {
1810
1823
  let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
1811
1824
  const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
1812
1825
  const triggerSkipNote = "## Conformance Review\n**Skipped by `review.trigger: enforce`:** no risk path, package edge or hotspot in the diff, and every deterministic gate passed; no reviewer ran (#4273).";
1826
+ let genPaths = null; // forge#4519: maintenance.generated_paths from hotspots.sh (plan facts; one config-only call without them, never on light lanes)
1813
1827
  async function amend() {
1814
- const a = applyAmendments(frozen, result?.amendments, issueText);
1828
+ genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
1829
+ const a = applyAmendments(frozen, result?.amendments, issueText, { generatedPaths: genPaths });
1815
1830
  const sig = JSON.stringify(a.applied);
1816
1831
  if (a.applied.length && sig !== lastAmend) {
1817
1832
  lastAmend = sig;
@@ -1926,8 +1941,9 @@ export async function runCore(o) {
1926
1941
  T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
1927
1942
  }
1928
1943
  const amendProblems = await amend();
1929
- const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1930
- const hotspots = light ? null : await hotspotsOf({ base, paths: files }); // #4451
1944
+ const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
1945
+ const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
1946
+ const hotspots = await hotspotsOf({ base, paths: files }); // #4502
1931
1947
  const validateRuns = await rerunFailedValidates(effective);
1932
1948
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
1933
1949
  const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
@@ -1937,6 +1953,12 @@ export async function runCore(o) {
1937
1953
  const note = `Scope-check validate ${pc.superseded.map((x) => x.id).join(", ")} superseded by the scope gate (amended: ${[...new Set(pc.superseded.flatMap((x) => x.files))].join(", ")})`;
1938
1954
  if (!T.decisions.includes(note)) T.decisions.push(note);
1939
1955
  }
1956
+ // #4527: name the validate ids CI verified instead of a local run
1957
+ if (pc.ciCovered?.length) {
1958
+ ev({ event: "VALIDATE_CI_VERIFIED", ids: pc.ciCovered, head: gateHead });
1959
+ const note = `Validate ${pc.ciCovered.join(", ")} verified by CI at ${gateHead.slice(0, 12)} (no local result)`;
1960
+ if (!T.decisions.includes(note)) T.decisions.push(note);
1961
+ }
1940
1962
  Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
1941
1963
  // #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
1942
1964
  // never a conformance failure, never a builder resume
@@ -2227,6 +2249,8 @@ export async function runCore(o) {
2227
2249
  await fixRound("review", conformanceResumePrompt(rv.failures, vcfg.mode), rv.failures, "review", failureLog(rv.failures));
2228
2250
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
2229
2251
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
2252
+ // #4527: the fix round's head is CI-verified before the retry review, as in the main loop
2253
+ if (remoteVerify) { const v = await ciVerify("base-sync"); if (v.done) return v.done; }
2230
2254
  rv = await doReview();
2231
2255
  if (rv.env) return await envNeedsHuman(rv.env);
2232
2256
  }
@@ -2293,7 +2317,7 @@ export async function runCore(o) {
2293
2317
  * only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
2294
2318
  */
2295
2319
  async function recordStructure(pr) {
2296
- const d = (light ? null : await structureOf()) ?? {}; // #4451
2320
+ const d = (await structureOf()) ?? {};
2297
2321
  const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
2298
2322
  const top_growth = numstat.split("\n").map((l) => l.split("\t")).filter((x) => x.length >= 3 && /^\d+$/.test(x[0]) && Number(x[0]) > 0 && !x[2].startsWith(`${LEAN_DIR}/`))
2299
2323
  .map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
@@ -2491,6 +2515,10 @@ export async function runCore(o) {
2491
2515
  return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env) }, OUTCOME_CODES.RATE_LIMITED);
2492
2516
  }
2493
2517
  if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
2518
+ // #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
2519
+ if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
2520
+ return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);
2521
+ }
2494
2522
  if (e?.laneCode) return await needsHuman(e.message, e.laneCode);
2495
2523
  if (e?.code === "CLI_SPAWN_FAILED" || e?.spawnDeterministic === true) { // #4129: certain to fail again, so not engine-error (orchestrate would relaunch it)
2496
2524
  return await needsHuman(`The claude CLI could not be started (${e.spawnErrno ?? "spawn error"}); a rerun fails the same way: ${String(e.message).slice(0, 400)}`, OUTCOME_CODES.CLI_SPAWN_FAILED);