forgedock 1.26.0 → 1.28.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/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* Phase-aware host admission (#4169): lanes run their LLM phases freely (bounded only by the host lane cap, #4191),
|
|
6
|
+
* and only `heavy` steps (installs, type checks, linters, test runners, containers) take a slot of one host semaphore.
|
|
7
|
+
* State is one file, `heavy.json` in `schedDir()` (next to the lane registry): `{v, seq, maxQueued, holders:{key:{pid,
|
|
8
|
+
* issue, phase, priority, mb, at}}, waiters:{key:{pid, issue, phase, priority, seq, at}}}`, mutated under an exclusive
|
|
9
|
+
* lock file (`heavy.lock`, owner pid; a dead owner's lock is taken over) with `writeAtomic`, like `sched.mjs`.
|
|
10
|
+
*
|
|
11
|
+
* A grant needs: live holders < max (default max(1, cores - 1)); the caller first among waiters (priority, then FIFO);
|
|
12
|
+
* and MemAvailable − Σ mb of holders granted within the ramp window ≥ step_mem_mb + reserve_mb. Swap never counts as
|
|
13
|
+
* free memory. With no holder, one grant is allowed while MemAvailable ≥ reserve_mb, so a step is never starved by a
|
|
14
|
+
* host whose memory is used by something else. Fail-open: an unreadable registry or a lock timeout grants.
|
|
15
|
+
*
|
|
16
|
+
* Builder-run commands are gated by per-run PATH shims (`writeHeavyShims`) that call this file as a CLI:
|
|
17
|
+
* `heavy.mjs run --name <n> -- <abs-real-bin> args...` (nested calls pass through via FORGE_HEAVY_HELD).
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, appendFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
21
|
+
import { cpus, freemem } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
|
+
import { writeAtomic } from "./batch.mjs";
|
|
25
|
+
import { schedDir, pidAlive, DEFAULT_PRIORITY } from "./sched.mjs";
|
|
26
|
+
import { which } from "./toolchain.mjs";
|
|
27
|
+
import { hostParallelism } from "./admission.mjs";
|
|
28
|
+
|
|
29
|
+
export const HEAVY_DEFAULTS = Object.freeze({ enabled: true, max: null, step_mem_mb: 2048, reserve_mb: 1024, ramp_s: 60, max_wait_s: 1800 });
|
|
30
|
+
export const HEAVY_POLL_MS = 2000;
|
|
31
|
+
const LOCK_WAIT_MS = 2000, EMPTY_LOCK_STALE_MS = 5000;
|
|
32
|
+
|
|
33
|
+
const PMS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
34
|
+
/** Package-manager subcommands that only read metadata (everything else installs or runs scripts/tools). */
|
|
35
|
+
const PM_LIGHT = new Set(["version", "help", "view", "info", "show", "ls", "list", "why", "outdated", "config", "get", "set", "whoami", "root", "bin", "prefix", "store", "cache", "pack", "publish", "login", "logout", "search", "owner", "dist-tag"]);
|
|
36
|
+
const PM_VALUE_FLAGS = new Set(["-w", "-C", "-F", "--filter", "--dir", "--cwd", "--prefix", "--workspace"]);
|
|
37
|
+
const RUNNERS = new Set(["npx", "bunx", "pnpx"]);
|
|
38
|
+
const TOOLS = new Set(["tsc", "eslint", "jest", "vitest", "playwright", "pytest", "mypy", "pyright", "tox", "gradle", "gradlew", "mvn"]);
|
|
39
|
+
const SUBTOOLS = {
|
|
40
|
+
cargo: new Set(["build", "b", "test", "t", "check", "c", "clippy", "run", "r", "bench", "doc", "install"]),
|
|
41
|
+
go: new Set(["build", "test", "vet", "run", "install", "generate"]),
|
|
42
|
+
docker: new Set(["run", "build", "compose", "buildx"]),
|
|
43
|
+
"docker-compose": new Set(["up", "run", "build", "create", "start"]),
|
|
44
|
+
};
|
|
45
|
+
/** Every command name a heavy shim is written for (only those found on the lane PATH get one). */
|
|
46
|
+
export const HEAVY_BINS = Object.freeze([...PMS, ...RUNNERS, ...TOOLS, ...Object.keys(SUBTOOLS)]);
|
|
47
|
+
const INFO = new Set(["-v", "-V", "--version", "version", "-h", "--help", "help"]);
|
|
48
|
+
|
|
49
|
+
/** 'heavy' | 'llm' for one command line (`name` is the basename, `args` its argv). Unknown commands are llm. */
|
|
50
|
+
export function classifyStep(name, args = []) {
|
|
51
|
+
const n = String(name ?? "").split("/").pop();
|
|
52
|
+
const a = (args ?? []).map(String);
|
|
53
|
+
const onlyInfo = a.length > 0 && a.every((x) => INFO.has(x));
|
|
54
|
+
if (PMS.has(n)) {
|
|
55
|
+
// A pm flag-with-value prefix (`pnpm --filter x test`, `npm -w a run`) is skipped to find the subcommand.
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < a.length && a[i].startsWith("-") && !INFO.has(a[i])) i += PM_VALUE_FLAGS.has(a[i]) ? 2 : 1;
|
|
58
|
+
const sub = a[i];
|
|
59
|
+
if (sub === undefined) return n === "yarn" && a.length === 0 ? "heavy" : "llm"; // bare `yarn` installs
|
|
60
|
+
return PM_LIGHT.has(sub) || INFO.has(sub) ? "llm" : "heavy";
|
|
61
|
+
}
|
|
62
|
+
if (RUNNERS.has(n) || TOOLS.has(n)) return a.length === 0 && RUNNERS.has(n) ? "llm" : onlyInfo ? "llm" : "heavy";
|
|
63
|
+
if (SUBTOOLS[n]) { const sub = a.find((x) => !x.startsWith("-")); return sub && SUBTOOLS[n].has(sub) ? "heavy" : "llm"; }
|
|
64
|
+
return "llm";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Merged heavy config: defaults ← cfg (forge.yaml engine.heavy_*) ← env (FORGE_HEAVY_MAX|STEP_MEM_MB|RESERVE_MB). */
|
|
68
|
+
export function heavyConfig(cfg = {}, env = {}) {
|
|
69
|
+
const pos = (v) => { const x = Number(v); return v !== undefined && v !== null && String(v).trim() !== "" && Number.isFinite(x) && x > 0 ? x : null; };
|
|
70
|
+
const c = { ...HEAVY_DEFAULTS };
|
|
71
|
+
for (const k of ["max", "step_mem_mb", "reserve_mb", "ramp_s", "max_wait_s"]) { const v = pos(cfg?.[k]); if (v !== null) c[k] = k === "max" ? Math.floor(v) : v; }
|
|
72
|
+
if (cfg?.enabled === false) c.enabled = false;
|
|
73
|
+
const e = { max: env.FORGE_HEAVY_MAX, step_mem_mb: env.FORGE_HEAVY_STEP_MEM_MB, reserve_mb: env.FORGE_HEAVY_RESERVE_MB };
|
|
74
|
+
for (const [k, v] of Object.entries(e)) { const x = pos(v); if (x !== null) c[k] = k === "max" ? Math.floor(x) : x; }
|
|
75
|
+
return c;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** MemAvailable (MB) from /proc/meminfo, else os.freemem; never swap. */
|
|
79
|
+
export function memAvailableMb() {
|
|
80
|
+
try { const m = /^MemAvailable:\s+(\d+)\s+kB/m.exec(readFileSync("/proc/meminfo", "utf8")); if (m) return Math.floor(Number(m[1]) / 1024); } catch { /* not Linux */ }
|
|
81
|
+
return Math.floor(freemem() / 1048576);
|
|
82
|
+
}
|
|
83
|
+
const defaultHost = () => ({ memAvailableMb, cores: () => cpus().length || 1 });
|
|
84
|
+
|
|
85
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
86
|
+
const byRank = ([ka, a], [kb, b]) => (a.priority - b.priority) || (a.seq - b.seq) || (ka < kb ? -1 : ka > kb ? 1 : 0);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handle on the heavy semaphore. `host` gives {memAvailableMb, cores}; `alive(pid)` the liveness probe (tests inject).
|
|
90
|
+
* @returns {{dir, file, max, tryAcquire, withdraw, release, stats}}
|
|
91
|
+
*/
|
|
92
|
+
export function openHeavy(dir = schedDir(), { cfg = {}, env = {}, host = defaultHost(), now = () => Date.now(), pid = process.pid, alive = pidAlive, lockWaitMs = LOCK_WAIT_MS } = {}) {
|
|
93
|
+
const c = heavyConfig(cfg, env);
|
|
94
|
+
const file = join(dir, "heavy.json"), lockFile = join(dir, "heavy.lock");
|
|
95
|
+
const num = (f) => { try { const v = f?.(); return Number.isFinite(v) ? v : null; } catch { return null; } };
|
|
96
|
+
const max = () => c.max ?? Math.max(1, (num(host.cores) ?? 1) - 1);
|
|
97
|
+
|
|
98
|
+
const lock = () => {
|
|
99
|
+
mkdirSync(dir, { recursive: true });
|
|
100
|
+
const end = Date.now() + lockWaitMs;
|
|
101
|
+
for (;;) {
|
|
102
|
+
try { const fd = openSync(lockFile, "wx"); try { writeSync(fd, String(pid)); } finally { closeSync(fd); } return; } catch (e) { if (e?.code !== "EEXIST") throw e; }
|
|
103
|
+
let owner = NaN;
|
|
104
|
+
try { owner = parseInt(readFileSync(lockFile, "utf8"), 10); } catch { /* released meanwhile */ }
|
|
105
|
+
let empty = false;
|
|
106
|
+
if (Number.isNaN(owner)) { try { empty = Date.now() - statSync(lockFile).mtimeMs < EMPTY_LOCK_STALE_MS; } catch { continue; } }
|
|
107
|
+
if (!empty && (Number.isNaN(owner) || !alive(owner))) {
|
|
108
|
+
const aside = `${lockFile}.stale-${pid}`;
|
|
109
|
+
try {
|
|
110
|
+
renameSync(lockFile, aside);
|
|
111
|
+
let got = NaN;
|
|
112
|
+
try { got = parseInt(readFileSync(aside, "utf8"), 10); } catch { /* gone */ }
|
|
113
|
+
if (!Number.isNaN(got) && got !== owner && alive(got)) { try { renameSync(aside, lockFile); } catch { /* best effort */ } }
|
|
114
|
+
else rmSync(aside, { force: true });
|
|
115
|
+
} catch { /* someone else took it over */ }
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (Date.now() >= end) throw new Error(`heavy registry lock held by pid ${Number.isNaN(owner) ? "?" : owner}`);
|
|
119
|
+
pause(10);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const unlock = () => { try { rmSync(lockFile, { force: true }); } catch { /* best effort */ } };
|
|
123
|
+
const read = () => {
|
|
124
|
+
let o;
|
|
125
|
+
try { o = JSON.parse(readFileSync(file, "utf8")); } catch (e) {
|
|
126
|
+
if (e?.code !== "ENOENT") throw new Error(`heavy registry ${file} unreadable: ${e?.message ?? e}`);
|
|
127
|
+
o = {};
|
|
128
|
+
}
|
|
129
|
+
const obj = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : {});
|
|
130
|
+
return { v: 1, seq: Number.isInteger(o?.seq) ? o.seq : 0, maxQueued: Number.isInteger(o?.maxQueued) ? o.maxQueued : 0, holders: obj(o?.holders), waiters: obj(o?.waiters) };
|
|
131
|
+
};
|
|
132
|
+
const mutate = (fn) => {
|
|
133
|
+
lock();
|
|
134
|
+
try {
|
|
135
|
+
const s = read();
|
|
136
|
+
for (const [k, e] of Object.entries(s.holders)) if (!alive(e.pid)) delete s.holders[k];
|
|
137
|
+
for (const [k, e] of Object.entries(s.waiters)) if (!alive(e.pid)) delete s.waiters[k];
|
|
138
|
+
const r = fn(s);
|
|
139
|
+
writeAtomic(file, JSON.stringify(s, null, 2) + "\n");
|
|
140
|
+
return r;
|
|
141
|
+
} finally { unlock(); }
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
dir, file, max, config: c,
|
|
146
|
+
/**
|
|
147
|
+
* Grant `key` a slot or queue it. entry: {issue, phase, priority, mb}.
|
|
148
|
+
* @returns {{ok:boolean, fallback?:true, reason?:string, holders?:number}}
|
|
149
|
+
*/
|
|
150
|
+
tryAcquire: (key, entry = {}) => {
|
|
151
|
+
try {
|
|
152
|
+
return mutate((s) => {
|
|
153
|
+
if (s.holders[key]) return { ok: true, holders: Object.keys(s.holders).length, queued: Object.keys(s.waiters).length };
|
|
154
|
+
const priority = Number.isFinite(Number(entry.priority)) ? Number(entry.priority) : DEFAULT_PRIORITY;
|
|
155
|
+
if (!s.waiters[key]) s.waiters[key] = { pid, issue: entry.issue ?? null, phase: entry.phase ?? null, priority, seq: ++s.seq, at: now() };
|
|
156
|
+
const queued = Object.keys(s.waiters).length;
|
|
157
|
+
s.maxQueued = Math.max(s.maxQueued, queued);
|
|
158
|
+
const held = Object.values(s.holders);
|
|
159
|
+
const order = Object.entries(s.waiters).sort(byRank);
|
|
160
|
+
if (order[0][0] !== key) return { ok: false, queued, reason: `queued behind ${order.findIndex(([k]) => k === key)} heavy step(s)` };
|
|
161
|
+
if (held.length >= max()) return { ok: false, queued, reason: `heavy cap ${max()} reached` };
|
|
162
|
+
const mem = num(host.memAvailableMb);
|
|
163
|
+
if (mem !== null) {
|
|
164
|
+
if (held.length === 0 ? mem < c.reserve_mb : (() => {
|
|
165
|
+
const ramping = held.filter((h) => now() - (h.at ?? 0) < c.ramp_s * 1000).reduce((t, h) => t + (Number(h.mb) || c.step_mem_mb), 0);
|
|
166
|
+
return mem - ramping < c.step_mem_mb + c.reserve_mb;
|
|
167
|
+
})()) return { ok: false, queued, reason: `free memory ${mem} MB below the heavy-step reservation` };
|
|
168
|
+
}
|
|
169
|
+
const w = s.waiters[key];
|
|
170
|
+
delete s.waiters[key];
|
|
171
|
+
s.holders[key] = { pid, issue: w.issue, phase: w.phase, priority: w.priority, mb: Number(entry.mb) > 0 ? Number(entry.mb) : c.step_mem_mb, at: now() };
|
|
172
|
+
return { ok: true, holders: held.length + 1, queued };
|
|
173
|
+
});
|
|
174
|
+
} catch (e) { return { ok: true, fallback: true, reason: e?.message ?? String(e) }; }
|
|
175
|
+
},
|
|
176
|
+
withdraw: (key) => { try { mutate((s) => { delete s.waiters[key]; }); } catch { /* fail-open */ } },
|
|
177
|
+
release: (key) => { try { mutate((s) => { delete s.holders[key]; delete s.waiters[key]; }); } catch { /* fail-open */ } },
|
|
178
|
+
/** {holders, queued, maxQueued, max} of live entries, read-only (no lock, no write); null when unreadable. */
|
|
179
|
+
stats: () => {
|
|
180
|
+
try {
|
|
181
|
+
const s = read(), live = (o) => Object.values(o).filter((e) => alive(e.pid)).length;
|
|
182
|
+
return { holders: live(s.holders), queued: live(s.waiters), maxQueued: s.maxQueued, max: max() };
|
|
183
|
+
} catch { return null; }
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let keySeq = 0;
|
|
189
|
+
/**
|
|
190
|
+
* Wait for a slot (polling), giving up after `max_wait_s` (the step then runs ungated: never stall a lane forever).
|
|
191
|
+
* `queued` is the longest heavy queue (this step included) seen while waiting.
|
|
192
|
+
* @returns {Promise<{key:string, waitMs:number, holders:number, queued:number, fallback?:true, timedOut?:true, release:Function}>}
|
|
193
|
+
*/
|
|
194
|
+
export async function acquireHeavy(h, entry = {}, { pollMs = HEAVY_POLL_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), pid = process.pid } = {}) {
|
|
195
|
+
const key = `${pid}:${++keySeq}:${entry.phase ?? "step"}`;
|
|
196
|
+
const t0 = now(), limit = (h.config?.max_wait_s ?? HEAVY_DEFAULTS.max_wait_s) * 1000;
|
|
197
|
+
let queued = 0;
|
|
198
|
+
const done = (extra) => ({ key, waitMs: now() - t0, holders: 1, queued, ...extra, release: () => h.release(key) });
|
|
199
|
+
for (;;) {
|
|
200
|
+
const r = h.tryAcquire(key, entry);
|
|
201
|
+
if (Number.isInteger(r.queued)) queued = Math.max(queued, r.queued);
|
|
202
|
+
if (r.ok) return done({ holders: r.holders ?? 1, ...(r.fallback ? { fallback: true } : {}) });
|
|
203
|
+
if (now() - t0 >= limit) { h.withdraw(key); return done({ timedOut: true, release: () => {} }); }
|
|
204
|
+
await sleep(pollMs);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Default lane effect: `acquire(entry, cfg)` → {waitMs, queued, release()} (cfg = the lane's forge.yaml heavy keys); `stats()`. */
|
|
209
|
+
export function makeHeavyIo({ dir = schedDir(), env = process.env, host } = {}) {
|
|
210
|
+
const open = (cfg) => openHeavy(dir, { cfg, env, ...(host ? { host } : {}) });
|
|
211
|
+
return { acquire: (entry, cfg = {}) => acquireHeavy(open(cfg), entry), stats: () => open({}).stats() };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const SHIM_HEADER = "#!/bin/sh\n# forgedock heavy-step shim (#4169): waits for a host heavy-step slot, then runs the real command; do not edit.\n";
|
|
215
|
+
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
216
|
+
|
|
217
|
+
/** Shim script for one command: exec node heavy.mjs run --name <n> -- <real> "$@". */
|
|
218
|
+
export function heavyShimScript(name, real, { node = process.execPath, script = fileURLToPath(import.meta.url) } = {}) {
|
|
219
|
+
return `${SHIM_HEADER}exec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Write 0755 shims into `binDir` for every HEAVY_BINS name found on `path` (binDir excluded, real path resolved now, so
|
|
224
|
+
* a shim never calls itself). Returns the names written.
|
|
225
|
+
*/
|
|
226
|
+
export async function writeHeavyShims(binDir, { path = process.env.PATH ?? "", find = which, node, script, mkdirp = (d) => mkdirSync(d, { recursive: true }), writeShim = (p, t) => { writeFileSync(p, t); chmodSync(p, 0o755); } } = {}) {
|
|
227
|
+
const found = HEAVY_BINS.map((n) => [n, find(n, path, [binDir])]).filter(([, r]) => r);
|
|
228
|
+
if (!found.length) return [];
|
|
229
|
+
await mkdirp(binDir);
|
|
230
|
+
for (const [n, real] of found) await writeShim(join(binDir, n), heavyShimScript(n, real, { ...(node ? { node } : {}), ...(script ? { script } : {}) }));
|
|
231
|
+
return found.map(([n]) => n);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Parse heavy-wait jsonl lines into {waits: {phase: ms}, queueMax} (bad lines ignored). */
|
|
235
|
+
export function sumWaits(text, waits = {}) {
|
|
236
|
+
let queueMax = 0;
|
|
237
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
238
|
+
if (!line.trim()) continue;
|
|
239
|
+
let j; try { j = JSON.parse(line); } catch { continue; }
|
|
240
|
+
const ms = Number(j?.waitMs);
|
|
241
|
+
if (Number.isFinite(ms) && ms >= 0) { const p = String(j.phase || "step"); waits[p] = (waits[p] ?? 0) + ms; }
|
|
242
|
+
if (Number.isInteger(j?.queued)) queueMax = Math.max(queueMax, j.queued);
|
|
243
|
+
}
|
|
244
|
+
return { waits, queueMax };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* CLI body: `run --name <n> -- <real> args...`. Pass-through when FORGE_HEAVY_HELD is set or the step is llm; else
|
|
249
|
+
* acquire, run with FORGE_HEAVY_HELD=1 and FORGE_HOST_PARALLELISM from live holders, release, propagate the exit.
|
|
250
|
+
*/
|
|
251
|
+
export async function runWrapped(argv, { env = process.env, open = openHeavy, run = spawnInherit, host } = {}) {
|
|
252
|
+
const sep = argv.indexOf("--");
|
|
253
|
+
if (argv[0] !== "run" || sep < 0 || !argv[sep + 1]) throw new Error("usage: heavy.mjs run --name <name> -- <command> [args...]");
|
|
254
|
+
const ni = argv.indexOf("--name");
|
|
255
|
+
const name = ni >= 0 && ni < sep ? argv[ni + 1] : argv[sep + 1];
|
|
256
|
+
const [cmd, ...args] = argv.slice(sep + 1);
|
|
257
|
+
const c = heavyConfig({}, env);
|
|
258
|
+
if (env.FORGE_HEAVY_HELD || env.FORGE_HEAVY_DISABLED === "1" || classifyStep(name, args) === "llm") return run(cmd, args, env);
|
|
259
|
+
const hostP = host ?? defaultHost();
|
|
260
|
+
const h = open(env.FORGEDOCK_SCHED_DIR || schedDir(env), { env, host: hostP });
|
|
261
|
+
const priority = Number.isFinite(Number(env.FORGE_LANE_PRIORITY)) && String(env.FORGE_LANE_PRIORITY).trim() !== "" ? Number(env.FORGE_LANE_PRIORITY) : DEFAULT_PRIORITY;
|
|
262
|
+
const phase = env.FORGE_LANE_PHASE || "step";
|
|
263
|
+
const g = await acquireHeavy(h, { issue: Number(env.FORGE_LANE_ISSUE) || null, phase, priority, mb: c.step_mem_mb });
|
|
264
|
+
if (env.FORGE_HEAVY_WAITS) { try { appendFileSync(env.FORGE_HEAVY_WAITS, JSON.stringify({ phase, name, waitMs: g.waitMs, queued: g.queued }) + "\n"); } catch { /* metrics are best effort */ } }
|
|
265
|
+
let cores = 1; try { cores = hostP.cores() || 1; } catch { /* 1 */ }
|
|
266
|
+
const childEnv = { ...env, FORGE_HEAVY_HELD: "1",
|
|
267
|
+
...(env.FORGE_HEAVY_PARALLELISM_PINNED === "1" ? {} : { FORGE_HOST_PARALLELISM: String(hostParallelism({ cores, lanes: g.holders })) }) };
|
|
268
|
+
try { return await run(cmd, args, childEnv); } finally { g.release(); }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Spawn with inherited stdio; resolves {code, signal}; forwards termination signals to the child. */
|
|
272
|
+
export function spawnInherit(cmd, args, env) {
|
|
273
|
+
return new Promise((done) => {
|
|
274
|
+
const child = spawn(cmd, args, { stdio: "inherit", env });
|
|
275
|
+
const fwd = (sig) => { try { child.kill(sig); } catch { /* gone */ } };
|
|
276
|
+
const sigs = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
277
|
+
for (const s of sigs) process.on(s, fwd);
|
|
278
|
+
const off = () => { for (const s of sigs) process.off(s, fwd); };
|
|
279
|
+
child.on("error", (e) => { off(); process.stderr.write(`${cmd}: ${e.message}\n`); done({ code: e.code === "ENOENT" ? 127 : 126, signal: null }); });
|
|
280
|
+
child.on("exit", (code, signal) => { off(); done({ code, signal }); });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
285
|
+
runWrapped(process.argv.slice(2)).then(({ code, signal }) => {
|
|
286
|
+
if (signal) { process.kill(process.pid, signal); return; }
|
|
287
|
+
process.exit(code ?? 1);
|
|
288
|
+
}, (e) => { process.stderr.write(`forgedock heavy: ${e?.message ?? e}\n`); process.exit(2); });
|
|
289
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS } from "./heavy.mjs";
|
|
9
|
+
import { checkAdmission, ADMISSION_DEFAULTS } from "./admission.mjs";
|
|
10
|
+
import { formatWaitSummary } from "./orchestrate.mjs";
|
|
11
|
+
import { readHeavy } from "../engine-cli.mjs";
|
|
12
|
+
|
|
13
|
+
// Injected pids, clock and host: `live` is the set of pids the registry sees as alive; `mem` the MemAvailable in MB.
|
|
14
|
+
const world = ({ mem = 12000, cores = 4, swapFree = 0 } = {}) => {
|
|
15
|
+
const live = new Set(), clock = { t: 1_000_000 }, h = { mem, cores, swapFree };
|
|
16
|
+
const dir = tmpDir("heavy-");
|
|
17
|
+
const host = { memAvailableMb: () => h.mem, cores: () => h.cores, swapFreeMb: () => h.swapFree };
|
|
18
|
+
const handle = (pid, cfg = {}) => { live.add(pid); return openHeavy(dir, { pid, alive: (p) => live.has(p), now: () => clock.t, host, cfg }); };
|
|
19
|
+
return { dir, live, clock, h, handle };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("step classes (#4169)", () => {
|
|
23
|
+
it("installs, checkers, test runners and containers are heavy across ecosystems; metadata calls and the rest are llm", () => {
|
|
24
|
+
const heavy = [["pnpm", ["install"]], ["npm", ["ci"]], ["yarn", []], ["pnpm", ["--filter", "web", "test"]], ["npm", ["run", "typecheck"]], ["bun", ["test"]],
|
|
25
|
+
["npx", ["tsc", "--noEmit"]], ["tsc", ["-p", "."]], ["eslint", ["."]], ["vitest", ["run"]], ["jest", []], ["pytest", ["-q"]], ["mypy", ["src"]],
|
|
26
|
+
["cargo", ["test"]], ["cargo", ["--locked", "clippy"]], ["go", ["test", "./..."]], ["docker", ["run", "--rm", "pg"]], ["docker", ["compose", "up"]],
|
|
27
|
+
["docker-compose", ["up"]], ["mvn", ["verify"]], ["gradle", ["build"]], ["/usr/bin/tsc", ["-b"]]];
|
|
28
|
+
for (const [n, a] of heavy) assert.equal(classifyStep(n, a), "heavy", `${n} ${a.join(" ")}`);
|
|
29
|
+
const llm = [["pnpm", ["--version"]], ["npm", ["ls"]], ["pnpm", []], ["npm", ["view", "x"]], ["tsc", ["--version"]], ["eslint", ["-v"]], ["npx", []],
|
|
30
|
+
["cargo", ["--version"]], ["go", ["env"]], ["docker", ["ps"]], ["docker", ["logs", "x"]], ["git", ["status"]], ["node", ["x.js"]], ["claude", ["--print"]]];
|
|
31
|
+
for (const [n, a] of llm) assert.equal(classifyStep(n, a), "llm", `${n} ${a.join(" ")}`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("load average is no longer a default admission signal; an explicit max_load_per_core still is", async () => {
|
|
35
|
+
assert.equal(ADMISSION_DEFAULTS.max_load_per_core, null);
|
|
36
|
+
const host = { memAvailableMb: () => 8000, freeMb: async () => 8000, tmpPaths: () => ["/tmp"], loadAvg1: () => 17, cores: () => 4 };
|
|
37
|
+
assert.equal((await checkAdmission({ host })).ok, true, "load 4.25/core does not refuse a lane by default");
|
|
38
|
+
const r = await checkAdmission({ cfg: { max_load_per_core: 4 }, host });
|
|
39
|
+
assert.equal(r.ok, false); assert.match(r.detail, /load 4\.3\/core > 4/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("config: defaults, forge.yaml keys, env overrides; readHeavy parses engine.heavy_*", () => {
|
|
43
|
+
assert.deepEqual(heavyConfig(), { enabled: true, max: null, step_mem_mb: 2048, reserve_mb: 1024, ramp_s: 60, max_wait_s: 1800 });
|
|
44
|
+
const c = heavyConfig({ max: 2, step_mem_mb: 3000, enabled: false }, { FORGE_HEAVY_MAX: "5", FORGE_HEAVY_RESERVE_MB: "bad" });
|
|
45
|
+
assert.equal(c.max, 5); assert.equal(c.step_mem_mb, 3000); assert.equal(c.reserve_mb, 1024); assert.equal(c.enabled, false);
|
|
46
|
+
const d = tmpDir("heavy-yaml-");
|
|
47
|
+
writeFileSync(join(d, "forge.yaml"), "engine:\n heavy_max: 2\n heavy_step_mem_mb: 2500\n heavy_enabled: false\n");
|
|
48
|
+
assert.deepEqual(readHeavy(d), { enabled: false, max: 2, step_mem_mb: 2500 });
|
|
49
|
+
assert.deepEqual(readHeavy(tmpDir("heavy-none-")), {});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("heavy-step semaphore (#4169)", () => {
|
|
54
|
+
it("caps live holders at max(1, cores-1) and frees a slot on release or holder death", () => {
|
|
55
|
+
const { handle, live } = world({ mem: 64000, cores: 4 });
|
|
56
|
+
const hs = [1, 2, 3, 4].map((p) => handle(100 + p));
|
|
57
|
+
assert.deepEqual(hs.map((h, i) => h.tryAcquire(`k${i}`, { issue: i }).ok), [true, true, true, false]);
|
|
58
|
+
assert.match(hs[3].tryAcquire("k3").reason, /heavy cap 3 reached/);
|
|
59
|
+
hs[0].release("k0");
|
|
60
|
+
assert.equal(hs[3].tryAcquire("k3").ok, true);
|
|
61
|
+
live.delete(102); // a holder whose process died is reaped
|
|
62
|
+
const h5 = handle(105);
|
|
63
|
+
assert.equal(h5.tryAcquire("k5").ok, true);
|
|
64
|
+
assert.equal(handle(106, { max: 1 }).max(), 1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("adapts to free memory: ramping steps count at their reservation, then live MemAvailable decides; swap never counts", () => {
|
|
68
|
+
const w = world({ mem: 7000, cores: 16, swapFree: 32000 });
|
|
69
|
+
const a = w.handle(1), b = w.handle(2), c = w.handle(3);
|
|
70
|
+
assert.equal(a.tryAcquire("a").ok, true);
|
|
71
|
+
assert.equal(b.tryAcquire("b").ok, true, "7000 − 2048 = 4952 ≥ 3072");
|
|
72
|
+
const r = c.tryAcquire("c");
|
|
73
|
+
assert.equal(r.ok, false, "7000 − 4096 = 2904 < 3072; 32 GB of free swap does not help");
|
|
74
|
+
assert.match(r.reason, /free memory 7000 MB/);
|
|
75
|
+
w.clock.t += 61_000; // a and b are past the ramp window: their real use is in MemAvailable now
|
|
76
|
+
w.h.mem = 2500;
|
|
77
|
+
assert.equal(c.tryAcquire("c").ok, false, "2500 < 3072 with holders: never push the host into swap");
|
|
78
|
+
w.h.mem = 3100;
|
|
79
|
+
assert.equal(c.tryAcquire("c").ok, true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("with no holder one step may run while MemAvailable ≥ reserve, so a step is not starved; below the reserve it waits", () => {
|
|
83
|
+
const w = world({ mem: 1500, cores: 4 });
|
|
84
|
+
assert.equal(w.handle(1).tryAcquire("a").ok, true);
|
|
85
|
+
const w2 = world({ mem: 900, cores: 4 });
|
|
86
|
+
assert.equal(w2.handle(1).tryAcquire("a").ok, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("queues by priority then FIFO: a P0 offered last is granted before queued P2s", () => {
|
|
90
|
+
const { handle } = world({ mem: 64000, cores: 2 }); // max 1
|
|
91
|
+
const hold = handle(1), p2a = handle(2), p2b = handle(3), p0 = handle(4);
|
|
92
|
+
assert.equal(hold.tryAcquire("hold").ok, true);
|
|
93
|
+
assert.equal(p2a.tryAcquire("p2a", { priority: 2 }).ok, false);
|
|
94
|
+
assert.equal(p2b.tryAcquire("p2b", { priority: 2 }).ok, false);
|
|
95
|
+
assert.equal(p0.tryAcquire("p0", { priority: 0 }).ok, false);
|
|
96
|
+
hold.release("hold");
|
|
97
|
+
assert.match(p2a.tryAcquire("p2a", { priority: 2 }).reason, /queued behind 1/);
|
|
98
|
+
assert.equal(p0.tryAcquire("p0", { priority: 0 }).ok, true);
|
|
99
|
+
p0.release("p0");
|
|
100
|
+
assert.equal(p2b.tryAcquire("p2b", { priority: 2 }).ok, false, "FIFO within P2: p2a first");
|
|
101
|
+
assert.equal(p2a.tryAcquire("p2a", { priority: 2 }).ok, true);
|
|
102
|
+
const st = hold.stats();
|
|
103
|
+
assert.equal(st.holders, 1); assert.equal(st.queued, 1); assert.equal(st.maxQueued, 3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("fails open on a corrupt registry; stats is read-only and null when unreadable", () => {
|
|
107
|
+
const w = world();
|
|
108
|
+
mkdirSync(w.dir, { recursive: true });
|
|
109
|
+
writeFileSync(join(w.dir, "heavy.json"), "{not json");
|
|
110
|
+
const r = w.handle(1).tryAcquire("a");
|
|
111
|
+
assert.equal(r.ok, true); assert.equal(r.fallback, true);
|
|
112
|
+
assert.equal(w.handle(1).stats(), null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("acquireHeavy polls until granted, reports the wait and the longest queue seen; gives up after max_wait_s (ungated)", async () => {
|
|
116
|
+
const w = world({ mem: 64000, cores: 2 });
|
|
117
|
+
const holder = w.handle(1);
|
|
118
|
+
holder.tryAcquire("hold");
|
|
119
|
+
const h = w.handle(2);
|
|
120
|
+
let polls = 0;
|
|
121
|
+
const g = await acquireHeavy(h, { phase: "precheck" }, { pid: 2, now: () => w.clock.t, sleep: async (ms) => { w.clock.t += ms; if (++polls === 3) holder.release("hold"); } });
|
|
122
|
+
assert.equal(g.waitMs, 6000); assert.equal(g.queued, 1); assert.equal(h.stats().holders, 1);
|
|
123
|
+
g.release();
|
|
124
|
+
assert.equal(h.stats().holders, 0);
|
|
125
|
+
holder.tryAcquire("hold2");
|
|
126
|
+
const h2 = w.handle(3, { max_wait_s: 10 });
|
|
127
|
+
const t = await acquireHeavy(h2, {}, { pid: 3, now: () => w.clock.t, sleep: async (ms) => { w.clock.t += ms; } });
|
|
128
|
+
assert.equal(t.timedOut, true); assert.equal(h2.stats().queued, 0, "a timed-out waiter is withdrawn");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("R4 simulation: 20 lanes on a 4-core/16 GB host at load 17 are all admitted, none deferred on load; ≤3 heavy steps at once", async () => {
|
|
132
|
+
const w = world({ mem: 16384, cores: 4 });
|
|
133
|
+
const lanes = 20, laneRss = 265, tscRss = 2048;
|
|
134
|
+
const admitHost = { memAvailableMb: () => w.h.mem, freeMb: async () => 50000, tmpPaths: () => ["/tmp"], loadAvg1: () => 17, cores: () => 4 };
|
|
135
|
+
w.h.mem = 16384 - lanes * laneRss; // every lane's agent is resident: 11084 MB left
|
|
136
|
+
const admitted = await Promise.all(Array.from({ length: lanes }, () => checkAdmission({ host: admitHost })));
|
|
137
|
+
assert.equal(admitted.filter((a) => a.ok).length, lanes, "all 20 lanes admitted into their LLM phases");
|
|
138
|
+
// Each lane runs one heavy step; a granted step consumes its tsc RSS once past the ramp window.
|
|
139
|
+
const hs = Array.from({ length: lanes }, (_, i) => w.handle(1000 + i));
|
|
140
|
+
const holding = new Set();
|
|
141
|
+
let peak = 0, done = 0;
|
|
142
|
+
for (let tick = 0; done < lanes && tick < 1000; tick++) {
|
|
143
|
+
for (let i = 0; i < lanes; i++) {
|
|
144
|
+
if (holding.has(i) || hs[i].done) continue;
|
|
145
|
+
if (hs[i].tryAcquire(`s${i}`, { issue: i }).ok) holding.add(i);
|
|
146
|
+
}
|
|
147
|
+
peak = Math.max(peak, holding.size);
|
|
148
|
+
w.clock.t += 61_000;
|
|
149
|
+
w.h.mem = 16384 - lanes * laneRss - holding.size * tscRss;
|
|
150
|
+
for (const i of [...holding].slice(0, 1)) { hs[i].release(`s${i}`); hs[i].done = true; holding.delete(i); done++; }
|
|
151
|
+
w.h.mem = 16384 - lanes * laneRss - holding.size * tscRss;
|
|
152
|
+
}
|
|
153
|
+
assert.equal(done, lanes, "every heavy step eventually ran");
|
|
154
|
+
assert.ok(peak <= 3 && peak >= 2, `2-3 heavy steps at once on 4 cores (peak ${peak})`);
|
|
155
|
+
// Less free memory shrinks heavy concurrency to what fits.
|
|
156
|
+
const w2 = world({ mem: 5000, cores: 4 });
|
|
157
|
+
const g = [1, 2, 3].map((p) => w2.handle(p).tryAcquire(`k${p}`).ok);
|
|
158
|
+
assert.deepEqual(g, [true, false, false], "5000 MB fits one 2 GB step plus the 1 GB reserve, not two");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("heavy shims and the run wrapper (#4169)", () => {
|
|
163
|
+
it("shims are written only for heavy names on PATH and never resolve to themselves", async () => {
|
|
164
|
+
const d = tmpDir("heavy-shim-"), real = join(d, "real"), bin = join(d, "bin");
|
|
165
|
+
mkdirSync(real, { recursive: true });
|
|
166
|
+
for (const n of ["tsc", "pnpm", "git"]) { writeFileSync(join(real, n), "#!/bin/sh\n"); chmodSync(join(real, n), 0o755); }
|
|
167
|
+
const names = await writeHeavyShims(bin, { path: `${bin}:${real}` });
|
|
168
|
+
assert.deepEqual(names.sort(), ["pnpm", "tsc"]);
|
|
169
|
+
assert.ok(!existsSync(join(bin, "git")));
|
|
170
|
+
const s = readFileSync(join(bin, "tsc"), "utf8");
|
|
171
|
+
assert.match(s, new RegExp(`-- '${real}/tsc' "\\$@"`));
|
|
172
|
+
assert.ok(!s.includes(`'${bin}/tsc'`));
|
|
173
|
+
assert.deepEqual(await writeHeavyShims(join(d, "none"), { path: join(d, "empty") }), []);
|
|
174
|
+
assert.ok(HEAVY_BINS.includes("docker") && HEAVY_BINS.includes("pytest"));
|
|
175
|
+
assert.match(heavyShimScript("x", "/r/x", { node: "/n", script: "/s" }), /^#!\/bin\/sh\n[\s\S]*exec '\/n' '\/s' run --name 'x' -- '\/r\/x' "\$@"\n$/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("run: heavy steps hold a slot for their duration, get FORGE_HEAVY_HELD and per-holder parallelism, record the wait; llm and nested pass through", async () => {
|
|
179
|
+
const w = world({ mem: 64000, cores: 8 });
|
|
180
|
+
const waitsFile = join(w.dir, "waits.jsonl");
|
|
181
|
+
const calls = [];
|
|
182
|
+
const env = { FORGEDOCK_SCHED_DIR: w.dir, FORGE_HEAVY_WAITS: waitsFile, FORGE_LANE_PHASE: "implement", FORGE_LANE_PRIORITY: "1", FORGE_LANE_ISSUE: "7" };
|
|
183
|
+
const open = (dir, o) => { w.live.add(process.pid); return openHeavy(dir, { ...o, alive: (p) => w.live.has(p) }); };
|
|
184
|
+
const host = { memAvailableMb: () => 64000, cores: () => 8 };
|
|
185
|
+
const run = async (cmd, args, e) => { calls.push({ cmd, args, held: e.FORGE_HEAVY_HELD, par: e.FORGE_HOST_PARALLELISM, holders: openHeavy(w.dir, { host }).stats().holders }); return { code: 3, signal: null }; };
|
|
186
|
+
assert.deepEqual(await runWrapped(["run", "--name", "tsc", "--", "/r/tsc", "--noEmit"], { env, open, run, host }), { code: 3, signal: null });
|
|
187
|
+
assert.deepEqual(calls[0], { cmd: "/r/tsc", args: ["--noEmit"], held: "1", par: "8", holders: 1 });
|
|
188
|
+
assert.equal(openHeavy(w.dir, { host }).stats().holders, 0, "released after the step");
|
|
189
|
+
assert.deepEqual(sumWaits(readFileSync(waitsFile, "utf8")), { waits: { implement: JSON.parse(readFileSync(waitsFile, "utf8")).waitMs }, queueMax: 1 });
|
|
190
|
+
await runWrapped(["run", "--name", "tsc", "--", "/r/tsc", "--version"], { env, open, run, host });
|
|
191
|
+
await runWrapped(["run", "--name", "pnpm", "--", "/r/pnpm", "install"], { env: { ...env, FORGE_HEAVY_HELD: "1" }, open, run, host });
|
|
192
|
+
assert.deepEqual(calls.slice(1).map((c) => [c.held, c.holders]), [[undefined, 0], ["1", 0]], "llm and nested calls take no slot");
|
|
193
|
+
await runWrapped(["run", "--name", "tsc", "--", "/r/tsc"], { env: { ...env, FORGE_HOST_PARALLELISM: "2", FORGE_HEAVY_PARALLELISM_PINNED: "1" }, open, run, host });
|
|
194
|
+
assert.equal(calls.at(-1).par, "2", "an operator-set FORGE_HOST_PARALLELISM wins");
|
|
195
|
+
await assert.rejects(runWrapped(["run", "--name", "x"], { env, open, run, host }), /usage/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("sumWaits sums per phase and ignores bad lines; formatWaitSummary renders the batch-report line", () => {
|
|
199
|
+
assert.deepEqual(sumWaits('{"phase":"implement","waitMs":1000,"queued":2}\nnot json\n{"phase":"implement","waitMs":500}\n{"phase":"fix","waitMs":60000,"queued":4}\n'),
|
|
200
|
+
{ waits: { implement: 1500, fix: 60000 }, queueMax: 4 });
|
|
201
|
+
const rows = [{ issue: 1, waits: { implement: 180000, setup: 6000 }, heavyQueueMax: 2 }, { issue: 2, waits: { implement: 12000 }, heavyQueueMax: 5 }, { issue: 3 }];
|
|
202
|
+
assert.equal(formatWaitSummary(rows, { queued: 1, maxQueued: 9 }), "waits by phase: implement 3.2m, setup 0.1m; heavy queue: now 1, max 5");
|
|
203
|
+
assert.equal(formatWaitSummary([{ issue: 3 }], null), null);
|
|
204
|
+
assert.equal(formatWaitSummary([{ issue: 3 }], { queued: 0 }), null, "no waits and an empty queue print no line");
|
|
205
|
+
assert.equal(formatWaitSummary([{ issue: 3 }], { queued: 2 }), "waits by phase: none; heavy queue: now 2, max 0");
|
|
206
|
+
});
|
|
207
|
+
});
|