forgedock 1.27.0 → 1.29.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 +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- 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 +104 -9
- 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 +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- 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/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- 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 +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- 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 +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- 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 +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -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 +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -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 +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- 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/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -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/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- 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/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -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
package/bin/engine-cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
|
|
|
9
9
|
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
12
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
13
13
|
import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
|
|
14
14
|
import { makeProjector } from "./engine/projector.mjs";
|
|
15
15
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
@@ -18,6 +18,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
|
|
|
18
18
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
19
19
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
20
|
import { parseVerify } from "./lean/verify.mjs";
|
|
21
|
+
import { parseExecution } from "./lean/execution.mjs";
|
|
21
22
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
22
23
|
import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
|
|
23
24
|
|
|
@@ -60,12 +61,13 @@ export function makeIo() {
|
|
|
60
61
|
return stdout;
|
|
61
62
|
};
|
|
62
63
|
// CI wait seam (scripts/wait-ci-green.sh, argv only, no shell). Resolves { rc, text } for any exit code.
|
|
63
|
-
|
|
64
|
+
// `ignore` (forge#4270): check names dropped before deciding (`--ignore-check`); omitted, the argv is unchanged.
|
|
65
|
+
const waitCiGreen = async (pr, repo, timeoutSec, { ignore = [] } = {}) => {
|
|
64
66
|
const script = fileURLToPath(new URL("../scripts/wait-ci-green.sh", import.meta.url));
|
|
65
67
|
try {
|
|
66
68
|
// The engine owns the backoff and the budget: each call is ONE read (no in-script polling, no settle hold;
|
|
67
69
|
// the re-dispatched phase re-runs the full gate before any merge).
|
|
68
|
-
const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec)],
|
|
70
|
+
const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec), ...ignore.flatMap((n) => ["--ignore-check", String(n)])],
|
|
69
71
|
{ maxBuffer: 10 * 1024 * 1024, timeout: (timeoutSec + 120) * 1000, env: { ...process.env, FORGE_CI_SETTLE: "0" } });
|
|
70
72
|
return { rc: 0, text: stdout };
|
|
71
73
|
} catch (e) { return { rc: Number.isInteger(e?.code) ? e.code : 2, text: String(e?.stdout ?? "") }; }
|
|
@@ -103,6 +105,67 @@ export function readConformanceMode(cwd = process.cwd()) {
|
|
|
103
105
|
return ["shadow", "enforce"].includes(v) ? v : undefined;
|
|
104
106
|
} catch { return undefined; }
|
|
105
107
|
}
|
|
108
|
+
/** forge.yaml `engine.review_incomplete` (needs-human|degraded, #4160); undefined when absent or invalid (the core defaults to needs-human). */
|
|
109
|
+
export function readReviewIncomplete(cwd = process.cwd()) {
|
|
110
|
+
try {
|
|
111
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.review_incomplete ?? "").trim();
|
|
112
|
+
return ["needs-human", "degraded"].includes(v) ? v : undefined;
|
|
113
|
+
} catch { return undefined; }
|
|
114
|
+
}
|
|
115
|
+
const warnedContextPack = new Set();
|
|
116
|
+
const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
|
|
117
|
+
/**
|
|
118
|
+
* forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
|
|
119
|
+
* (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
120
|
+
* `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
|
|
121
|
+
* (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
|
|
122
|
+
*/
|
|
123
|
+
export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
|
|
124
|
+
try {
|
|
125
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
126
|
+
let mode = String(e.context_pack ?? "").trim().toLowerCase();
|
|
127
|
+
if (mode && !["shadow", "on", "off"].includes(mode)) {
|
|
128
|
+
warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
|
|
129
|
+
mode = "";
|
|
130
|
+
}
|
|
131
|
+
const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
|
|
132
|
+
...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {}) };
|
|
133
|
+
return Object.keys(out).length ? out : undefined;
|
|
134
|
+
} catch { return undefined; }
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
|
|
138
|
+
* list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
|
|
139
|
+
* shadow with the built-in risk patterns). An invalid mode reads as unset.
|
|
140
|
+
*/
|
|
141
|
+
export function readReviewTrigger(cwd = process.cwd()) {
|
|
142
|
+
let raw;
|
|
143
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
144
|
+
let sec = {};
|
|
145
|
+
try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
|
|
146
|
+
const m = String(sec?.trigger ?? "").trim();
|
|
147
|
+
const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
|
|
148
|
+
const riskPaths = typeof sec?.risk_paths === "string" ? sec.risk_paths.split(",").map((x) => x.trim()).filter(Boolean) : [];
|
|
149
|
+
let inReview = false, listIndent = null;
|
|
150
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
151
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
152
|
+
if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
153
|
+
if (!inReview) continue;
|
|
154
|
+
const indent = line.length - line.trimStart().length;
|
|
155
|
+
if (listIndent === null) { if (/^\s+risk_paths:\s*(#.*)?$/.test(line)) listIndent = indent; continue; }
|
|
156
|
+
if (indent <= listIndent) { listIndent = /^\s+risk_paths:\s*(#.*)?$/.test(line) ? indent : null; continue; }
|
|
157
|
+
const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
|
|
158
|
+
if (li) riskPaths.push(li[1].trim());
|
|
159
|
+
}
|
|
160
|
+
return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
|
|
161
|
+
}
|
|
162
|
+
/** forge.yaml `lean.repro_test` (off|prefer, #4271); undefined when absent or invalid (the core defaults to prefer). */
|
|
163
|
+
export function readReproTest(cwd = process.cwd()) {
|
|
164
|
+
try {
|
|
165
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean?.repro_test ?? "").trim();
|
|
166
|
+
return ["off", "prefer"].includes(v) ? v : undefined;
|
|
167
|
+
} catch { return undefined; }
|
|
168
|
+
}
|
|
106
169
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
107
170
|
try {
|
|
108
171
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -124,6 +187,20 @@ export function readAdmission(cwd = process.cwd()) {
|
|
|
124
187
|
max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core) };
|
|
125
188
|
} catch { return {}; }
|
|
126
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* forge.yaml heavy-step semaphore (#4169), read next to `engine.admission`: `engine.heavy_enabled|heavy_max|
|
|
192
|
+
* heavy_step_mem_mb|heavy_reserve_mb` (or flat `admission_heavy_*`). Unset keys keep the heavy.mjs defaults.
|
|
193
|
+
*/
|
|
194
|
+
export function readHeavy(cwd = process.cwd()) {
|
|
195
|
+
try {
|
|
196
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
197
|
+
const num = (v) => { const n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n > 0 ? n : undefined; };
|
|
198
|
+
const pick = (k) => e[`heavy_${k}`] ?? e[`admission_heavy_${k}`];
|
|
199
|
+
const en = String(pick("enabled") ?? "").trim().toLowerCase();
|
|
200
|
+
const out = { ...(en === "false" ? { enabled: false } : {}), max: num(pick("max")), step_mem_mb: num(pick("step_mem_mb")), reserve_mb: num(pick("reserve_mb")) };
|
|
201
|
+
return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
|
|
202
|
+
} catch { return {}; }
|
|
203
|
+
}
|
|
127
204
|
/**
|
|
128
205
|
* forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
|
|
129
206
|
* `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
|
|
@@ -151,7 +228,8 @@ export function readMergeWait(cwd = process.cwd()) {
|
|
|
151
228
|
}
|
|
152
229
|
/**
|
|
153
230
|
* forge.yaml `verify:` section (#4167, block style: `verify:` then ` mode: ci`, ` workflow: verify.yml`, ` paths_input`,
|
|
154
|
-
* ` skip_paths` comma-separated, ` max_rounds`) for the core path; undefined when the
|
|
231
|
+
* ` skip_paths` comma-separated, ` max_rounds`, ` check_run: true` (#4270)) for the core path; undefined when the
|
|
232
|
+
* section is absent or unreadable.
|
|
155
233
|
*/
|
|
156
234
|
export function readVerify(cwd = process.cwd()) {
|
|
157
235
|
try {
|
|
@@ -159,6 +237,62 @@ export function readVerify(cwd = process.cwd()) {
|
|
|
159
237
|
return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
|
|
160
238
|
} catch { return undefined; }
|
|
161
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
|
|
242
|
+
* ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
|
|
243
|
+
* ` token_env`, ` cache_store`, ` allow_actions`; #4255 budget: ` budget_minutes`, ` budget_usd`, ` day_budget_minutes`,
|
|
244
|
+
* ` day_budget_usd`, ` on_budget: stop|local`, ` cost_per_minute`) for `orchestrate`; undefined when the section is
|
|
245
|
+
* absent or unreadable.
|
|
246
|
+
*/
|
|
247
|
+
export function readExecution(cwd = process.cwd()) {
|
|
248
|
+
try {
|
|
249
|
+
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.execution;
|
|
250
|
+
return sec && typeof sec === "object" ? parseExecution(sec) : undefined;
|
|
251
|
+
} catch { return undefined; }
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* forge.yaml `learned.test_commands` (#4240): the repo's declared check commands, run by the core path in the lane
|
|
255
|
+
* worktree before the PR. A block list (` test_commands:` then ` - "cmd"` or ` - cmd`); parseForgeYaml reads
|
|
256
|
+
* scalars only. undefined when absent, empty or unreadable.
|
|
257
|
+
*/
|
|
258
|
+
export function readPrecheckCommands(cwd = process.cwd()) {
|
|
259
|
+
let raw;
|
|
260
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
261
|
+
const out = [];
|
|
262
|
+
let inLearned = false, listIndent = null;
|
|
263
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
264
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
265
|
+
if (/^\S/.test(line)) { inLearned = /^learned:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
266
|
+
if (!inLearned) continue;
|
|
267
|
+
const indent = line.length - line.trimStart().length;
|
|
268
|
+
if (listIndent === null) {
|
|
269
|
+
if (/^\s+test_commands:\s*(#.*)?$/.test(line)) listIndent = indent;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (indent <= listIndent) { listIndent = /^\s+test_commands:\s*(#.*)?$/.test(line) ? indent : null; continue; }
|
|
273
|
+
const m = /^\s+-\s+(.*)$/.exec(line);
|
|
274
|
+
if (!m) continue;
|
|
275
|
+
let v = m[1].trim();
|
|
276
|
+
const q = /^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(v) ?? /^'((?:[^']|'')*)'\s*(#.*)?$/.exec(v);
|
|
277
|
+
if (q) v = v.startsWith('"') ? q[1].replace(/\\(["\\])/g, "$1") : q[1].replace(/''/g, "'");
|
|
278
|
+
else v = v.replace(/\s+#.*$/, "").trim();
|
|
279
|
+
if (v) out.push(v);
|
|
280
|
+
}
|
|
281
|
+
return out.length ? out : undefined;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
|
|
285
|
+
* per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
|
|
286
|
+
*/
|
|
287
|
+
export function readEnvConfig(cwd = process.cwd()) {
|
|
288
|
+
try {
|
|
289
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.env ?? {};
|
|
290
|
+
const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
291
|
+
const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
|
|
292
|
+
return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
|
|
293
|
+
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}) };
|
|
294
|
+
} catch { return {}; }
|
|
295
|
+
}
|
|
162
296
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
163
297
|
|
|
164
298
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
@@ -503,7 +637,7 @@ export function lastLocalRun(dir) {
|
|
|
503
637
|
*/
|
|
504
638
|
export async function runFromCli(argv, deps = {}) {
|
|
505
639
|
const issue = parseInt(argv[0], 10);
|
|
506
|
-
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--retry] [--wait-lease]");
|
|
640
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n]] [--retry] [--wait-lease]");
|
|
507
641
|
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
508
642
|
const lane = flag(argv, "--lane");
|
|
509
643
|
if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
|
|
@@ -512,7 +646,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
512
646
|
const backend = flag(argv, "--backend");
|
|
513
647
|
const model = flag(argv, "--model");
|
|
514
648
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
515
|
-
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file"].includes(argv[i - 1]));
|
|
649
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason", "--recover", "--recover-attempt"].includes(argv[i - 1]));
|
|
516
650
|
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
517
651
|
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
518
652
|
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
@@ -525,8 +659,18 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
525
659
|
}
|
|
526
660
|
// The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
|
|
527
661
|
if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
|
|
662
|
+
// #4298: recovery lanes (#4267) exist only on the core path; the legacy full path would silently ignore --recover.
|
|
663
|
+
if (enginePath === "full" && argv.includes("--recover")) throw new Error("--recover is not supported with --path full (recovery lanes run on the core path only)");
|
|
528
664
|
const io = deps.io ?? makeIo();
|
|
529
665
|
const resultFile = flag(argv, "--result-file");
|
|
666
|
+
// #4256: where the dispatcher placed this lane (hybrid execution), recorded in the lane's FORGE:STATE.
|
|
667
|
+
const placementWhere = flag(argv, "--placement");
|
|
668
|
+
const placement = placementWhere === "local" || placementWhere === "remote"
|
|
669
|
+
? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
|
|
670
|
+
// #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`).
|
|
671
|
+
const recoverCode = flag(argv, "--recover");
|
|
672
|
+
const recoverAttempt = Number(flag(argv, "--recover-attempt"));
|
|
673
|
+
const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
|
|
530
674
|
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
531
675
|
try {
|
|
532
676
|
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
@@ -589,9 +733,9 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
589
733
|
let lres;
|
|
590
734
|
try {
|
|
591
735
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
592
|
-
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}),
|
|
736
|
+
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
593
737
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
594
|
-
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
738
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
595
739
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
596
740
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
597
741
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -774,8 +918,106 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
774
918
|
* `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
|
|
775
919
|
* forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
|
|
776
920
|
* burst, and `--set-parallel <id> K` changes K of a running batch.
|
|
921
|
+
* Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
|
|
922
|
+
* a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
|
|
923
|
+
* unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
|
|
924
|
+
* Host lane registry (#4191): every lane takes a slot in `~/.forge/sched/` (or `FORGEDOCK_SCHED_DIR`), capped host-wide by
|
|
925
|
+
* `forgedock sched cap N` or forge.yaml `orchestration.global_parallel`; each issue's priority comes from its
|
|
926
|
+
* `priority:P<n>` / `P<n>` labels (unlabelled = P2), read in the same gh call as its body.
|
|
927
|
+
* Host scheduler (#4192): the batch runs in the `forgedock schedd` daemon (auto-spawned, `lean/schedd.mjs`), one process
|
|
928
|
+
* for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
|
|
929
|
+
* running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
|
|
930
|
+
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
777
931
|
*/
|
|
778
932
|
export async function orchestrateFromCli(argv, deps = {}) {
|
|
933
|
+
const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
|
|
934
|
+
|| argv.includes("--foreground") || process.env.FORGEDOCK_SCHEDD === "0";
|
|
935
|
+
if (inProcess) return runBatch({ argv: argv.filter((a) => a !== "--foreground"), cwd: process.cwd(), env: process.env, sink: consoleSink(), deps });
|
|
936
|
+
return orchestrateClient(argv, deps);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
940
|
+
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
941
|
+
|
|
942
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
|
|
943
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
944
|
+
const issuesOf = (argv) => {
|
|
945
|
+
const issues = [];
|
|
946
|
+
for (let i = 0; i < argv.length; i++) {
|
|
947
|
+
if (ORCH_VALUED.includes(argv[i])) { i++; continue; }
|
|
948
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
949
|
+
}
|
|
950
|
+
return issues;
|
|
951
|
+
};
|
|
952
|
+
const parallelOf = (argv) => {
|
|
953
|
+
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
954
|
+
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
955
|
+
return parallel;
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
|
|
960
|
+
* streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
|
|
961
|
+
*/
|
|
962
|
+
async function orchestrateClient(argv, deps = {}) {
|
|
963
|
+
const schedd = await import("./lean/schedd.mjs");
|
|
964
|
+
const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
|
|
965
|
+
const connect = () => schedd.connectDaemon({ dir, ...(deps.spawnDaemon !== undefined ? { spawnDaemon: deps.spawnDaemon } : {}), ...(deps.waitMs ? { waitMs: deps.waitMs } : {}) });
|
|
966
|
+
if (argv.includes("--set-parallel")) {
|
|
967
|
+
const i = argv.indexOf("--set-parallel");
|
|
968
|
+
const id = argv[i + 1], k = Number(argv[i + 2]);
|
|
969
|
+
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
970
|
+
let sock = null;
|
|
971
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
972
|
+
if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
|
|
973
|
+
else {
|
|
974
|
+
// No daemon: the batch is not running anywhere; the parallel file is all there is to change.
|
|
975
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
976
|
+
const b = batchMod.openBatch(batchMod.batchRoot(), id);
|
|
977
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${batchMod.batchRoot()}`);
|
|
978
|
+
b.setParallel(k);
|
|
979
|
+
}
|
|
980
|
+
console.log(`batch ${id}: parallel set to ${k}`);
|
|
981
|
+
return { ok: true, batch: id, parallel: k };
|
|
982
|
+
}
|
|
983
|
+
const resumeId = flag(argv, "--resume");
|
|
984
|
+
let msg;
|
|
985
|
+
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
986
|
+
else {
|
|
987
|
+
if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
|
|
988
|
+
parallelOf(argv);
|
|
989
|
+
let sub = [...argv];
|
|
990
|
+
if (!flag(sub, "--repo")) {
|
|
991
|
+
const repo = await resolveDefaultRepo(deps.io ?? makeIo());
|
|
992
|
+
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
993
|
+
sub = [...sub, "--repo", repo];
|
|
994
|
+
}
|
|
995
|
+
msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
|
|
996
|
+
}
|
|
997
|
+
const sock = await connect();
|
|
998
|
+
let id = resumeId;
|
|
999
|
+
try {
|
|
1000
|
+
const done = await schedd.request(sock, msg, (m) => {
|
|
1001
|
+
if (m.type === "batch") id = m.id;
|
|
1002
|
+
else if (m.type === "row") console.log(formatRowLine(m.row));
|
|
1003
|
+
else if (m.type === "line") console.log(m.text);
|
|
1004
|
+
else if (m.type === "done" && m.replay && m.report) console.log(m.report);
|
|
1005
|
+
});
|
|
1006
|
+
return done.result;
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
|
|
1009
|
+
throw e;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* One batch from start to report, in this process: the in-process `orchestrate` path and the schedd daemon's per-batch
|
|
1015
|
+
* runner (#4192). `cwd` and `env` are the submitting client's (`env` picks the lanes' account); output goes to `sink`
|
|
1016
|
+
* (`batch(b)` once the batch exists, `row(r)` per update, `line(text)` otherwise). A daemon-hosted batch records
|
|
1017
|
+
* `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
|
|
1018
|
+
* (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
|
|
1019
|
+
*/
|
|
1020
|
+
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
|
|
779
1021
|
const batchMod = await import("./lean/batch.mjs");
|
|
780
1022
|
const root = deps.batchRoot ?? batchMod.batchRoot();
|
|
781
1023
|
if (argv.includes("--set-parallel")) {
|
|
@@ -784,7 +1026,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
784
1026
|
if (!b.exists()) throw new Error(`no batch ${argv[i + 1]} in ${root}`);
|
|
785
1027
|
const k = Number(argv[i + 2]);
|
|
786
1028
|
b.setParallel(k);
|
|
787
|
-
|
|
1029
|
+
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
788
1030
|
return { ok: true, batch: b.id, parallel: k };
|
|
789
1031
|
}
|
|
790
1032
|
const resumeId = flag(argv, "--resume");
|
|
@@ -795,39 +1037,40 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
795
1037
|
if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
|
|
796
1038
|
argv = plan.argv;
|
|
797
1039
|
}
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
806
|
-
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1040
|
+
const issues = issuesOf(argv);
|
|
1041
|
+
if (!issues.length) throw new Error(ORCH_USAGE);
|
|
1042
|
+
const parallel = parallelOf(argv);
|
|
1043
|
+
// The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
|
|
1044
|
+
// A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
|
|
1045
|
+
const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? callerEnv : null);
|
|
1046
|
+
const env = kept ? (await import("./lean/schedd.mjs")).mergeEnv(process.env, kept) : process.env;
|
|
807
1047
|
const lane = flag(argv, "--lane");
|
|
808
1048
|
const laneOverride = argv.includes("--lane-override");
|
|
809
1049
|
const model = flag(argv, "--model");
|
|
810
1050
|
const io = deps.io ?? makeIo();
|
|
811
1051
|
const repo = flag(argv, "--repo") ?? plan?.repo ?? (await resolveDefaultRepo(io));
|
|
812
1052
|
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
813
|
-
|
|
814
|
-
|
|
1053
|
+
const cwd = plan?.cwd ?? callerCwd;
|
|
1054
|
+
await assertLaneNotDefault(io, lane, repo, cwd);
|
|
815
1055
|
if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
|
|
816
1056
|
if (!batch) {
|
|
817
1057
|
const base = deps.batchId ?? batchMod.newBatchId();
|
|
818
1058
|
batch = batchMod.openBatch(root, base);
|
|
819
1059
|
for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
|
|
820
|
-
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString() };
|
|
1060
|
+
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
821
1061
|
batch.writePlan(plan);
|
|
822
1062
|
batch.setParallel(parallel);
|
|
823
1063
|
} else {
|
|
824
1064
|
// A resume is the operator's decision to continue: the breaker starts closed again.
|
|
825
1065
|
const st = batch.store.load();
|
|
826
|
-
if (st) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1066
|
+
if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1067
|
+
if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
|
|
1068
|
+
batch.clearReport();
|
|
827
1069
|
}
|
|
828
|
-
|
|
1070
|
+
sink.batch?.(batch);
|
|
1071
|
+
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
829
1072
|
const dir = deps.dir ?? runDir();
|
|
830
|
-
const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
|
|
1073
|
+
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists } = await import("./lean/orchestrate.mjs");
|
|
831
1074
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
832
1075
|
const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
833
1076
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
@@ -839,48 +1082,364 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
839
1082
|
if (r.ok && prefilter && !fetched.has(r.lane)) { fetched.add(r.lane); try { await leanIo.git(["fetch", "origin", r.lane], { cwd }); } catch { /* offline: use the local ref */ } }
|
|
840
1083
|
return r;
|
|
841
1084
|
};
|
|
842
|
-
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes
|
|
843
|
-
|
|
1085
|
+
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
|
|
1086
|
+
// sandboxes when forge.yaml `execution` places them remote (#4253).
|
|
1087
|
+
const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
|
|
1088
|
+
let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
|
|
1089
|
+
const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
|
|
844
1090
|
if (deps.leanModule) {
|
|
845
1091
|
const { runCore } = deps.leanModule;
|
|
846
1092
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
847
|
-
lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
848
|
-
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
1093
|
+
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1094
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
849
1095
|
} else {
|
|
850
|
-
|
|
851
|
-
|
|
1096
|
+
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1097
|
+
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
1098
|
+
// #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
|
|
1099
|
+
const placementOf = new Map(), recoverOf = new Map();
|
|
1100
|
+
const laneArgs = (n, ln) => { const pl = placementOf.get(n), rc = recoverOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
|
|
1101
|
+
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1)] : [])]; };
|
|
1102
|
+
const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
|
|
1103
|
+
if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
|
|
1104
|
+
if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
|
|
1105
|
+
return l.runOne(n, ln, ctx);
|
|
1106
|
+
}, adopt: l.adopt });
|
|
1107
|
+
const execMod = await import("./lean/execution.mjs");
|
|
1108
|
+
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1109
|
+
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1110
|
+
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1111
|
+
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1112
|
+
let gitDir = null;
|
|
1113
|
+
try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
|
|
1114
|
+
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env });
|
|
1115
|
+
const { makeLedger } = await import("./lean/ledger.mjs");
|
|
1116
|
+
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1117
|
+
const bud = exec.budget ?? {};
|
|
1118
|
+
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1119
|
+
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1120
|
+
lanes = withPlacement(remoteLanes);
|
|
1121
|
+
if (bud.onBudget === "local" || hybrid) {
|
|
1122
|
+
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
1123
|
+
const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
|
|
1124
|
+
lanes = withPlacement({
|
|
1125
|
+
runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
|
|
1126
|
+
adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
// #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
|
|
1130
|
+
execConf = exec;
|
|
1131
|
+
execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
|
|
1132
|
+
const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1133
|
+
// #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
|
|
1134
|
+
const place = hybrid ? await (async () => {
|
|
1135
|
+
const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
|
|
1136
|
+
let weight = deps.validationWeight ?? null;
|
|
1137
|
+
if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
|
|
1138
|
+
const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
|
|
1139
|
+
return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
|
|
1140
|
+
})() : null;
|
|
1141
|
+
remote = {
|
|
1142
|
+
max: exec.maxRemote, isRemote: () => true, ...(place ? { place, fallbackLocal: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1143
|
+
admit: (u) => execBudget.admit(u),
|
|
1144
|
+
onUsage: (n, sb, rec) => {
|
|
1145
|
+
const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
|
|
1146
|
+
try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
|
|
1147
|
+
Promise.resolve(emitSbx({ event: "SANDBOX_USAGE", issue: n, provider: sb.provider, sandbox_id: sb.id, minutes: sb.minutes, cost_usd: sb.cost_usd, batch: batch.id })).catch(() => {});
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
1150
|
+
sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
|
|
1151
|
+
} else {
|
|
1152
|
+
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1153
|
+
}
|
|
852
1154
|
}
|
|
853
1155
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1156
|
+
// #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
|
|
1157
|
+
const limitsMod = await import("./lean/limits.mjs");
|
|
1158
|
+
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
|
|
1159
|
+
const identity = deps.identity ?? (() => limitsMod.laneBackendId(env));
|
|
1160
|
+
const maint = deps.maintenance !== undefined ? deps.maintenance : readMaintenance(cwd);
|
|
1161
|
+
const schedMod = await import("./lean/sched.mjs");
|
|
1162
|
+
const registry = deps.sched !== undefined ? deps.sched : schedMod.openSched(schedMod.schedDir());
|
|
1163
|
+
const labelNames = async (n) => {
|
|
1164
|
+
if (!labelsOf.has(n)) labelsOf.set(n, (JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "labels"])).labels ?? []).map((x) => x.name));
|
|
1165
|
+
return labelsOf.get(n);
|
|
1166
|
+
};
|
|
1167
|
+
let budget = {};
|
|
1168
|
+
if (maint) {
|
|
1169
|
+
const mLabels = maint.labels ?? DEFAULT_MAINTENANCE_LABELS;
|
|
1170
|
+
for (const l of mLabels) {
|
|
1171
|
+
let listed = [];
|
|
1172
|
+
try { listed = JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--state", "open", "--label", l, "--json", "number,labels", "--limit", "100"])); } catch { listed = []; }
|
|
1173
|
+
for (const it of Array.isArray(listed) ? listed : []) {
|
|
1174
|
+
const names = (it.labels ?? []).map((x) => x.name);
|
|
1175
|
+
if (!labelsOf.has(it.number)) labelsOf.set(it.number, names);
|
|
1176
|
+
if (!names.includes("needs-human") && !issues.includes(it.number)) issues.push(it.number);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const classify = async (n) => {
|
|
1180
|
+
const names = await labelNames(n);
|
|
1181
|
+
const p = names.map((x) => /^priority:P(\d+)$/i.exec(x)?.[1]).find((x) => x !== undefined);
|
|
1182
|
+
return { maintenance: names.some((x) => mLabels.includes(x)), priority: p === undefined ? 2 : Number(p) };
|
|
1183
|
+
};
|
|
1184
|
+
budget = { classify, maintenance: { share: maint.share ?? 0.2, whenIdle: maint.whenIdle ?? true } };
|
|
1185
|
+
}
|
|
1186
|
+
// forge#4161: milestone drift. Always folds merged milestone lanes into the summary; thresholds and holds only with config.
|
|
1187
|
+
let drift = deps.drift;
|
|
1188
|
+
if (drift === undefined) {
|
|
1189
|
+
const { makeDrift } = await import("./lean/drift.mjs");
|
|
1190
|
+
const { affectedFiles } = await import("./orchestrate-preflight.mjs");
|
|
1191
|
+
drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
|
|
1192
|
+
onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
|
|
1193
|
+
}
|
|
854
1194
|
const crossLane = [];
|
|
1195
|
+
// #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
|
|
1196
|
+
const gcMod = await import("./gc.mjs");
|
|
1197
|
+
const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
|
|
1198
|
+
const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
|
|
1199
|
+
const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
|
|
1200
|
+
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
|
|
1201
|
+
const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
|
|
1202
|
+
const nowMs = deps.now ?? (() => Date.now());
|
|
1203
|
+
let lastGcAt = null;
|
|
1204
|
+
const runGcPhase = async (phase) => {
|
|
1205
|
+
if (!gcRun) return;
|
|
1206
|
+
lastGcAt = nowMs();
|
|
1207
|
+
try {
|
|
1208
|
+
const r = (await gcRun({ phase })) ?? {};
|
|
1209
|
+
const n = Number(r.reclaimed) || 0;
|
|
1210
|
+
reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
|
|
1211
|
+
reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
|
|
1212
|
+
for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
|
|
1213
|
+
} catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1214
|
+
};
|
|
1215
|
+
await runGcPhase("preflight");
|
|
1216
|
+
const GC_ADMISSION_INTERVAL_MS = 5 * 60 * 1000;
|
|
1217
|
+
const beforeAdmit = !gcRun ? null : async () => {
|
|
1218
|
+
if (lastGcAt !== null && nowMs() - lastGcAt < GC_ADMISSION_INTERVAL_MS) return;
|
|
1219
|
+
let near = false;
|
|
1220
|
+
try {
|
|
1221
|
+
const { nearCeiling } = await import("./lean/admission.mjs");
|
|
1222
|
+
const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
|
|
1223
|
+
near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
|
|
1224
|
+
} catch { near = false; }
|
|
1225
|
+
if (near) await runGcPhase("admission");
|
|
1226
|
+
};
|
|
855
1227
|
const rows = await orchestrate({
|
|
1228
|
+
beforeAdmit,
|
|
856
1229
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
|
|
857
|
-
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
|
|
1230
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1231
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1232
|
+
...(remote ? { remote } : {}),
|
|
1233
|
+
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1234
|
+
...(orch.recovery?.maxAttempts ? { recovery: orch.recovery } : {}),
|
|
1235
|
+
decide: deps.decide ?? (async (d) => {
|
|
1236
|
+
if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
|
|
1237
|
+
const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
|
|
1238
|
+
try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
|
|
1239
|
+
}),
|
|
1240
|
+
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1241
|
+
relabelNeedsHuman: deps.relabelNeedsHuman ?? (async ({ issue, evidence }) => {
|
|
1242
|
+
try {
|
|
1243
|
+
await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`]);
|
|
1244
|
+
await leanIo.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
1245
|
+
} catch { /* best effort: the batch row keeps the evidence */ }
|
|
1246
|
+
}),
|
|
1247
|
+
conflicting: deps.conflicting ?? (async (_n, pr) => {
|
|
1248
|
+
try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
|
|
1249
|
+
}),
|
|
858
1250
|
onCrossLane: (e) => {
|
|
859
1251
|
crossLane.push(e);
|
|
860
|
-
|
|
1252
|
+
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
861
1253
|
},
|
|
862
|
-
readBody:
|
|
863
|
-
|
|
1254
|
+
readBody: maint || registry || hybrid
|
|
1255
|
+
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
|
|
1256
|
+
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1257
|
+
onUpdate: (r) => sink.row(r),
|
|
864
1258
|
});
|
|
865
|
-
|
|
1259
|
+
const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
|
|
866
1260
|
const pfLine = formatPrefilterSummary(rows);
|
|
867
|
-
if (pfLine)
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1261
|
+
if (pfLine) report.push(pfLine);
|
|
1262
|
+
let heavyStats = null; // #4169: live heavy-semaphore queue next to the per-phase waits the lanes reported
|
|
1263
|
+
try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
|
|
1264
|
+
const waitLine = formatWaitSummary(rows, heavyStats);
|
|
1265
|
+
if (waitLine) report.push(waitLine);
|
|
1266
|
+
let day = null;
|
|
1267
|
+
try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
|
|
1268
|
+
const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
|
|
1269
|
+
if (sbxLine) report.push(sbxLine);
|
|
1270
|
+
if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
|
|
1271
|
+
if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
|
|
1272
|
+
report.push(formatReclaimed(reclaimed));
|
|
1273
|
+
for (const l of report) sink.line(l);
|
|
1274
|
+
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1275
|
+
try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1276
|
+
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** The batch report's "reclaimed" line (#4183). */
|
|
1280
|
+
export function formatReclaimed(r) {
|
|
1281
|
+
if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
|
|
1282
|
+
const b = r.bytes, size = b >= 1073741824 ? `${(b / 1073741824).toFixed(1)}G` : b >= 1048576 ? `${(b / 1048576).toFixed(1)}M` : b >= 1024 ? `${Math.round(b / 1024)}K` : `${b}B`;
|
|
1283
|
+
return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
|
|
1288
|
+
*/
|
|
1289
|
+
export async function scheddFromCli(argv, deps = {}) {
|
|
1290
|
+
const schedd = await import("./lean/schedd.mjs");
|
|
1291
|
+
const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
|
|
1292
|
+
if (argv[0] === "status") {
|
|
1293
|
+
let sock;
|
|
1294
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
|
|
1295
|
+
const st = await schedd.request(sock, { op: "status" });
|
|
1296
|
+
console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}`);
|
|
1297
|
+
return { running: true, ...st };
|
|
1298
|
+
}
|
|
1299
|
+
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1300
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1301
|
+
if (!d.running) return d;
|
|
1302
|
+
for (const sig of ["SIGTERM", "SIGINT"]) process.once(sig, () => d.close());
|
|
1303
|
+
await d.closed;
|
|
1304
|
+
return { running: false };
|
|
871
1305
|
}
|
|
872
1306
|
|
|
873
1307
|
/**
|
|
874
|
-
*
|
|
875
|
-
*
|
|
1308
|
+
* `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
|
|
1309
|
+
* Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
|
|
1310
|
+
* shared by every batch on the host. `show` lists the active holds (identity, reset time, source; `*` marks the account
|
|
1311
|
+
* this shell's lanes would use); `clear` drops one hold or all of them, so held batches admit on their next pass.
|
|
1312
|
+
*/
|
|
1313
|
+
export async function limitsFromCli(argv, deps = {}) {
|
|
1314
|
+
const limitsMod = await import("./lean/limits.mjs");
|
|
1315
|
+
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile());
|
|
1316
|
+
const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env)))();
|
|
1317
|
+
const usage = "usage: forgedock limits show | forgedock limits clear <id>|--all";
|
|
1318
|
+
const [sub, arg] = argv;
|
|
1319
|
+
if (!sub || sub === "show") {
|
|
1320
|
+
const holds = limits.list();
|
|
1321
|
+
if (!holds.length) console.log(`no usage-limit holds (this account: ${current})`);
|
|
1322
|
+
for (const h of holds) console.log(`${h.id === current ? "*" : " "} ${h.id} until ${new Date(h.until).toISOString()} source ${h.source ?? "-"}`);
|
|
1323
|
+
return { holds, current };
|
|
1324
|
+
}
|
|
1325
|
+
if (sub === "clear") {
|
|
1326
|
+
if (arg === "--all") { const n = limits.clearAll(); console.log(`cleared ${n} usage-limit hold(s)`); return { cleared: n }; }
|
|
1327
|
+
if (!arg || arg.startsWith("--")) throw new Error(usage);
|
|
1328
|
+
if (!limits.clear(arg)) throw new Error(`no active usage-limit hold for ${arg}`);
|
|
1329
|
+
console.log(`cleared usage-limit hold for ${arg}`);
|
|
1330
|
+
return { cleared: 1 };
|
|
1331
|
+
}
|
|
1332
|
+
throw new Error(usage);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
|
|
1337
|
+
* The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
|
|
1338
|
+
* `show` prints the cap, the repo weights, the running slots and the queued issues in grant order; `cap N` sets the
|
|
1339
|
+
* host-wide cap live (the next admission pass of every batch uses it); `cap --clear` falls back to each batch's
|
|
1340
|
+
* `orchestration.global_parallel`. `weight <owner/repo> N` sets that repo's share of slots within a priority (default 1);
|
|
1341
|
+
* `--clear` resets it to 1.
|
|
1342
|
+
*/
|
|
1343
|
+
export async function schedFromCli(argv, deps = {}) {
|
|
1344
|
+
const schedMod = await import("./lean/sched.mjs");
|
|
1345
|
+
const reg = deps.sched ?? schedMod.openSched(schedMod.schedDir());
|
|
1346
|
+
const usage = "usage: forgedock sched show | forgedock sched cap <N>|--clear | forgedock sched weight <owner/repo> <N>|--clear";
|
|
1347
|
+
const [sub, arg, arg2] = argv;
|
|
1348
|
+
if (!sub || sub === "show") {
|
|
1349
|
+
const st = reg.list();
|
|
1350
|
+
const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
|
|
1351
|
+
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
|
|
1352
|
+
const ws = Object.entries(st.weights ?? {});
|
|
1353
|
+
console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
|
|
1354
|
+
for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
|
|
1355
|
+
for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
|
|
1356
|
+
return st;
|
|
1357
|
+
}
|
|
1358
|
+
if (sub === "cap") {
|
|
1359
|
+
if (arg === "--clear") { reg.setCap(null); console.log("host cap cleared"); return { cap: null }; }
|
|
1360
|
+
const k = Number(arg);
|
|
1361
|
+
if (!/^\d+$/.test(String(arg ?? "")) || !Number.isInteger(k) || k < 1) throw new Error(usage);
|
|
1362
|
+
reg.setCap(k);
|
|
1363
|
+
console.log(`host cap set to ${k}`);
|
|
1364
|
+
return { cap: k };
|
|
1365
|
+
}
|
|
1366
|
+
if (sub === "weight") {
|
|
1367
|
+
if (!arg || arg.startsWith("-")) throw new Error(usage);
|
|
1368
|
+
if (arg2 === "--clear") { reg.setWeight(arg, null); console.log(`weight of ${arg} reset to 1`); return { repo: arg, weight: null }; }
|
|
1369
|
+
const n = Number(arg2);
|
|
1370
|
+
if (!/^\d+$/.test(String(arg2 ?? "")) || !Number.isInteger(n) || n < 1) throw new Error(usage);
|
|
1371
|
+
reg.setWeight(arg, n);
|
|
1372
|
+
console.log(`weight of ${arg} set to ${n}`);
|
|
1373
|
+
return { repo: arg, weight: n };
|
|
1374
|
+
}
|
|
1375
|
+
throw new Error(usage);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
1380
|
+
* `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
|
|
876
1381
|
* `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
|
|
1382
|
+
* `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
|
|
1383
|
+
* issue for a mechanical needs-human, default 1, 0 turns recovery off.
|
|
877
1384
|
*/
|
|
878
1385
|
export function readOrchestration(cwd = process.cwd()) {
|
|
879
1386
|
let o = {};
|
|
880
1387
|
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
|
|
881
1388
|
const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
|
|
882
1389
|
const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
|
|
883
|
-
|
|
1390
|
+
const gp = int(o.global_parallel, null);
|
|
1391
|
+
const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
|
|
1392
|
+
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
|
|
1393
|
+
recovery: { maxAttempts: recoveryMax } };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* forge.yaml `orchestrate.drift` (#4161): `{pairGrowth, packageLines, fileLines, blockDispatch, generatedGlobs}` with
|
|
1398
|
+
* only the positive-integer thresholds set, or null when no threshold and no `block_dispatch: true` is set (summary
|
|
1399
|
+
* only: no script run, no issue, no hold). The parser flattens `orchestrate.drift.*` to `orchestrate.*`.
|
|
1400
|
+
*/
|
|
1401
|
+
export function readDrift(cwd = process.cwd()) {
|
|
1402
|
+
let o = null;
|
|
1403
|
+
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestrate ?? null; } catch { o = null; }
|
|
1404
|
+
if (!o || typeof o !== "object") return null;
|
|
1405
|
+
const int = (v) => { const n = Number(v); return v !== undefined && String(v).trim() !== "" && Number.isInteger(n) && n > 0 ? n : null; };
|
|
1406
|
+
const out = { pairGrowth: int(o.pair_growth), packageLines: int(o.package_lines), fileLines: int(o.file_lines),
|
|
1407
|
+
blockDispatch: String(o.block_dispatch ?? "").trim() === "true",
|
|
1408
|
+
generatedGlobs: String(o.generated_globs ?? "").split(",").map((x) => x.trim()).filter(Boolean) };
|
|
1409
|
+
return out.pairGrowth == null && out.packageLines == null && out.fileLines == null && !out.blockDispatch ? null : out;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* forge.yaml text without the `maintenance.hotspot:` sub-map (forge#4195, read by scripts/hotspots.sh via yq). The
|
|
1414
|
+
* parser flattens one nested level, so its `enabled`/`file_lines` would otherwise overwrite the budget's own keys.
|
|
1415
|
+
*/
|
|
1416
|
+
function withoutHotspotMap(raw) {
|
|
1417
|
+
let inMaint = false, sub = -1;
|
|
1418
|
+
return String(raw).split(/\r?\n/).filter((l) => {
|
|
1419
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(l)) { inMaint = /^maintenance:\s*(?:#.*)?$/.test(l); sub = -1; return true; }
|
|
1420
|
+
const indent = /^\s*/.exec(l)[0].length;
|
|
1421
|
+
if (sub >= 0) { if (/^\s*(?:#.*)?$/.test(l) || indent > sub) return false; sub = -1; }
|
|
1422
|
+
if (inMaint && /^\s+hotspot:\s*(?:#.*)?$/.test(l)) { sub = indent; return false; }
|
|
1423
|
+
return true;
|
|
1424
|
+
}).join("\n");
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
export const DEFAULT_MAINTENANCE_LABELS = ["maintenance", "refactor:split", "dead-code"];
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* forge.yaml `maintenance:` (#4197): null unless `enabled: true`. `share` in (0,1] (default 0.2), `when_idle` (default
|
|
1431
|
+
* true), `labels` comma-separated (default maintenance,refactor:split,dead-code), `scan` (default weekly), and the
|
|
1432
|
+
* maintenance-scan thresholds `file_lines` (2000) / `doc_kb` (64). Block form only: the parser does not read `{ ... }`.
|
|
1433
|
+
*/
|
|
1434
|
+
export function readMaintenance(cwd = process.cwd()) {
|
|
1435
|
+
let m = null;
|
|
1436
|
+
try { m = parseForgeYaml(withoutHotspotMap(readFileSync(join(cwd, "forge.yaml"), "utf8")))?.maintenance ?? null; } catch { m = null; }
|
|
1437
|
+
if (!m || typeof m !== "object" || String(m.enabled ?? "").trim() !== "true") return null;
|
|
1438
|
+
const num = (v, d, ok) => { const n = Number(v); return v !== undefined && v !== "" && ok(n) ? n : d; };
|
|
1439
|
+
const labels = String(m.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
1440
|
+
return { share: num(m.share, 0.2, (n) => n > 0 && n <= 1), whenIdle: String(m.when_idle ?? "true").trim() !== "false",
|
|
1441
|
+
labels: labels.length ? labels : [...DEFAULT_MAINTENANCE_LABELS], scan: String(m.scan ?? "weekly").trim() || "weekly",
|
|
1442
|
+
fileLines: num(m.file_lines, 2000, (n) => Number.isInteger(n) && n > 0), docKb: num(m.doc_kb, 64, (n) => n > 0) };
|
|
884
1443
|
}
|
|
885
1444
|
|
|
886
1445
|
/** `--prefilter-model M`, else forge.yaml `engine.lean_prefilter_model`, else the pre-filter default. */
|