forgedock 1.32.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- 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.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- 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 +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- 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/scripts/refresh-bot-token.test.sh +56 -0
|
@@ -38,7 +38,7 @@ const FIXTURES = {
|
|
|
38
38
|
const LOCKFILE = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", uv: "uv.lock", poetry: "poetry.lock" };
|
|
39
39
|
|
|
40
40
|
/** Fake package managers: write what a real install leaves behind, including the absolute paths that need rewriting. */
|
|
41
|
-
function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
|
|
41
|
+
function fakeExec({ failInstall = false, failRepair = false, installDelayMs = 0 } = {}) {
|
|
42
42
|
const calls = [];
|
|
43
43
|
const venv = (cwd, lock) => {
|
|
44
44
|
const v = join(cwd, ".venv");
|
|
@@ -65,6 +65,10 @@ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
|
|
|
65
65
|
if (full && installDelayMs) await new Promise((r) => setTimeout(r, installDelayMs));
|
|
66
66
|
if (full && failInstall) { put(join(cwd, cmd === "uv" || cmd === "poetry" ? ".venv/partial" : "node_modules/partial"), "x"); throw Object.assign(new Error("install failed"), { stderr: "ERR_PNPM_FROZEN_LOCKFILE" }); }
|
|
67
67
|
if (cmd === "pnpm") {
|
|
68
|
+
if (args.includes("--offline") && failRepair) {
|
|
69
|
+
// pnpm prints its errors on stdout; execFile's error then carries an empty stderr (#4522).
|
|
70
|
+
throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile --offline"), { stderr: "", stdout: "Lockfile is up to date\n ERR_PNPM_TEST_REPAIR some cause\n" });
|
|
71
|
+
}
|
|
68
72
|
if (args.includes("--offline")) return ""; // warm repair pass: nothing to do
|
|
69
73
|
const lock = readFileSync(join(cwd, "pnpm-lock.yaml"), "utf8");
|
|
70
74
|
put(join(cwd, "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js"), `module.exports = ${JSON.stringify(lock)};\n`);
|
|
@@ -222,6 +226,43 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
|
|
|
222
226
|
assert.equal(r.published, true, "the stale lock was replaced");
|
|
223
227
|
});
|
|
224
228
|
|
|
229
|
+
it("#4522 a snapshot whose repair fails falls back to a fresh install outside the lock, keeps the snapshot and records pnpm's stdout error", async () => {
|
|
230
|
+
const s = setup("pnpm");
|
|
231
|
+
const [ra] = await s.cache.prepare({ wt: s.lane("a"), repoKey: "r" });
|
|
232
|
+
assert.equal(ra.status, "miss-installed", ra.reason);
|
|
233
|
+
const [snap] = snapDirs(s.root, "pnpm");
|
|
234
|
+
const before = snapshotOf(snap);
|
|
235
|
+
const broken = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true }).exec, env: {}, pollMs: 5 });
|
|
236
|
+
const wt = s.lane("b");
|
|
237
|
+
const [rb] = await broken.prepare({ wt, repoKey: "r" });
|
|
238
|
+
assert.equal(rb.status, "materialize-fallback", rb.reason);
|
|
239
|
+
assert.equal(rb.published, false);
|
|
240
|
+
assert.match(rb.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR {2}some cause/);
|
|
241
|
+
assert.ok(existsSync(join(wt, "node_modules/lodash/index.js")), "the lane has node_modules");
|
|
242
|
+
const after = snapshotOf(snap);
|
|
243
|
+
delete before["last-used"]; delete after["last-used"];
|
|
244
|
+
assert.deepEqual(after, before, "the snapshot is untouched");
|
|
245
|
+
assert.deepEqual(readdirSync(join(s.root, "r")).filter((n) => !/^pnpm-[0-9a-f]{16}$/.test(n)), [], "no lock or tmp left");
|
|
246
|
+
|
|
247
|
+
const both = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true, failInstall: true }).exec, env: {}, pollMs: 5 });
|
|
248
|
+
const wt2 = s.lane("c");
|
|
249
|
+
const [rc] = await both.prepare({ wt: wt2, repoKey: "r" });
|
|
250
|
+
assert.equal(rc.status, "failed");
|
|
251
|
+
assert.match(rc.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR.*; install failed: ERR_PNPM_FROZEN_LOCKFILE/);
|
|
252
|
+
assert.ok(!existsSync(join(wt2, "node_modules")), "no partial tree");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("#4522 a failed pnpm install records the ERR_PNPM line it printed on stdout", async () => {
|
|
256
|
+
const s = setup("pnpm");
|
|
257
|
+
const exec = async (cmd, args, o) => {
|
|
258
|
+
if (cmd === "pnpm" && !args.includes("--offline")) throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile"), { stderr: "", stdout: "Progress: resolved 1\n ERR_PNPM_OUTDATED_LOCKFILE Cannot install with frozen-lockfile\n\nNote that in CI environments this setting is true by default.\nA\nB\nC\n" });
|
|
259
|
+
return s.f.exec(cmd, args, o);
|
|
260
|
+
};
|
|
261
|
+
const [r] = await makeDepCache({ root: s.root, exec, env: {} }).prepare({ wt: s.lane("x"), repoKey: "r" });
|
|
262
|
+
assert.equal(r.status, "failed");
|
|
263
|
+
assert.match(r.reason, /^install failed: ERR_PNPM_OUTDATED_LOCKFILE {2}Cannot install with frozen-lockfile A B C$/);
|
|
264
|
+
});
|
|
265
|
+
|
|
225
266
|
it("a failed install, a missing tool or another filesystem leaves no snapshot and no partial tree", async () => {
|
|
226
267
|
const s = setup("pnpm", { failInstall: true });
|
|
227
268
|
const wt = s.lane("x");
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
4
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
6
|
+
import { readFileSync, appendFileSync, mkdirSync, mkdtempSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
|
|
8
8
|
import { promises as fsp } from "node:fs";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -234,10 +234,36 @@ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }
|
|
|
234
234
|
});
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* #4507: a per-run gh HTTP cache (XDG_CACHE_HOME on the gh child only), so concurrent lanes never share a cache entry.
|
|
239
|
+
* `reset()` drops the current dir (only one this cache created) and switches to a fresh one; with no `root` the dirs
|
|
240
|
+
* are mkdtemp dirs under `tmp`, removed by `dispose()`.
|
|
241
|
+
*/
|
|
242
|
+
export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
|
|
243
|
+
let n = 0, dir = root ? join(root, "gh-cache") : null, disposed = false;
|
|
244
|
+
const made = new Set();
|
|
245
|
+
const drop = (d) => { if (d && made.has(d)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } made.delete(d); } };
|
|
246
|
+
return {
|
|
247
|
+
env() {
|
|
248
|
+
if (!dir || disposed) return undefined; // after dispose (run teardown) gh falls back to its default cache
|
|
249
|
+
if (!made.has(dir)) { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.add(dir); } catch { return undefined; } }
|
|
250
|
+
return { XDG_CACHE_HOME: dir };
|
|
251
|
+
},
|
|
252
|
+
reset() {
|
|
253
|
+
drop(dir);
|
|
254
|
+
if (disposed) return;
|
|
255
|
+
if (root) { dir = join(root, `gh-cache-${++n}`); return; }
|
|
256
|
+
try { dir = mkdtempSync(join(tmp, "forge-gh-cache-")); made.add(dir); } catch { dir = null; }
|
|
257
|
+
},
|
|
258
|
+
dispose() { disposed = true; for (const d of [...made]) drop(d); },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
237
262
|
export function makeLeanIo({ repo }) {
|
|
238
263
|
const base = makeIo();
|
|
239
|
-
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
264
|
+
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
|
|
240
265
|
return {
|
|
266
|
+
ghCache: (root) => makeGhCache({ root }),
|
|
241
267
|
gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
242
268
|
/** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
|
|
243
269
|
deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
|
|
@@ -344,7 +370,7 @@ export function makeLeanIo({ repo }) {
|
|
|
344
370
|
sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
|
|
345
371
|
sequenceVerify: ({ cwd, base, before, moves }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before, moves }),
|
|
346
372
|
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
347
|
-
sequenceRenumber: ({ cwd, base, laneRef, regenerate }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate }),
|
|
373
|
+
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
|
|
348
374
|
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
349
375
|
sequenceLease: makeSequenceLeaseIo(),
|
|
350
376
|
/** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
|
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;
|
|
@@ -411,7 +501,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
411
501
|
await tear(led, h); // every path, after the result is in (tear never throws)
|
|
412
502
|
return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
|
|
413
503
|
},
|
|
414
|
-
adopt: async (n, rec) => {
|
|
504
|
+
adopt: async (n, rec, ctx = {}) => {
|
|
415
505
|
const saved = rec?.handle;
|
|
416
506
|
if (!saved?.id || saved.provider !== provider.name) return null;
|
|
417
507
|
// Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
|
|
@@ -420,7 +510,9 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
420
510
|
led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
|
|
421
511
|
let r;
|
|
422
512
|
try {
|
|
423
|
-
|
|
513
|
+
const ended = await provider.fetchResult(h);
|
|
514
|
+
if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
|
|
515
|
+
else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
|
|
424
516
|
} catch (e) {
|
|
425
517
|
r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
|
|
426
518
|
}
|
|
@@ -6,7 +6,7 @@ import assert from "node:assert/strict";
|
|
|
6
6
|
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
-
import { parseExecution, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
9
|
+
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
10
10
|
import { makeLedger, countLiveLanes } from "./ledger.mjs";
|
|
11
11
|
import { openBatch } from "./batch.mjs";
|
|
12
12
|
import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
|
|
@@ -42,6 +42,11 @@ const NO_BUDGET = { batchMinutes: null, batchUsd: null, dayMinutes: null, dayUsd
|
|
|
42
42
|
const ledgerIn = (root, { pid = process.pid, host = HOST } = {}) => (n) => makeLedger({ root, batch: "bt", lane: `sandbox-${n}`, owner: "orch_test", issue: n, host, pid });
|
|
43
43
|
|
|
44
44
|
describe("execution config (#4253)", () => {
|
|
45
|
+
it("pool is a known sandbox provider (#4546)", () => {
|
|
46
|
+
assert.ok(SANDBOX_PROVIDERS.includes("pool"));
|
|
47
|
+
assert.equal(parseExecution({ mode: "hybrid", max_remote: "1", provider: "pool" }).provider, "pool");
|
|
48
|
+
});
|
|
49
|
+
|
|
45
50
|
it("absent, empty or invalid config is local; max_remote and secrets are validated", () => {
|
|
46
51
|
assert.deepEqual(parseExecution(undefined), { mode: "local", maxRemote: 0, provider: "local", image: null, secrets: [], budget: NO_BUDGET });
|
|
47
52
|
assert.deepEqual(parseExecution({ mode: "cloud", max_remote: "-1" }), { mode: "local", maxRemote: 0, provider: "local", image: null, secrets: [], budget: NO_BUDGET });
|
|
@@ -440,6 +445,19 @@ describe("execution budget and sandbox cost (#4255)", () => {
|
|
|
440
445
|
"sandbox: 1 lane, 12.5 min, $0.25 (token cost $1.50); budget: batch 10 min, day $20; host today 12.5 min, $0.25; cap reached, 1 held");
|
|
441
446
|
});
|
|
442
447
|
|
|
448
|
+
it("report: sandbox usage agent becomes per-agent minutes on the row and a by-agent summary suffix (#4549)", async () => {
|
|
449
|
+
const lines = formatSandboxSummary([
|
|
450
|
+
{ issue: 1, outcome: "merged", cost_usd: 1, sandbox: { minutes: 10, cost_usd: 0, attempts: 1, by_agent: { legion: 10 } } },
|
|
451
|
+
{ issue: 2, outcome: "merged", cost_usd: 1, sandbox: { minutes: 30.5, cost_usd: 0, attempts: 2, by_agent: { legion: 20.5, box2: 10 } } }]);
|
|
452
|
+
assert.match(lines, /; by agent: legion 30\.5 min, box2 10\.0 min$/);
|
|
453
|
+
assert.doesNotMatch(formatSandboxSummary([{ issue: 1, outcome: "merged", cost_usd: 1, sandbox: { minutes: 1, cost_usd: 0 } }]), /by agent/);
|
|
454
|
+
const prov = fakeProvider({ finish: () => ({ terminalReason: "merged", pr: 2 }), usage: async () => ({ minutes: 7, agent: "legion" }) });
|
|
455
|
+
const lanes = providerLanes({ provider: prov, laneArgs: () => [], ledgerFor: ledgerIn(tmpDir("exec-agent-")), pollMs: 1, now: () => 1000 });
|
|
456
|
+
const rows = await orchestrate({ issues: [5], parallel: 1, store: null, readBody: async () => "", now: () => 0, remote: { max: 1, isRemote: () => true },
|
|
457
|
+
runOne: (n, l, ctx) => lanes.runOne(n, l, ctx) });
|
|
458
|
+
assert.deepEqual(rows[0].sandbox.by_agent, { legion: 7 });
|
|
459
|
+
});
|
|
460
|
+
|
|
443
461
|
it("runBatch: rows, report.json and FORGE:TELEMETRY carry each lane's sandbox usage; the host-day store records it", async () => {
|
|
444
462
|
const work = tmpDir("exec-cli-cost-");
|
|
445
463
|
const ledRoot = join(work, "ledger");
|
|
@@ -502,6 +520,28 @@ describe("hybrid placement (#4256)", () => {
|
|
|
502
520
|
assert.deepEqual(await validationWeight({ cwd: repoWith({}), precheckCommands: ["echo ok", "git diff --check"] }), { heavy: false, signals: [] });
|
|
503
521
|
});
|
|
504
522
|
|
|
523
|
+
it("validationWeight sees monorepo subdirectory markers (#4523)", async () => {
|
|
524
|
+
const repo = (files) => { const d = tmpDir("vw-mono-"); for (const [f, t] of Object.entries(files)) { mkdirSync(join(d, f, ".."), { recursive: true }); writeFileSync(join(d, f), t); } return d; };
|
|
525
|
+
const mono = repo({ "package.json": "{}", "services/x/pyproject.toml": "[project]", "web/tsconfig.json": "{}" });
|
|
526
|
+
assert.deepEqual(await validationWeight({ cwd: mono }), { heavy: true, signals: ["python", "tsc"] });
|
|
527
|
+
assert.equal((await validationWeight({ cwd: repo({ "web/package.json": JSON.stringify({ scripts: { lint: "eslint ." } }) }) })).heavy, true);
|
|
528
|
+
assert.deepEqual(await validationWeight({ cwd: repo({ "node_modules/a/tsconfig.json": "{}", ".git/x/go.mod": "m", "a/b/c/Cargo.toml": "x" }) }), { heavy: false, signals: [] });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("execution.heavy overrides placement only (#4523)", () => {
|
|
532
|
+
const hy = (heavy) => parseExecution({ mode: "hybrid", max_remote: "2", ...(heavy === undefined ? {} : { heavy }) });
|
|
533
|
+
assert.equal(hy().heavy, undefined);
|
|
534
|
+
assert.equal(hy("TRUE").heavy, true);
|
|
535
|
+
assert.equal(hy("false").heavy, false);
|
|
536
|
+
assert.equal(hy("maybe").heavy, undefined);
|
|
537
|
+
const LIGHT = { heavy: false, signals: [] }, HEAVY = { heavy: true, signals: ["tsc"] };
|
|
538
|
+
assert.deepEqual(placeLane({ exec: hy("true"), weight: LIGHT, headroom: { ok: true } }), { where: "remote", reason: "execution.heavy: true" });
|
|
539
|
+
assert.deepEqual(placeLane({ exec: hy("false"), weight: HEAVY, headroom: { ok: true } }), { where: "local", reason: "light lane, host has headroom" });
|
|
540
|
+
assert.equal(placeLane({ exec: hy("false"), weight: HEAVY, headroom: { ok: false, detail: "mem" } }).where, "remote");
|
|
541
|
+
assert.equal(placeLane({ exec: hy("true"), verifyMode: "ci", weight: LIGHT }).where, "local");
|
|
542
|
+
assert.equal(placeLane({ exec: parseExecution({ mode: "hybrid", max_remote: "0", heavy: "true" }), weight: LIGHT }).where, "local");
|
|
543
|
+
});
|
|
544
|
+
|
|
505
545
|
it("hostHeadroom: memory below the admission floor plus one heavy step, or a configured load ceiling", () => {
|
|
506
546
|
const host = (mem, load = 1, cores = 4) => ({ memAvailableMb: () => mem, loadAvg1: () => load, cores: () => cores });
|
|
507
547
|
assert.deepEqual(hostHeadroom({ host: host(8000) }), { ok: true, detail: "" });
|
|
@@ -581,3 +621,63 @@ describe("hybrid placement (#4256)", () => {
|
|
|
581
621
|
assert.deepEqual(res.rows[0].placement, { where: "remote", reason: "heavy validation: tsc" });
|
|
582
622
|
});
|
|
583
623
|
});
|
|
624
|
+
|
|
625
|
+
describe("#4547: fallback_local and agent liveness", () => {
|
|
626
|
+
it("parseExecution: fallback_local is opt-in", () => {
|
|
627
|
+
assert.equal(parseExecution({ mode: "remote", max_remote: "2" }).fallbackLocal, undefined);
|
|
628
|
+
assert.equal(parseExecution({ mode: "remote", max_remote: "2", fallback_local: "true" }).fallbackLocal, true);
|
|
629
|
+
assert.equal(parseExecution({ mode: "remote", fallback_local: "yes" }).fallbackLocal, undefined);
|
|
630
|
+
});
|
|
631
|
+
it("transport capacity carries free and liveness; none valid is unknown", async () => {
|
|
632
|
+
const { httpTransport } = await import("./sandbox-remote.mjs");
|
|
633
|
+
const cap = async (data, ok = true) => httpTransport({ endpoint: "http://pool.test", token: "t", fetchFn: async () => ({ ok, status: ok ? 200 : 500, headers: new Headers(), text: async () => JSON.stringify(data) }) }).capacity();
|
|
634
|
+
assert.deepEqual(await cap({ online: 0, total: 2, agents: [] }), { online: 0, total: 2 });
|
|
635
|
+
assert.deepEqual(await cap({ free: 3 }), { free: 3 });
|
|
636
|
+
assert.equal(await cap({ online: -1 }), null);
|
|
637
|
+
assert.equal(await cap({ online: 1 }, false), null);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe("host capabilities in placement (#4542)", () => {
|
|
642
|
+
it("placeLane: a need the provider does not relay is local in remote and hybrid mode, with the reason", () => {
|
|
643
|
+
assert.deepEqual(placeLane({ exec: { mode: "remote" }, needs: ["sops"] }), { where: "local", reason: "host capability: sops" });
|
|
644
|
+
assert.deepEqual(placeLane({ exec: { mode: "hybrid", maxRemote: 2, heavy: true }, needs: ["prod_monitor", "sops"] }), { where: "local", reason: "host capability: prod_monitor, sops" });
|
|
645
|
+
assert.equal(placeLane({ exec: { mode: "remote", maxRemote: 2 }, needs: ["sops"], relays: ["sops"] }).where, "remote");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("no needs: placement is unchanged", () => {
|
|
649
|
+
assert.deepEqual(placeLane({ exec: { mode: "remote", maxRemote: 2 }, needs: [] }), { where: "remote", reason: "mode remote" });
|
|
650
|
+
assert.deepEqual(placeLane({ exec: { mode: "remote", maxRemote: 2 } }), { where: "remote", reason: "mode remote" });
|
|
651
|
+
assert.equal(placeLane({ exec: { mode: "hybrid", maxRemote: 2, heavy: true } }).where, "remote");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("detectNeeds: built-in signals, declared names and declared patterns, zero LLM", () => {
|
|
655
|
+
assert.deepEqual(detectNeeds("Edit `.secrets/prod.enc.yaml` and run ssh ovh-monitor; see credentials.yaml"), ["ops_credentials", "prod_monitor", "sops"]);
|
|
656
|
+
assert.deepEqual(detectNeeds("plain change to src/a.js"), []);
|
|
657
|
+
const exec = parseExecution({ mode: "remote", max_remote: "2", host_capabilities: "vault, Bad Name", host_capability_patterns: "vault:vault\\s+kv, x:(" });
|
|
658
|
+
assert.deepEqual(exec.hostCapabilities, ["vault"]);
|
|
659
|
+
assert.deepEqual(exec.hostCapabilityPatterns, [{ name: "vault", pattern: "vault\\s+kv" }]);
|
|
660
|
+
assert.deepEqual(detectNeeds("run vault kv get", exec), ["vault"]);
|
|
661
|
+
assert.deepEqual(detectNeeds("needs the Vault", exec), ["vault"]);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("hostCapabilityBlock: only declared capabilities count; a mixed or unknown need stays a human block", () => {
|
|
665
|
+
const caps = { hostCapabilities: ["sops"] };
|
|
666
|
+
assert.deepEqual(hostCapabilityBlock("this lane has no sops key", caps), ["sops"]);
|
|
667
|
+
assert.deepEqual(hostCapabilityBlock("this lane has no sops key", null), []);
|
|
668
|
+
assert.deepEqual(hostCapabilityBlock("no sops key and no ssh ovh-monitor", caps), []);
|
|
669
|
+
assert.deepEqual(hostCapabilityBlock("owner must create an account", caps), []);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("orchestrate: a remote lane that ends env.capability_remote is relaunched locally once", async () => {
|
|
673
|
+
const calls = [];
|
|
674
|
+
const rows = await orchestrate({ issues: [5], parallel: 2, recovery: { maxAttempts: 1 }, readBody: async () => "",
|
|
675
|
+
remote: { max: 2, isRemote: () => true, place: () => ({ where: "remote", reason: "mode remote" }), admit: () => ({ ok: true }), onBudget: "stop", costPerMinute: 0 },
|
|
676
|
+
runOne: async (n, _l, ctx = {}) => {
|
|
677
|
+
calls.push({ remote: !!ctx.remote, placement: ctx.placement?.where, recover: ctx.recover?.code ?? null });
|
|
678
|
+
return ctx.recover ? { terminalReason: "merged", pr: 9 } : { terminalReason: "needs-human", code: "env.capability_remote", detail: "no sops" };
|
|
679
|
+
} });
|
|
680
|
+
assert.deepEqual(calls, [{ remote: true, placement: "remote", recover: null }, { remote: false, placement: "local", recover: "env.capability_remote" }]);
|
|
681
|
+
assert.equal(rows.find((r) => r.issue === 5).outcome, "merged");
|
|
682
|
+
});
|
|
683
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4518: a builder CLI killed from outside (host or OOM guard, scope stop, operator SIGTERM) exits with SIGTERM/SIGKILL
|
|
3
|
+
* or status 143/137 and no timer of ours fired (our own kills throw CLI_TIMEOUT / CLI_IDLE / CLI_CONTEXT / CLI_TURNS).
|
|
4
|
+
*/
|
|
5
|
+
export function isExternalKill(e) {
|
|
6
|
+
if (e?.code !== "CLI_BACKEND_FAILED") return false;
|
|
7
|
+
return e.signal === "SIGTERM" || e.signal === "SIGKILL" || e.status === 143 || e.status === 137;
|
|
8
|
+
}
|