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
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";
|
|
@@ -17,7 +17,10 @@ import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
|
17
17
|
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
|
+
import { parseVerify } from "./lean/verify.mjs";
|
|
21
|
+
import { parseExecution } from "./lean/execution.mjs";
|
|
20
22
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
23
|
+
import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
@@ -101,6 +104,46 @@ export function readConformanceMode(cwd = process.cwd()) {
|
|
|
101
104
|
return ["shadow", "enforce"].includes(v) ? v : undefined;
|
|
102
105
|
} catch { return undefined; }
|
|
103
106
|
}
|
|
107
|
+
/** forge.yaml `engine.review_incomplete` (needs-human|degraded, #4160); undefined when absent or invalid (the core defaults to needs-human). */
|
|
108
|
+
export function readReviewIncomplete(cwd = process.cwd()) {
|
|
109
|
+
try {
|
|
110
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.review_incomplete ?? "").trim();
|
|
111
|
+
return ["needs-human", "degraded"].includes(v) ? v : undefined;
|
|
112
|
+
} catch { return undefined; }
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
|
|
116
|
+
* list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
|
|
117
|
+
* shadow with the built-in risk patterns). An invalid mode reads as unset.
|
|
118
|
+
*/
|
|
119
|
+
export function readReviewTrigger(cwd = process.cwd()) {
|
|
120
|
+
let raw;
|
|
121
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
122
|
+
let sec = {};
|
|
123
|
+
try { sec = parseForgeYaml(raw)?.review ?? {}; } catch { /* unreadable: no trigger config */ }
|
|
124
|
+
const m = String(sec?.trigger ?? "").trim();
|
|
125
|
+
const mode = ["shadow", "enforce", "off"].includes(m) ? m : undefined;
|
|
126
|
+
const riskPaths = typeof sec?.risk_paths === "string" ? sec.risk_paths.split(",").map((x) => x.trim()).filter(Boolean) : [];
|
|
127
|
+
let inReview = false, listIndent = null;
|
|
128
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
129
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
130
|
+
if (/^\S/.test(line)) { inReview = /^review:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
131
|
+
if (!inReview) continue;
|
|
132
|
+
const indent = line.length - line.trimStart().length;
|
|
133
|
+
if (listIndent === null) { if (/^\s+risk_paths:\s*(#.*)?$/.test(line)) listIndent = indent; continue; }
|
|
134
|
+
if (indent <= listIndent) { listIndent = /^\s+risk_paths:\s*(#.*)?$/.test(line) ? indent : null; continue; }
|
|
135
|
+
const li = /^\s+-\s+["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line);
|
|
136
|
+
if (li) riskPaths.push(li[1].trim());
|
|
137
|
+
}
|
|
138
|
+
return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
|
|
139
|
+
}
|
|
140
|
+
/** forge.yaml `lean.repro_test` (off|prefer, #4271); undefined when absent or invalid (the core defaults to prefer). */
|
|
141
|
+
export function readReproTest(cwd = process.cwd()) {
|
|
142
|
+
try {
|
|
143
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean?.repro_test ?? "").trim();
|
|
144
|
+
return ["off", "prefer"].includes(v) ? v : undefined;
|
|
145
|
+
} catch { return undefined; }
|
|
146
|
+
}
|
|
104
147
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
105
148
|
try {
|
|
106
149
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -122,6 +165,20 @@ export function readAdmission(cwd = process.cwd()) {
|
|
|
122
165
|
max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core) };
|
|
123
166
|
} catch { return {}; }
|
|
124
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* forge.yaml heavy-step semaphore (#4169), read next to `engine.admission`: `engine.heavy_enabled|heavy_max|
|
|
170
|
+
* heavy_step_mem_mb|heavy_reserve_mb` (or flat `admission_heavy_*`). Unset keys keep the heavy.mjs defaults.
|
|
171
|
+
*/
|
|
172
|
+
export function readHeavy(cwd = process.cwd()) {
|
|
173
|
+
try {
|
|
174
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
175
|
+
const num = (v) => { const n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n > 0 ? n : undefined; };
|
|
176
|
+
const pick = (k) => e[`heavy_${k}`] ?? e[`admission_heavy_${k}`];
|
|
177
|
+
const en = String(pick("enabled") ?? "").trim().toLowerCase();
|
|
178
|
+
const out = { ...(en === "false" ? { enabled: false } : {}), max: num(pick("max")), step_mem_mb: num(pick("step_mem_mb")), reserve_mb: num(pick("reserve_mb")) };
|
|
179
|
+
return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined));
|
|
180
|
+
} catch { return {}; }
|
|
181
|
+
}
|
|
125
182
|
/**
|
|
126
183
|
* forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
|
|
127
184
|
* `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
|
|
@@ -147,6 +204,72 @@ export function readMergeWait(cwd = process.cwd()) {
|
|
|
147
204
|
return ["required", "all", "none"].includes(v) ? v : undefined;
|
|
148
205
|
} catch { return undefined; }
|
|
149
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* forge.yaml `verify:` section (#4167, block style: `verify:` then ` mode: ci`, ` workflow: verify.yml`, ` paths_input`,
|
|
209
|
+
* ` skip_paths` comma-separated, ` max_rounds`) for the core path; undefined when the section is absent or unreadable.
|
|
210
|
+
*/
|
|
211
|
+
export function readVerify(cwd = process.cwd()) {
|
|
212
|
+
try {
|
|
213
|
+
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
|
|
214
|
+
return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
|
|
215
|
+
} catch { return undefined; }
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
|
|
219
|
+
* ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
|
|
220
|
+
* ` token_env`, ` cache_store`, ` allow_actions`; #4255 budget: ` budget_minutes`, ` budget_usd`, ` day_budget_minutes`,
|
|
221
|
+
* ` day_budget_usd`, ` on_budget: stop|local`, ` cost_per_minute`) for `orchestrate`; undefined when the section is
|
|
222
|
+
* absent or unreadable.
|
|
223
|
+
*/
|
|
224
|
+
export function readExecution(cwd = process.cwd()) {
|
|
225
|
+
try {
|
|
226
|
+
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.execution;
|
|
227
|
+
return sec && typeof sec === "object" ? parseExecution(sec) : undefined;
|
|
228
|
+
} catch { return undefined; }
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* forge.yaml `learned.test_commands` (#4240): the repo's declared check commands, run by the core path in the lane
|
|
232
|
+
* worktree before the PR. A block list (` test_commands:` then ` - "cmd"` or ` - cmd`); parseForgeYaml reads
|
|
233
|
+
* scalars only. undefined when absent, empty or unreadable.
|
|
234
|
+
*/
|
|
235
|
+
export function readPrecheckCommands(cwd = process.cwd()) {
|
|
236
|
+
let raw;
|
|
237
|
+
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
238
|
+
const out = [];
|
|
239
|
+
let inLearned = false, listIndent = null;
|
|
240
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
241
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
242
|
+
if (/^\S/.test(line)) { inLearned = /^learned:\s*(#.*)?$/.test(line); listIndent = null; continue; }
|
|
243
|
+
if (!inLearned) continue;
|
|
244
|
+
const indent = line.length - line.trimStart().length;
|
|
245
|
+
if (listIndent === null) {
|
|
246
|
+
if (/^\s+test_commands:\s*(#.*)?$/.test(line)) listIndent = indent;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (indent <= listIndent) { listIndent = /^\s+test_commands:\s*(#.*)?$/.test(line) ? indent : null; continue; }
|
|
250
|
+
const m = /^\s+-\s+(.*)$/.exec(line);
|
|
251
|
+
if (!m) continue;
|
|
252
|
+
let v = m[1].trim();
|
|
253
|
+
const q = /^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(v) ?? /^'((?:[^']|'')*)'\s*(#.*)?$/.exec(v);
|
|
254
|
+
if (q) v = v.startsWith('"') ? q[1].replace(/\\(["\\])/g, "$1") : q[1].replace(/''/g, "'");
|
|
255
|
+
else v = v.replace(/\s+#.*$/, "").trim();
|
|
256
|
+
if (v) out.push(v);
|
|
257
|
+
}
|
|
258
|
+
return out.length ? out : undefined;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
|
|
262
|
+
* per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
|
|
263
|
+
*/
|
|
264
|
+
export function readEnvConfig(cwd = process.cwd()) {
|
|
265
|
+
try {
|
|
266
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.env ?? {};
|
|
267
|
+
const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
268
|
+
const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
|
|
269
|
+
return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
|
|
270
|
+
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}) };
|
|
271
|
+
} catch { return {}; }
|
|
272
|
+
}
|
|
150
273
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
151
274
|
|
|
152
275
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
@@ -256,21 +379,36 @@ export function formatTerminalDiagnostics(dir, issue) {
|
|
|
256
379
|
return lines.join("\n");
|
|
257
380
|
}
|
|
258
381
|
|
|
382
|
+
// forge#4157: backoff between `gh repo view` attempts. Under host load one call can exceed makeIo's exec timeout.
|
|
383
|
+
export const REPO_VIEW_BACKOFF_MS = [2000, 4000];
|
|
384
|
+
// forge#4157: how long an orchestrate lane waits before rerunning after `gh` stayed unreachable.
|
|
385
|
+
export const GH_UNAVAILABLE_RETRY_S = 60;
|
|
386
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
387
|
+
|
|
259
388
|
/**
|
|
260
389
|
* Resolves the repo `gh` would target by default (i.e. resolved from the cwd
|
|
261
390
|
* git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
|
|
262
|
-
* against the ambient context — see `assertRepoMatchesCwd`.
|
|
391
|
+
* against the ambient context — see `assertRepoMatchesCwd`. A failed call is
|
|
392
|
+
* retried with backoff (forge#4157: it is usually a transient timeout under load).
|
|
263
393
|
* @param {{gh: Function}} io
|
|
264
|
-
* @
|
|
394
|
+
* @param {{sleep?: (ms:number)=>Promise<void>, detail?: boolean}} [opts] - `detail: true` returns
|
|
395
|
+
* `{repo, failed}` so a caller can tell "every attempt errored" from an answer.
|
|
396
|
+
* @returns {Promise<string|null|{repo:string|null, failed:boolean}>} "owner/repo", or null if it can't be determined
|
|
265
397
|
*/
|
|
266
|
-
async function resolveDefaultRepo(io) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
398
|
+
async function resolveDefaultRepo(io, { sleep = realSleep, detail = false } = {}) {
|
|
399
|
+
let failed = false;
|
|
400
|
+
for (let attempt = 0; ; attempt++) {
|
|
401
|
+
try {
|
|
402
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
403
|
+
const repo = String(out).trim() || null;
|
|
404
|
+
return detail ? { repo, failed: false } : repo;
|
|
405
|
+
} catch {
|
|
406
|
+
failed = true;
|
|
407
|
+
if (attempt >= REPO_VIEW_BACKOFF_MS.length) break;
|
|
408
|
+
await sleep(REPO_VIEW_BACKOFF_MS[attempt]);
|
|
409
|
+
}
|
|
273
410
|
}
|
|
411
|
+
return detail ? { repo: null, failed } : null;
|
|
274
412
|
}
|
|
275
413
|
|
|
276
414
|
/**
|
|
@@ -298,16 +436,20 @@ export async function assertLaneNotDefault(io, lane, repo = null, cwd = process.
|
|
|
298
436
|
* default, refuse to run at all rather than silently mixing repos.
|
|
299
437
|
* @param {{gh: Function}} io
|
|
300
438
|
* @param {string|null} repo
|
|
439
|
+
* @param {{sleep?: Function}} [opts] - backoff seam for the `gh repo view` retries
|
|
301
440
|
* @returns {Promise<void>}
|
|
302
441
|
*/
|
|
303
|
-
async function assertRepoMatchesCwd(io, repo) {
|
|
442
|
+
async function assertRepoMatchesCwd(io, repo, { sleep } = {}) {
|
|
304
443
|
if (!repo) return;
|
|
305
|
-
const defaultRepo = await resolveDefaultRepo(io);
|
|
444
|
+
const { repo: defaultRepo, failed } = await resolveDefaultRepo(io, { sleep, detail: true });
|
|
306
445
|
if (defaultRepo === null) {
|
|
307
|
-
|
|
446
|
+
const err = new Error(
|
|
308
447
|
`--repo ${repo} was given, but the current repo could not be determined (\`gh repo view\` failed) to ` +
|
|
309
448
|
`verify it matches. Refusing to run cross-repo without verification.`
|
|
310
449
|
);
|
|
450
|
+
// forge#4157: `gh` kept failing after retries; transient, so run-issue defers instead of erroring.
|
|
451
|
+
if (failed) err.code = OUTCOME_CODES.GH_UNAVAILABLE;
|
|
452
|
+
throw err;
|
|
311
453
|
}
|
|
312
454
|
if (defaultRepo !== repo) {
|
|
313
455
|
throw new Error(
|
|
@@ -481,7 +623,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
481
623
|
const backend = flag(argv, "--backend");
|
|
482
624
|
const model = flag(argv, "--model");
|
|
483
625
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
484
|
-
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file"].includes(argv[i - 1]));
|
|
626
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason"].includes(argv[i - 1]));
|
|
485
627
|
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
486
628
|
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
487
629
|
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
@@ -495,7 +637,21 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
495
637
|
// The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
|
|
496
638
|
if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
|
|
497
639
|
const io = deps.io ?? makeIo();
|
|
498
|
-
|
|
640
|
+
const resultFile = flag(argv, "--result-file");
|
|
641
|
+
// #4256: where the dispatcher placed this lane (hybrid execution), recorded in the lane's FORGE:STATE.
|
|
642
|
+
const placementWhere = flag(argv, "--placement");
|
|
643
|
+
const placement = placementWhere === "local" || placementWhere === "remote"
|
|
644
|
+
? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
|
|
645
|
+
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
646
|
+
try {
|
|
647
|
+
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
648
|
+
} catch (e) {
|
|
649
|
+
if (e?.code !== OUTCOME_CODES.GH_UNAVAILABLE) throw e;
|
|
650
|
+
// forge#4157: a scheduling fact, not an engine error; the batch requeues it and the breaker never sees it.
|
|
651
|
+
const res = report({ terminalReason: "deferred", code: OUTCOME_CODES.GH_UNAVAILABLE, detail: e.message, retryInSeconds: GH_UNAVAILABLE_RETRY_S });
|
|
652
|
+
console.log(`issue #${issue} → deferred (${res.detail})`);
|
|
653
|
+
return res;
|
|
654
|
+
}
|
|
499
655
|
await assertLaneNotDefault(io, lane, repo);
|
|
500
656
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
501
657
|
const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
|
|
@@ -529,14 +685,31 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
529
685
|
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
530
686
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
531
687
|
// #4121: a batch lane reports its result through --result-file (written atomically, also for a thrown run).
|
|
532
|
-
|
|
533
|
-
|
|
688
|
+
// #4179: SIGTERM/SIGINT run the lane's teardown (ledger, checkpoint, worktree, lease), report deferred run.interrupted, exit 128+signo.
|
|
689
|
+
let teardown = null, interrupted = false;
|
|
690
|
+
const on = deps.processOn ?? ((s, f) => process.on(s, f)), off = deps.processOff ?? ((s, f) => process.removeListener(s, f));
|
|
691
|
+
const exit = deps.exit ?? ((c) => process.exit(c));
|
|
692
|
+
const SIGNO = { SIGINT: 2, SIGTERM: 15 };
|
|
693
|
+
const handlers = Object.fromEntries(Object.keys(SIGNO).map((sig) => [sig, async () => {
|
|
694
|
+
if (interrupted) return;
|
|
695
|
+
interrupted = true;
|
|
696
|
+
let r = null;
|
|
697
|
+
try { r = teardown ? await teardown(sig) : null; } catch { r = null; }
|
|
698
|
+
try { report({ ...(r ?? {}), terminalReason: "deferred", code: OUTCOME_CODES.RUN_INTERRUPTED, detail: r?.detail ?? `interrupted by ${sig}` }); } catch { /* exit anyway */ }
|
|
699
|
+
console.log(`issue #${issue} → deferred (interrupted by ${sig})`);
|
|
700
|
+
exit(128 + SIGNO[sig]);
|
|
701
|
+
}]));
|
|
702
|
+
for (const [sig, f] of Object.entries(handlers)) on(sig, f);
|
|
703
|
+
const env = deps.env ?? process.env;
|
|
534
704
|
let lres;
|
|
535
705
|
try {
|
|
536
706
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
707
|
+
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}),
|
|
537
708
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
538
|
-
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
539
|
-
} catch (e) { report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
709
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(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") } : {}) });
|
|
710
|
+
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
711
|
+
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
712
|
+
if (interrupted) return lres; // the signal handler reported and exits
|
|
540
713
|
report(lres);
|
|
541
714
|
if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
|
|
542
715
|
if (lres.terminalReason === "lane-refused") { console.log(`issue #${issue} → lane-refused ${lres.code} (${lres.detail})`); return lres; }
|
|
@@ -629,7 +802,7 @@ export async function resumeStalledFromCli(argv, deps = {}) {
|
|
|
629
802
|
const repo = flag(argv, "--repo");
|
|
630
803
|
|
|
631
804
|
const io = deps.io ?? makeIo();
|
|
632
|
-
await assertRepoMatchesCwd(io, repo);
|
|
805
|
+
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
633
806
|
await assertLaneNotDefault(io, lane, repo);
|
|
634
807
|
const dispatch = deps.dispatch ?? runFromCli;
|
|
635
808
|
const projector = makeProjector(io);
|
|
@@ -716,8 +889,106 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
716
889
|
* `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
|
|
717
890
|
* forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
|
|
718
891
|
* burst, and `--set-parallel <id> K` changes K of a running batch.
|
|
892
|
+
* Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
|
|
893
|
+
* a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
|
|
894
|
+
* unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
|
|
895
|
+
* Host lane registry (#4191): every lane takes a slot in `~/.forge/sched/` (or `FORGEDOCK_SCHED_DIR`), capped host-wide by
|
|
896
|
+
* `forgedock sched cap N` or forge.yaml `orchestration.global_parallel`; each issue's priority comes from its
|
|
897
|
+
* `priority:P<n>` / `P<n>` labels (unlabelled = P2), read in the same gh call as its body.
|
|
898
|
+
* Host scheduler (#4192): the batch runs in the `forgedock schedd` daemon (auto-spawned, `lean/schedd.mjs`), one process
|
|
899
|
+
* for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
|
|
900
|
+
* running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
|
|
901
|
+
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
719
902
|
*/
|
|
720
903
|
export async function orchestrateFromCli(argv, deps = {}) {
|
|
904
|
+
const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
|
|
905
|
+
|| argv.includes("--foreground") || process.env.FORGEDOCK_SCHEDD === "0";
|
|
906
|
+
if (inProcess) return runBatch({ argv: argv.filter((a) => a !== "--foreground"), cwd: process.cwd(), env: process.env, sink: consoleSink(), deps });
|
|
907
|
+
return orchestrateClient(argv, deps);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
911
|
+
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
912
|
+
|
|
913
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
|
|
914
|
+
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
915
|
+
const issuesOf = (argv) => {
|
|
916
|
+
const issues = [];
|
|
917
|
+
for (let i = 0; i < argv.length; i++) {
|
|
918
|
+
if (ORCH_VALUED.includes(argv[i])) { i++; continue; }
|
|
919
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
920
|
+
}
|
|
921
|
+
return issues;
|
|
922
|
+
};
|
|
923
|
+
const parallelOf = (argv) => {
|
|
924
|
+
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
925
|
+
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
926
|
+
return parallel;
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
|
|
931
|
+
* streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
|
|
932
|
+
*/
|
|
933
|
+
async function orchestrateClient(argv, deps = {}) {
|
|
934
|
+
const schedd = await import("./lean/schedd.mjs");
|
|
935
|
+
const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
|
|
936
|
+
const connect = () => schedd.connectDaemon({ dir, ...(deps.spawnDaemon !== undefined ? { spawnDaemon: deps.spawnDaemon } : {}), ...(deps.waitMs ? { waitMs: deps.waitMs } : {}) });
|
|
937
|
+
if (argv.includes("--set-parallel")) {
|
|
938
|
+
const i = argv.indexOf("--set-parallel");
|
|
939
|
+
const id = argv[i + 1], k = Number(argv[i + 2]);
|
|
940
|
+
if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
|
|
941
|
+
let sock = null;
|
|
942
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
943
|
+
if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
|
|
944
|
+
else {
|
|
945
|
+
// No daemon: the batch is not running anywhere; the parallel file is all there is to change.
|
|
946
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
947
|
+
const b = batchMod.openBatch(batchMod.batchRoot(), id);
|
|
948
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${batchMod.batchRoot()}`);
|
|
949
|
+
b.setParallel(k);
|
|
950
|
+
}
|
|
951
|
+
console.log(`batch ${id}: parallel set to ${k}`);
|
|
952
|
+
return { ok: true, batch: id, parallel: k };
|
|
953
|
+
}
|
|
954
|
+
const resumeId = flag(argv, "--resume");
|
|
955
|
+
let msg;
|
|
956
|
+
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
957
|
+
else {
|
|
958
|
+
if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
|
|
959
|
+
parallelOf(argv);
|
|
960
|
+
let sub = [...argv];
|
|
961
|
+
if (!flag(sub, "--repo")) {
|
|
962
|
+
const repo = await resolveDefaultRepo(deps.io ?? makeIo());
|
|
963
|
+
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
964
|
+
sub = [...sub, "--repo", repo];
|
|
965
|
+
}
|
|
966
|
+
msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
|
|
967
|
+
}
|
|
968
|
+
const sock = await connect();
|
|
969
|
+
let id = resumeId;
|
|
970
|
+
try {
|
|
971
|
+
const done = await schedd.request(sock, msg, (m) => {
|
|
972
|
+
if (m.type === "batch") id = m.id;
|
|
973
|
+
else if (m.type === "row") console.log(formatRowLine(m.row));
|
|
974
|
+
else if (m.type === "line") console.log(m.text);
|
|
975
|
+
else if (m.type === "done" && m.replay && m.report) console.log(m.report);
|
|
976
|
+
});
|
|
977
|
+
return done.result;
|
|
978
|
+
} catch (e) {
|
|
979
|
+
if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
|
|
980
|
+
throw e;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* One batch from start to report, in this process: the in-process `orchestrate` path and the schedd daemon's per-batch
|
|
986
|
+
* runner (#4192). `cwd` and `env` are the submitting client's (`env` picks the lanes' account); output goes to `sink`
|
|
987
|
+
* (`batch(b)` once the batch exists, `row(r)` per update, `line(text)` otherwise). A daemon-hosted batch records
|
|
988
|
+
* `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
|
|
989
|
+
* (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
|
|
990
|
+
*/
|
|
991
|
+
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
|
|
721
992
|
const batchMod = await import("./lean/batch.mjs");
|
|
722
993
|
const root = deps.batchRoot ?? batchMod.batchRoot();
|
|
723
994
|
if (argv.includes("--set-parallel")) {
|
|
@@ -726,7 +997,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
726
997
|
if (!b.exists()) throw new Error(`no batch ${argv[i + 1]} in ${root}`);
|
|
727
998
|
const k = Number(argv[i + 2]);
|
|
728
999
|
b.setParallel(k);
|
|
729
|
-
|
|
1000
|
+
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
730
1001
|
return { ok: true, batch: b.id, parallel: k };
|
|
731
1002
|
}
|
|
732
1003
|
const resumeId = flag(argv, "--resume");
|
|
@@ -737,39 +1008,40 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
737
1008
|
if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
|
|
738
1009
|
argv = plan.argv;
|
|
739
1010
|
}
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
748
|
-
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1011
|
+
const issues = issuesOf(argv);
|
|
1012
|
+
if (!issues.length) throw new Error(ORCH_USAGE);
|
|
1013
|
+
const parallel = parallelOf(argv);
|
|
1014
|
+
// The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
|
|
1015
|
+
// A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
|
|
1016
|
+
const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? callerEnv : null);
|
|
1017
|
+
const env = kept ? (await import("./lean/schedd.mjs")).mergeEnv(process.env, kept) : process.env;
|
|
749
1018
|
const lane = flag(argv, "--lane");
|
|
750
1019
|
const laneOverride = argv.includes("--lane-override");
|
|
751
1020
|
const model = flag(argv, "--model");
|
|
752
1021
|
const io = deps.io ?? makeIo();
|
|
753
1022
|
const repo = flag(argv, "--repo") ?? plan?.repo ?? (await resolveDefaultRepo(io));
|
|
754
1023
|
if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
755
|
-
|
|
756
|
-
|
|
1024
|
+
const cwd = plan?.cwd ?? callerCwd;
|
|
1025
|
+
await assertLaneNotDefault(io, lane, repo, cwd);
|
|
757
1026
|
if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
|
|
758
1027
|
if (!batch) {
|
|
759
1028
|
const base = deps.batchId ?? batchMod.newBatchId();
|
|
760
1029
|
batch = batchMod.openBatch(root, base);
|
|
761
1030
|
for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
|
|
762
|
-
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString() };
|
|
1031
|
+
plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
|
|
763
1032
|
batch.writePlan(plan);
|
|
764
1033
|
batch.setParallel(parallel);
|
|
765
1034
|
} else {
|
|
766
1035
|
// A resume is the operator's decision to continue: the breaker starts closed again.
|
|
767
1036
|
const st = batch.store.load();
|
|
768
|
-
if (st) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1037
|
+
if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1038
|
+
if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
|
|
1039
|
+
batch.clearReport();
|
|
769
1040
|
}
|
|
770
|
-
|
|
1041
|
+
sink.batch?.(batch);
|
|
1042
|
+
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
771
1043
|
const dir = deps.dir ?? runDir();
|
|
772
|
-
const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
|
|
1044
|
+
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary } = await import("./lean/orchestrate.mjs");
|
|
773
1045
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
774
1046
|
const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
775
1047
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
@@ -781,40 +1053,280 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
781
1053
|
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 */ } }
|
|
782
1054
|
return r;
|
|
783
1055
|
};
|
|
784
|
-
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes
|
|
785
|
-
|
|
1056
|
+
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
|
|
1057
|
+
// sandboxes when forge.yaml `execution` places them remote (#4253).
|
|
1058
|
+
const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
|
|
1059
|
+
let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
|
|
1060
|
+
const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
|
|
786
1061
|
if (deps.leanModule) {
|
|
787
1062
|
const { runCore } = deps.leanModule;
|
|
788
1063
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
789
|
-
lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
|
|
790
|
-
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
1064
|
+
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1065
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(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) } : {}) }) };
|
|
791
1066
|
} else {
|
|
792
|
-
|
|
793
|
-
|
|
1067
|
+
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1068
|
+
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
1069
|
+
const placementOf = new Map();
|
|
1070
|
+
const laneArgs = (n, ln) => { const pl = placementOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
|
|
1071
|
+
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : [])]; };
|
|
1072
|
+
const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => { if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n); return l.runOne(n, ln, ctx); }, adopt: l.adopt });
|
|
1073
|
+
const execMod = await import("./lean/execution.mjs");
|
|
1074
|
+
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1075
|
+
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
1076
|
+
if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
|
|
1077
|
+
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1078
|
+
let gitDir = null;
|
|
1079
|
+
try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
|
|
1080
|
+
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env });
|
|
1081
|
+
const { makeLedger } = await import("./lean/ledger.mjs");
|
|
1082
|
+
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1083
|
+
const bud = exec.budget ?? {};
|
|
1084
|
+
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
|
|
1085
|
+
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
|
|
1086
|
+
lanes = withPlacement(remoteLanes);
|
|
1087
|
+
if (bud.onBudget === "local" || hybrid) {
|
|
1088
|
+
// #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
|
|
1089
|
+
const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
|
|
1090
|
+
lanes = withPlacement({
|
|
1091
|
+
runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
|
|
1092
|
+
adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
// #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
|
|
1096
|
+
execConf = exec;
|
|
1097
|
+
execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
|
|
1098
|
+
const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1099
|
+
// #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
|
|
1100
|
+
const place = hybrid ? await (async () => {
|
|
1101
|
+
const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
|
|
1102
|
+
let weight = deps.validationWeight ?? null;
|
|
1103
|
+
if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
|
|
1104
|
+
const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
|
|
1105
|
+
return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
|
|
1106
|
+
})() : null;
|
|
1107
|
+
remote = {
|
|
1108
|
+
max: exec.maxRemote, isRemote: () => true, ...(place ? { place, fallbackLocal: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1109
|
+
admit: (u) => execBudget.admit(u),
|
|
1110
|
+
onUsage: (n, sb, rec) => {
|
|
1111
|
+
const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
|
|
1112
|
+
try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
|
|
1113
|
+
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(() => {});
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
|
|
1117
|
+
} else {
|
|
1118
|
+
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1119
|
+
}
|
|
794
1120
|
}
|
|
795
1121
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1122
|
+
// #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
|
|
1123
|
+
const limitsMod = await import("./lean/limits.mjs");
|
|
1124
|
+
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
|
|
1125
|
+
const identity = deps.identity ?? (() => limitsMod.laneBackendId(env));
|
|
1126
|
+
const maint = deps.maintenance !== undefined ? deps.maintenance : readMaintenance(cwd);
|
|
1127
|
+
const schedMod = await import("./lean/sched.mjs");
|
|
1128
|
+
const registry = deps.sched !== undefined ? deps.sched : schedMod.openSched(schedMod.schedDir());
|
|
1129
|
+
const labelNames = async (n) => {
|
|
1130
|
+
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));
|
|
1131
|
+
return labelsOf.get(n);
|
|
1132
|
+
};
|
|
1133
|
+
let budget = {};
|
|
1134
|
+
if (maint) {
|
|
1135
|
+
const mLabels = maint.labels ?? DEFAULT_MAINTENANCE_LABELS;
|
|
1136
|
+
for (const l of mLabels) {
|
|
1137
|
+
let listed = [];
|
|
1138
|
+
try { listed = JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--state", "open", "--label", l, "--json", "number,labels", "--limit", "100"])); } catch { listed = []; }
|
|
1139
|
+
for (const it of Array.isArray(listed) ? listed : []) {
|
|
1140
|
+
const names = (it.labels ?? []).map((x) => x.name);
|
|
1141
|
+
if (!labelsOf.has(it.number)) labelsOf.set(it.number, names);
|
|
1142
|
+
if (!names.includes("needs-human") && !issues.includes(it.number)) issues.push(it.number);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const classify = async (n) => {
|
|
1146
|
+
const names = await labelNames(n);
|
|
1147
|
+
const p = names.map((x) => /^priority:P(\d+)$/i.exec(x)?.[1]).find((x) => x !== undefined);
|
|
1148
|
+
return { maintenance: names.some((x) => mLabels.includes(x)), priority: p === undefined ? 2 : Number(p) };
|
|
1149
|
+
};
|
|
1150
|
+
budget = { classify, maintenance: { share: maint.share ?? 0.2, whenIdle: maint.whenIdle ?? true } };
|
|
1151
|
+
}
|
|
1152
|
+
// forge#4161: milestone drift. Always folds merged milestone lanes into the summary; thresholds and holds only with config.
|
|
1153
|
+
let drift = deps.drift;
|
|
1154
|
+
if (drift === undefined) {
|
|
1155
|
+
const { makeDrift } = await import("./lean/drift.mjs");
|
|
1156
|
+
const { affectedFiles } = await import("./orchestrate-preflight.mjs");
|
|
1157
|
+
drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
|
|
1158
|
+
onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
|
|
1159
|
+
}
|
|
796
1160
|
const crossLane = [];
|
|
1161
|
+
// #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
|
|
1162
|
+
const gcMod = await import("./gc.mjs");
|
|
1163
|
+
const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
|
|
1164
|
+
const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
|
|
1165
|
+
const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
|
|
1166
|
+
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
|
|
1167
|
+
const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
|
|
1168
|
+
const nowMs = deps.now ?? (() => Date.now());
|
|
1169
|
+
let lastGcAt = null;
|
|
1170
|
+
const runGcPhase = async (phase) => {
|
|
1171
|
+
if (!gcRun) return;
|
|
1172
|
+
lastGcAt = nowMs();
|
|
1173
|
+
try {
|
|
1174
|
+
const r = (await gcRun({ phase })) ?? {};
|
|
1175
|
+
const n = Number(r.reclaimed) || 0;
|
|
1176
|
+
reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
|
|
1177
|
+
reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
|
|
1178
|
+
for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
|
|
1179
|
+
} catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1180
|
+
};
|
|
1181
|
+
await runGcPhase("preflight");
|
|
1182
|
+
const GC_ADMISSION_INTERVAL_MS = 5 * 60 * 1000;
|
|
1183
|
+
const beforeAdmit = !gcRun ? null : async () => {
|
|
1184
|
+
if (lastGcAt !== null && nowMs() - lastGcAt < GC_ADMISSION_INTERVAL_MS) return;
|
|
1185
|
+
let near = false;
|
|
1186
|
+
try {
|
|
1187
|
+
const { nearCeiling } = await import("./lean/admission.mjs");
|
|
1188
|
+
const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
|
|
1189
|
+
near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
|
|
1190
|
+
} catch { near = false; }
|
|
1191
|
+
if (near) await runGcPhase("admission");
|
|
1192
|
+
};
|
|
797
1193
|
const rows = await orchestrate({
|
|
1194
|
+
beforeAdmit,
|
|
798
1195
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
|
|
799
|
-
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
|
|
1196
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1197
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1198
|
+
...(remote ? { remote } : {}),
|
|
800
1199
|
onCrossLane: (e) => {
|
|
801
1200
|
crossLane.push(e);
|
|
802
|
-
|
|
1201
|
+
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`);
|
|
803
1202
|
},
|
|
804
|
-
readBody:
|
|
805
|
-
|
|
1203
|
+
readBody: maint || registry || hybrid
|
|
1204
|
+
? 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; }
|
|
1205
|
+
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1206
|
+
onUpdate: (r) => sink.row(r),
|
|
806
1207
|
});
|
|
807
|
-
|
|
1208
|
+
const report = ["\n" + formatTable(rows)];
|
|
808
1209
|
const pfLine = formatPrefilterSummary(rows);
|
|
809
|
-
if (pfLine)
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1210
|
+
if (pfLine) report.push(pfLine);
|
|
1211
|
+
let heavyStats = null; // #4169: live heavy-semaphore queue next to the per-phase waits the lanes reported
|
|
1212
|
+
try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
|
|
1213
|
+
const waitLine = formatWaitSummary(rows, heavyStats);
|
|
1214
|
+
if (waitLine) report.push(waitLine);
|
|
1215
|
+
let day = null;
|
|
1216
|
+
try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
|
|
1217
|
+
const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
|
|
1218
|
+
if (sbxLine) report.push(sbxLine);
|
|
1219
|
+
if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
|
|
1220
|
+
if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
|
|
1221
|
+
report.push(formatReclaimed(reclaimed));
|
|
1222
|
+
for (const l of report) sink.line(l);
|
|
1223
|
+
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1224
|
+
try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1225
|
+
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** The batch report's "reclaimed" line (#4183). */
|
|
1229
|
+
export function formatReclaimed(r) {
|
|
1230
|
+
if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
|
|
1231
|
+
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`;
|
|
1232
|
+
return `reclaimed: ${r.items} item(s), ${size} (preflight ${r.preflight}, admissions ${r.admissions})`;
|
|
813
1233
|
}
|
|
814
1234
|
|
|
815
1235
|
/**
|
|
816
|
-
*
|
|
817
|
-
|
|
1236
|
+
* `forgedock schedd` (foreground; for a systemd-user unit: `ExecStart=forgedock schedd`) · `forgedock schedd status` (#4192)
|
|
1237
|
+
*/
|
|
1238
|
+
export async function scheddFromCli(argv, deps = {}) {
|
|
1239
|
+
const schedd = await import("./lean/schedd.mjs");
|
|
1240
|
+
const dir = deps.scheddDir ?? (await import("./lean/sched.mjs")).schedDir();
|
|
1241
|
+
if (argv[0] === "status") {
|
|
1242
|
+
let sock;
|
|
1243
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
|
|
1244
|
+
const st = await schedd.request(sock, { op: "status" });
|
|
1245
|
+
console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}`);
|
|
1246
|
+
return { running: true, ...st };
|
|
1247
|
+
}
|
|
1248
|
+
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1249
|
+
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1250
|
+
if (!d.running) return d;
|
|
1251
|
+
for (const sig of ["SIGTERM", "SIGINT"]) process.once(sig, () => d.close());
|
|
1252
|
+
await d.closed;
|
|
1253
|
+
return { running: false };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
|
|
1258
|
+
* Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
|
|
1259
|
+
* shared by every batch on the host. `show` lists the active holds (identity, reset time, source; `*` marks the account
|
|
1260
|
+
* this shell's lanes would use); `clear` drops one hold or all of them, so held batches admit on their next pass.
|
|
1261
|
+
*/
|
|
1262
|
+
export async function limitsFromCli(argv, deps = {}) {
|
|
1263
|
+
const limitsMod = await import("./lean/limits.mjs");
|
|
1264
|
+
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile());
|
|
1265
|
+
const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env)))();
|
|
1266
|
+
const usage = "usage: forgedock limits show | forgedock limits clear <id>|--all";
|
|
1267
|
+
const [sub, arg] = argv;
|
|
1268
|
+
if (!sub || sub === "show") {
|
|
1269
|
+
const holds = limits.list();
|
|
1270
|
+
if (!holds.length) console.log(`no usage-limit holds (this account: ${current})`);
|
|
1271
|
+
for (const h of holds) console.log(`${h.id === current ? "*" : " "} ${h.id} until ${new Date(h.until).toISOString()} source ${h.source ?? "-"}`);
|
|
1272
|
+
return { holds, current };
|
|
1273
|
+
}
|
|
1274
|
+
if (sub === "clear") {
|
|
1275
|
+
if (arg === "--all") { const n = limits.clearAll(); console.log(`cleared ${n} usage-limit hold(s)`); return { cleared: n }; }
|
|
1276
|
+
if (!arg || arg.startsWith("--")) throw new Error(usage);
|
|
1277
|
+
if (!limits.clear(arg)) throw new Error(`no active usage-limit hold for ${arg}`);
|
|
1278
|
+
console.log(`cleared usage-limit hold for ${arg}`);
|
|
1279
|
+
return { cleared: 1 };
|
|
1280
|
+
}
|
|
1281
|
+
throw new Error(usage);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
|
|
1286
|
+
* The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
|
|
1287
|
+
* `show` prints the cap, the repo weights, the running slots and the queued issues in grant order; `cap N` sets the
|
|
1288
|
+
* host-wide cap live (the next admission pass of every batch uses it); `cap --clear` falls back to each batch's
|
|
1289
|
+
* `orchestration.global_parallel`. `weight <owner/repo> N` sets that repo's share of slots within a priority (default 1);
|
|
1290
|
+
* `--clear` resets it to 1.
|
|
1291
|
+
*/
|
|
1292
|
+
export async function schedFromCli(argv, deps = {}) {
|
|
1293
|
+
const schedMod = await import("./lean/sched.mjs");
|
|
1294
|
+
const reg = deps.sched ?? schedMod.openSched(schedMod.schedDir());
|
|
1295
|
+
const usage = "usage: forgedock sched show | forgedock sched cap <N>|--clear | forgedock sched weight <owner/repo> <N>|--clear";
|
|
1296
|
+
const [sub, arg, arg2] = argv;
|
|
1297
|
+
if (!sub || sub === "show") {
|
|
1298
|
+
const st = reg.list();
|
|
1299
|
+
const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
|
|
1300
|
+
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
|
|
1301
|
+
const ws = Object.entries(st.weights ?? {});
|
|
1302
|
+
console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
|
|
1303
|
+
for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
|
|
1304
|
+
for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
|
|
1305
|
+
return st;
|
|
1306
|
+
}
|
|
1307
|
+
if (sub === "cap") {
|
|
1308
|
+
if (arg === "--clear") { reg.setCap(null); console.log("host cap cleared"); return { cap: null }; }
|
|
1309
|
+
const k = Number(arg);
|
|
1310
|
+
if (!/^\d+$/.test(String(arg ?? "")) || !Number.isInteger(k) || k < 1) throw new Error(usage);
|
|
1311
|
+
reg.setCap(k);
|
|
1312
|
+
console.log(`host cap set to ${k}`);
|
|
1313
|
+
return { cap: k };
|
|
1314
|
+
}
|
|
1315
|
+
if (sub === "weight") {
|
|
1316
|
+
if (!arg || arg.startsWith("-")) throw new Error(usage);
|
|
1317
|
+
if (arg2 === "--clear") { reg.setWeight(arg, null); console.log(`weight of ${arg} reset to 1`); return { repo: arg, weight: null }; }
|
|
1318
|
+
const n = Number(arg2);
|
|
1319
|
+
if (!/^\d+$/.test(String(arg2 ?? "")) || !Number.isInteger(n) || n < 1) throw new Error(usage);
|
|
1320
|
+
reg.setWeight(arg, n);
|
|
1321
|
+
console.log(`weight of ${arg} set to ${n}`);
|
|
1322
|
+
return { repo: arg, weight: n };
|
|
1323
|
+
}
|
|
1324
|
+
throw new Error(usage);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
1329
|
+
* `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
|
|
818
1330
|
* `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
|
|
819
1331
|
*/
|
|
820
1332
|
export function readOrchestration(cwd = process.cwd()) {
|
|
@@ -822,7 +1334,57 @@ export function readOrchestration(cwd = process.cwd()) {
|
|
|
822
1334
|
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
|
|
823
1335
|
const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
|
|
824
1336
|
const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
|
|
825
|
-
|
|
1337
|
+
const gp = int(o.global_parallel, null);
|
|
1338
|
+
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* forge.yaml `orchestrate.drift` (#4161): `{pairGrowth, packageLines, fileLines, blockDispatch, generatedGlobs}` with
|
|
1343
|
+
* only the positive-integer thresholds set, or null when no threshold and no `block_dispatch: true` is set (summary
|
|
1344
|
+
* only: no script run, no issue, no hold). The parser flattens `orchestrate.drift.*` to `orchestrate.*`.
|
|
1345
|
+
*/
|
|
1346
|
+
export function readDrift(cwd = process.cwd()) {
|
|
1347
|
+
let o = null;
|
|
1348
|
+
try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestrate ?? null; } catch { o = null; }
|
|
1349
|
+
if (!o || typeof o !== "object") return null;
|
|
1350
|
+
const int = (v) => { const n = Number(v); return v !== undefined && String(v).trim() !== "" && Number.isInteger(n) && n > 0 ? n : null; };
|
|
1351
|
+
const out = { pairGrowth: int(o.pair_growth), packageLines: int(o.package_lines), fileLines: int(o.file_lines),
|
|
1352
|
+
blockDispatch: String(o.block_dispatch ?? "").trim() === "true",
|
|
1353
|
+
generatedGlobs: String(o.generated_globs ?? "").split(",").map((x) => x.trim()).filter(Boolean) };
|
|
1354
|
+
return out.pairGrowth == null && out.packageLines == null && out.fileLines == null && !out.blockDispatch ? null : out;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* forge.yaml text without the `maintenance.hotspot:` sub-map (forge#4195, read by scripts/hotspots.sh via yq). The
|
|
1359
|
+
* parser flattens one nested level, so its `enabled`/`file_lines` would otherwise overwrite the budget's own keys.
|
|
1360
|
+
*/
|
|
1361
|
+
function withoutHotspotMap(raw) {
|
|
1362
|
+
let inMaint = false, sub = -1;
|
|
1363
|
+
return String(raw).split(/\r?\n/).filter((l) => {
|
|
1364
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(l)) { inMaint = /^maintenance:\s*(?:#.*)?$/.test(l); sub = -1; return true; }
|
|
1365
|
+
const indent = /^\s*/.exec(l)[0].length;
|
|
1366
|
+
if (sub >= 0) { if (/^\s*(?:#.*)?$/.test(l) || indent > sub) return false; sub = -1; }
|
|
1367
|
+
if (inMaint && /^\s+hotspot:\s*(?:#.*)?$/.test(l)) { sub = indent; return false; }
|
|
1368
|
+
return true;
|
|
1369
|
+
}).join("\n");
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export const DEFAULT_MAINTENANCE_LABELS = ["maintenance", "refactor:split", "dead-code"];
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* forge.yaml `maintenance:` (#4197): null unless `enabled: true`. `share` in (0,1] (default 0.2), `when_idle` (default
|
|
1376
|
+
* true), `labels` comma-separated (default maintenance,refactor:split,dead-code), `scan` (default weekly), and the
|
|
1377
|
+
* maintenance-scan thresholds `file_lines` (2000) / `doc_kb` (64). Block form only: the parser does not read `{ ... }`.
|
|
1378
|
+
*/
|
|
1379
|
+
export function readMaintenance(cwd = process.cwd()) {
|
|
1380
|
+
let m = null;
|
|
1381
|
+
try { m = parseForgeYaml(withoutHotspotMap(readFileSync(join(cwd, "forge.yaml"), "utf8")))?.maintenance ?? null; } catch { m = null; }
|
|
1382
|
+
if (!m || typeof m !== "object" || String(m.enabled ?? "").trim() !== "true") return null;
|
|
1383
|
+
const num = (v, d, ok) => { const n = Number(v); return v !== undefined && v !== "" && ok(n) ? n : d; };
|
|
1384
|
+
const labels = String(m.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
1385
|
+
return { share: num(m.share, 0.2, (n) => n > 0 && n <= 1), whenIdle: String(m.when_idle ?? "true").trim() !== "false",
|
|
1386
|
+
labels: labels.length ? labels : [...DEFAULT_MAINTENANCE_LABELS], scan: String(m.scan ?? "weekly").trim() || "weekly",
|
|
1387
|
+
fileLines: num(m.file_lines, 2000, (n) => Number.isInteger(n) && n > 0), docKb: num(m.doc_kb, 64, (n) => n > 0) };
|
|
826
1388
|
}
|
|
827
1389
|
|
|
828
1390
|
/** `--prefilter-model M`, else forge.yaml `engine.lean_prefilter_model`, else the pre-filter default. */
|