forgedock 1.32.1 → 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.
- package/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +3 -3
- package/bin/engine-cli.mjs +56 -11
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.test.mjs +103 -2
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.mjs +102 -12
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +49 -21
- package/bin/lean/lean.test.mjs +133 -7
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +63 -17
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
package/bin/lean/execution.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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 (
|
|
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 === "
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1930
|
-
const
|
|
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 = (
|
|
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);
|