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.
Files changed (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +601 -42
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. package/templates/bootstrap/technical-spike.md +75 -0
@@ -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
- const waitCiGreen = async (pr, repo, timeoutSec) => {
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 section is absent or unreadable.
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
- console.log(`batch ${b.id}: parallel set to ${k}`);
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 valued = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
799
- const issues = [];
800
- for (let i = 0; i < argv.length; i++) {
801
- if (valued.includes(argv[i])) { i++; continue; }
802
- if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
803
- }
804
- if (!issues.length) throw new Error("usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>");
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
- await assertLaneNotDefault(io, lane, repo);
814
- const cwd = plan?.cwd ?? process.cwd();
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
- console.log(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
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
- let lanes;
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
- lanes = batchMod.detachedLanes({ batch, cwd, script: deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), bin: deps.laneBin, pollMs: deps.pollMs,
851
- laneArgs: (n, ln) => ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : [])] });
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
- console.log(`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`);
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: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
863
- onUpdate: (r) => console.log(r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
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
- console.log("\n" + formatTable(rows));
1259
+ const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
866
1260
  const pfLine = formatPrefilterSummary(rows);
867
- if (pfLine) console.log(pfLine);
868
- if (crossLane.length) console.log(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
869
- if (rows.some((r) => r.outcome === "held")) console.log(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
870
- return { rows, crossLane, batch: batch.id, ok: rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome)) };
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
- * forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off) and
875
- * `orchestration.max_deferrals` (default 3). The parser flattens one nested level, so the breaker keys arrive as
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
- return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3) };
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. */