forgedock 1.36.0 → 1.37.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -4,7 +4,7 @@
4
4
  * `forgedock resume-stalled [--dry-run] [--lane <lane>]` enumerates all in-flight
5
5
  * issues, calls scanStalls, and re-dispatches each stalled issue via run-issue.
6
6
  */
7
- import { execFile } from "node:child_process";
7
+ import { execFile, spawn } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
9
  import { readdirSync, statSync, readFileSync } from "node:fs";
10
10
  import { fileURLToPath } from "node:url";
@@ -16,7 +16,7 @@ import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from ".
16
16
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
17
17
  import { parseForgeYaml } from "./forge-utils.mjs";
18
18
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
19
- import { makeEventEmitter, probeEvent, regressionEvent, experimentEvent } from "./engine/events.mjs";
19
+ import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/events.mjs";
20
20
  import { readExperiment } from "./experiment.mjs";
21
21
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
22
22
  import { parseVerify } from "./lean/verify.mjs";
@@ -141,7 +141,7 @@ export function readEvents(cwd = process.cwd()) {
141
141
  /** The probe sweep's `onResult`: one probe.passed / probe.failed event per recorded pass or fail row. Never throws. */
142
142
  function probeEventSink(repo) {
143
143
  const events = makeEventEmitter({ repo, config: readEvents() });
144
- return async (row) => { for (const p of [probeEvent(row), regressionEvent(row)]) if (p) await events.emitEvent(p.type, p.data, p.ctx); };
144
+ return async (row) => { for (const p of probeSweepEvents(row)) await events.emitEvent(p.type, p.data, p.ctx); };
145
145
  }
146
146
  /** `forgedock stats --experiment <id> --emit-event` (#4632): one experiment.verdict event for a met or missed report; inconclusive emits nothing. Never throws. */
147
147
  export async function experimentEventSink(repo, report) {
@@ -262,6 +262,10 @@ export function readBaseMerge(cwd = process.cwd()) {
262
262
  export function readSequenceRegenerate(cwd = process.cwd()) {
263
263
  try { const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; return String(l.sequence_regenerate ?? l.regenerate ?? "").trim() || undefined; } catch { return undefined; }
264
264
  }
265
+ /** #4664: forge.yaml `followups.default_milestone` (milestone title for deferred follow-ups whose parent's milestone has shipped), or undefined. */
266
+ export function readFollowupsDefaultMilestone(cwd = process.cwd()) {
267
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.default_milestone ?? "").trim() || undefined; } catch { return undefined; }
268
+ }
265
269
  /** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
266
270
  export function readPreconditionCmd(cwd = process.cwd()) {
267
271
  try {
@@ -276,6 +280,10 @@ export async function readPostDeployMinAge(cwd = process.cwd()) {
276
280
  return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
277
281
  } catch { return null; }
278
282
  }
283
+ /** forge.yaml `milestones.close_on` (#4657): `staging` or `default` (the default). */
284
+ export function readMilestoneCloseOn(cwd = process.cwd()) {
285
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.milestones?.close_on ?? "").trim().toLowerCase() === "staging" ? "staging" : "default"; } catch { return "default"; }
286
+ }
279
287
  /** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
280
288
  export function readExplore(cwd = process.cwd()) {
281
289
  try {
@@ -327,7 +335,7 @@ export function readHeavy(cwd = process.cwd()) {
327
335
  /**
328
336
  * forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
329
337
  * `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
330
- * `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
338
+ * `lane_limits_memory_max`/`lane_limits_cpu_quota`), plus `tasks_max` (#4690, default 1024). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
331
339
  * `engine.cli_busy_minutes` (default 45, #4405): extra silence allowed while the builder has a live heavy step or heavy-queue entry.
332
340
  * #4390: `engine.context_ceiling_tokens` (default 120000 in runCore; 0 = off).
333
341
  * #4392: `engine.turn_budget_s|m|l` (implement turn budget per lane size; defaults 25/60/100, always returned; 0 = off).
@@ -336,17 +344,18 @@ export function readCoreLimits(cwd = process.cwd()) {
336
344
  try {
337
345
  const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8")) ?? {};
338
346
  const pick = (k) => { for (const sec of [y.pipeline, y.engine]) { const v = sec?.[k] ?? sec?.[`lane_limits_${k}`]; if (v !== undefined && v !== "") return String(v).trim(); } return undefined; };
339
- const mem = pick("memory_max"), cpu = pick("cpu_quota");
347
+ const mem = pick("memory_max"), cpu = pick("cpu_quota"), tasks = pick("tasks_max");
340
348
  const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : undefined; };
341
349
  return {
342
- ...(mem || cpu ? { laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}) } } : {}),
350
+ // #4690: lanes get TasksMax by default; MemoryMax is opt-in (a default K-derived cap killed healthy lanes, #4740); `off`/0 disables one limit
351
+ laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}), ...(tasks ? { tasks_max: tasks } : {}) },
343
352
  ...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
344
353
  ...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
345
354
  ...(posNum(y.engine?.cli_busy_minutes) ? { cliBusyMinutes: posNum(y.engine.cli_busy_minutes) } : {}),
346
355
  ...(/^\d+$/.test(String(y.engine?.context_ceiling_tokens ?? "").trim()) ? { contextCeilingTokens: Number(y.engine.context_ceiling_tokens) } : {}),
347
356
  turnBudgets: parseTurnBudgets(y.engine) ?? { ...TURN_BUDGET_DEFAULTS }, // #4392: lanes are budgeted by default
348
357
  };
349
- } catch { return { turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
358
+ } catch { return { laneLimits: {}, turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
350
359
  }
351
360
  /** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
352
361
  export function readMergeWait(cwd = process.cwd()) {
@@ -904,7 +913,7 @@ export async function runFromCli(argv, deps = {}) {
904
913
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
905
914
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
906
915
  emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
907
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
916
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
908
917
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
909
918
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
910
919
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1113,11 +1122,49 @@ export async function orchestrateFromCli(argv, deps = {}) {
1113
1122
  return orchestrateClient(argv, deps);
1114
1123
  }
1115
1124
 
1116
- const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
1125
+ const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
1117
1126
  const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
1118
1127
 
1119
- const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...>";
1120
- const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason"];
1128
+ /** forge.yaml `orchestration.env_passthrough` / `path_exclude` (comma-separated) and `execution.token_env` (#4691): what the pinned env keeps beyond the allowlist. */
1129
+ function readPinnedEnvConfig(cwd) {
1130
+ try {
1131
+ const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"));
1132
+ const list = (v) => String(v ?? "").split(",").map((x) => x.trim()).filter(Boolean);
1133
+ const tok = String(y?.execution?.token_env ?? "").trim();
1134
+ return { passthrough: [...list(y?.orchestration?.env_passthrough), ...(/^[A-Za-z_][A-Za-z0-9_]*$/.test(tok) ? [tok] : [])], pathExclude: list(y?.orchestration?.path_exclude) };
1135
+ } catch { return { passthrough: [], pathExclude: [] }; }
1136
+ }
1137
+
1138
+ /** `--show-env <id>` (secrets redacted), `--set-env <id> KEY=VAL`, `--unset-env <id> KEY` (#4691): edits the batch's pinned env in plan.json; newly launched lanes read it. */
1139
+ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
1140
+ const op = ["--show-env", "--set-env", "--unset-env"].find((f) => argv.includes(f));
1141
+ const i = argv.indexOf(op), id = argv[i + 1], arg = argv[i + 2];
1142
+ if (!id || id.startsWith("--") || (op !== "--show-env" && !arg)) throw new Error(ORCH_USAGE);
1143
+ const batchMod = await import("./lean/batch.mjs"), { redactEnv } = await import("./lean/schedd.mjs");
1144
+ const b = batchMod.openBatch(root, id);
1145
+ const plan = b.readPlan();
1146
+ if (!plan) throw new Error(`no batch ${id} in ${root}`);
1147
+ if (op === "--show-env") {
1148
+ const env = redactEnv(plan.env);
1149
+ print(plan.env ? Object.entries(env).sort(([a], [c]) => (a < c ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join("\n") : `batch ${id}: pins no env (its lanes use the daemon's)`);
1150
+ return { ok: true, batch: id, env };
1151
+ }
1152
+ const env = { ...(plan.env ?? {}) };
1153
+ if (op === "--set-env") {
1154
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(arg);
1155
+ if (!m) throw new Error("--set-env needs KEY=VAL");
1156
+ env[m[1]] = m[2];
1157
+ print(`batch ${id}: set ${m[1]} (applies to newly launched lanes)`);
1158
+ } else {
1159
+ delete env[arg];
1160
+ print(`batch ${id}: unset ${arg} (applies to newly launched lanes)`);
1161
+ }
1162
+ b.writePlan({ ...plan, env });
1163
+ return { ok: true, batch: id };
1164
+ }
1165
+
1166
+ const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
1167
+ const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force"];
1121
1168
  const issuesOf = (argv) => {
1122
1169
  const issues = [];
1123
1170
  for (let i = 0; i < argv.length; i++) {
@@ -1167,10 +1214,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1167
1214
  if (st === "OPEN") open.push(n);
1168
1215
  else if (!skipped.some((x) => x.issue === n)) skipped.push({ issue: n, reason: st ? "closed" : "unreadable" });
1169
1216
  }
1170
- let res = { added: [], skipped: [] };
1171
- if (open.length) res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1172
- const out = { ok: true, batch: id, added: res.added ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
1173
- print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || "(none)"}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1217
+ let res = { added: [], skipped: [], requeued: [] };
1218
+ if (open.length) {
1219
+ res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1220
+ if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
1221
+ }
1222
+ const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
1223
+ print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || (out.requeued.length ? "" : "(none)")}${out.requeued.length ? `${out.added.length ? " " : ""}${out.requeued.map((n) => `requeued #${n}`).join(" ")}` : ""}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1174
1224
  return out;
1175
1225
  }
1176
1226
 
@@ -1292,6 +1342,8 @@ async function orchestrateClient(argv, deps = {}) {
1292
1342
  return sock ? schedd.request(sock, { op, id, issues }) : null;
1293
1343
  };
1294
1344
  const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
1345
+ if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root: await batchRootOf() });
1346
+ if (argv.includes("--clear-breaker")) return runBatch({ argv, deps, sink: consoleSink() });
1295
1347
  if (argv.includes("--cancel")) {
1296
1348
  const id = flag(argv, "--cancel");
1297
1349
  return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
@@ -1330,7 +1382,10 @@ async function orchestrateClient(argv, deps = {}) {
1330
1382
  if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
1331
1383
  sub = [...sub, "--repo", repo];
1332
1384
  }
1333
- msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
1385
+ const pinned = schedd.batchEnv(process.env, readPinnedEnvConfig(process.cwd()));
1386
+ const dropped = schedd.droppedEnv(process.env, pinned);
1387
+ if (dropped.length) console.log(`orchestrate: not pinned into the batch (lanes use the daemon's env or resolve them at lane start): ${dropped.join(", ")}; name them in forge.yaml orchestration.env_passthrough to pin them`);
1388
+ msg = { op: "submit", argv: sub, cwd: process.cwd(), env: pinned };
1334
1389
  }
1335
1390
  let sock = await connect(), req = msg, reattached = false, losses = 0;
1336
1391
  let id = resumeId;
@@ -1373,6 +1428,15 @@ async function orchestrateClient(argv, deps = {}) {
1373
1428
  export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false, signal = null }) {
1374
1429
  const batchMod = await import("./lean/batch.mjs");
1375
1430
  const root = deps.batchRoot ?? batchMod.batchRoot();
1431
+ if (argv.includes("--clear-breaker")) {
1432
+ // #4685: close a tripped breaker without editing state.json; the next --resume (or a running batch's next save) starts closed.
1433
+ const b = batchMod.openBatch(root, flag(argv, "--clear-breaker"));
1434
+ if (!b.exists()) throw new Error(`no batch ${flag(argv, "--clear-breaker")} in ${root}`);
1435
+ const st = b.store.load();
1436
+ if (st) b.store.save({ ...st, breaker: null, errorTimes: [] });
1437
+ sink.line(`batch ${b.id}: ${st?.breaker ? "breaker cleared" : "no breaker was tripped"}`);
1438
+ return { ok: true, batch: b.id, cleared: Boolean(st?.breaker) };
1439
+ }
1376
1440
  if (argv.includes("--set-parallel")) {
1377
1441
  const i = argv.indexOf("--set-parallel");
1378
1442
  const b = batchMod.openBatch(root, argv[i + 1]);
@@ -1382,6 +1446,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1382
1446
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1383
1447
  return { ok: true, batch: b.id, parallel: k };
1384
1448
  }
1449
+ if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root, print: (l) => sink.line(l) });
1385
1450
  if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
1386
1451
  if (argv.includes("--requeue")) {
1387
1452
  const i = argv.indexOf("--requeue");
@@ -1413,8 +1478,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1413
1478
  const parallel = parallelOf(argv);
1414
1479
  // The lanes' env: a resumed daemon batch uses the env its plan kept, over the daemon's own.
1415
1480
  // A pre-#4192 batch resumed by the daemon kept no env: its lanes use the daemon's.
1416
- const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? callerEnv : null);
1417
- const env = kept ? (await import("./lean/schedd.mjs")).mergeEnv(process.env, kept) : process.env;
1481
+ // #4691: a fresh pin is allowlisted and PATH-sanitised again here (idempotent for a client that already did it).
1482
+ const scheddMod = await import("./lean/schedd.mjs");
1483
+ const kept = plan?.env ?? (resumeId ? null : daemon || callerEnv !== process.env ? scheddMod.batchEnv(callerEnv, readPinnedEnvConfig(plan?.cwd ?? callerCwd)) : null);
1484
+ const env = kept ? scheddMod.mergeEnv(process.env, kept) : process.env;
1418
1485
  const lane = flag(argv, "--lane");
1419
1486
  const laneOverride = argv.includes("--lane-override");
1420
1487
  const model = flag(argv, "--model");
@@ -1425,16 +1492,29 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1425
1492
  await assertLaneNotDefault(io, lane, repo, cwd);
1426
1493
  if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
1427
1494
  if (!batch) {
1495
+ // #4659: a milestone start is refused unless every dependency of its issues is merged into its lane, or --force <reason>
1496
+ const force = flag(argv, "--force");
1497
+ if (argv.includes("--force") && (!force || force.startsWith("--"))) throw new Error("--force requires a reason: --force \"<why the unmet dependencies are acceptable>\"");
1498
+ if (!force) {
1499
+ const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
1500
+ const gateBranches = readBranches(cwd);
1501
+ const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1502
+ const unmet = await unmetDependencies({ issues, repo, gh: io.gh, baseOf: async (n) => {
1503
+ const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
1504
+ return r.ok ? r.lane : null;
1505
+ } });
1506
+ if (unmet.length) throw new Error(`parallel milestone start refused: ${unmet.map((u) => `#${u.issue} depends on #${u.dep} (${u.why}; base ${u.base})`).join("; ")}. Merge the dependencies first, or pass --force "<reason>".`);
1507
+ }
1428
1508
  const base = deps.batchId ?? batchMod.newBatchId();
1429
1509
  batch = batchMod.openBatch(root, base);
1430
1510
  for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
1431
- plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(daemon ? { daemon: true, env: kept } : {}) };
1511
+ plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
1432
1512
  batch.writePlan(plan);
1433
1513
  batch.setParallel(parallel);
1434
1514
  } else {
1435
1515
  // A resume is the operator's decision to continue: the breaker starts closed again.
1436
1516
  const st = batch.store.load();
1437
- if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1517
+ if (st && (!keepBreaker || st.breaker?.transport)) batch.store.save({ ...st, breaker: null, errorTimes: [] });
1438
1518
  if (!keepBreaker) batch.clearCancel(); // #4376: the operator's resume continues a cancelled batch
1439
1519
  if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
1440
1520
  batch.clearReport();
@@ -1476,9 +1556,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1476
1556
  const { runCore } = deps.leanModule;
1477
1557
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1478
1558
  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,
1479
- 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) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}) }) };
1559
+ 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) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
1480
1560
  } else {
1481
1561
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1562
+ // #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
1563
+ const laneEnv = { ...env, FORGEDOCK_LANE_PARALLEL: String(parallel) };
1564
+ // #4691: --set-env / --unset-env edit plan.json while the batch runs; each newly launched lane re-reads the pinned env.
1565
+ const laneSpawn = (bin, args, o) => {
1566
+ const p = batch.readPlan();
1567
+ const fresh = p?.env ? { ...scheddMod.mergeEnv(process.env, p.env), FORGEDOCK_LANE_PARALLEL: String(parallel) } : null;
1568
+ return (deps.spawnFn ?? spawn)(bin, args, fresh ? { ...o, env: { ...fresh, FORGEDOCK_BATCH_ID: o.env?.FORGEDOCK_BATCH_ID } } : o);
1569
+ };
1570
+ const effects = await import("./lean/effects.mjs");
1571
+ const uncapped = effects.laneCapNotice(deps.laneCapProbe ?? effects.systemdRunAvailable);
1572
+ if (uncapped) (deps.out ?? ((l) => console.log(l)))(uncapped);
1482
1573
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
1483
1574
  // #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
1484
1575
  const placementOf = new Map(), recoverOf = new Map();
@@ -1503,12 +1594,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1503
1594
  const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
1504
1595
  const bud = exec.budget ?? {};
1505
1596
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1597
+ ...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
1506
1598
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1507
1599
  remoteStop = remoteLanes.stopLane;
1508
1600
  lanes = withPlacement(remoteLanes);
1509
1601
  {
1510
1602
  // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
1511
- const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1603
+ const local = batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn });
1512
1604
  lanes = withPlacement({
1513
1605
  runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
1514
1606
  adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
@@ -1548,7 +1640,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1548
1640
  };
1549
1641
  sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
1550
1642
  } else {
1551
- lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
1643
+ lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn }));
1552
1644
  }
1553
1645
  // #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on unless `--cancel --stop-remote` (#4612).
1554
1646
  const { killLedgerPid } = await import("./lean/effects.mjs");
@@ -1597,6 +1689,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1597
1689
  drift = makeDrift({ repo, cwd, io: leanIo, config: readDrift(cwd), stagingBranch: branches.staging ?? "staging", affectedFiles,
1598
1690
  onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
1599
1691
  }
1692
+ let lifecycle = deps.lifecycle; // forge#4657: milestone lifecycle; closes at `milestones.close_on`
1693
+ if (lifecycle === undefined) {
1694
+ const { makeLifecycle } = await import("./lean/lifecycle.mjs");
1695
+ lifecycle = makeLifecycle({ gh: leanIo.gh, git: (a) => leanIo.git(a, { cwd }), repo, closeOn: readMilestoneCloseOn(cwd), defaultBranch: branches.default ?? "main" });
1696
+ }
1697
+ // forge#4667: milestone integration runs (every N lane merges) and the red-streak admission hold; off without forge.yaml ci.workflow.
1698
+ let integration = deps.integration;
1699
+ if (integration === undefined) {
1700
+ const { makeIntegration } = await import("./lean/integration.mjs");
1701
+ integration = makeIntegration({ repo, io: leanIo, cfg: await readIntegration(cwd), pattern: branches.featurePattern ?? undefined,
1702
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)), onEvent: (e) => console.log(`#${e.issue} → ${e.detail}`) });
1703
+ try { await integration?.prime(); } catch { /* streaks unknown: nothing is held */ }
1704
+ }
1600
1705
  const crossLane = [];
1601
1706
  // #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
1602
1707
  const gcMod = await import("./gc.mjs");
@@ -1658,8 +1763,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1658
1763
  rows = await orchestrate({
1659
1764
  ...(estimate ? { estimate } : {}),
1660
1765
  beforeAdmit,
1661
- issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
1662
- breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1766
+ issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
1767
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1663
1768
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1664
1769
  ...(remote ? { remote } : {}),
1665
1770
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
@@ -1703,6 +1808,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1703
1808
  onUpdate: (r) => sink.row(r),
1704
1809
  });
1705
1810
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
1811
+ try { await integration?.settle?.(); } catch { /* best effort: the state block is the record */ }
1706
1812
  const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
1707
1813
  const pfLine = formatPrefilterSummary(rows);
1708
1814
  if (pfLine) report.push(pfLine);
@@ -1760,8 +1866,9 @@ export function makeDispatchCheck({ gh, repo }) {
1760
1866
  } catch { return null; }
1761
1867
  };
1762
1868
  return async (n) => {
1763
- const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
1869
+ const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason"]);
1764
1870
  const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
1871
+ const notPlanned = String(iss?.stateReason ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
1765
1872
  const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
1766
1873
  const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
1767
1874
  if (pr !== null && state === "OPEN") {
@@ -1770,7 +1877,7 @@ export function makeDispatchCheck({ gh, repo }) {
1770
1877
  if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
1771
1878
  }
1772
1879
  if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
1773
- if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
1880
+ if (state === "CLOSED") return notPlanned ? { outcome: "invalid", detail: "issue closed (not planned)", final: true } : { outcome: "invalid", detail: "issue closed" }; // #4687: final = its dependents stay skipped
1774
1881
  return null;
1775
1882
  };
1776
1883
  }
@@ -1781,7 +1888,7 @@ export function makeDispatchCheck({ gh, repo }) {
1781
1888
  * resume retries); then the capped FORGE:NEEDS_HUMAN comment, whose failure keeps the label → true.
1782
1889
  */
1783
1890
  export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
1784
- return async ({ issue, evidence }) => {
1891
+ return async ({ issue, evidence, parked = null }) => {
1785
1892
  const { stateOf } = await import("./lean/context.mjs");
1786
1893
  try {
1787
1894
  const v = JSON.parse(await gh(["issue", "view", String(issue), "-R", repo, "--json", "state,body"]));
@@ -1789,13 +1896,28 @@ export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
1789
1896
  const lease = stateOf(v?.body)?.lease;
1790
1897
  if (lease && Number(lease.until) > now()) return "skipped";
1791
1898
  } catch { /* unreadable: relabel as before */ }
1792
- try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); } catch { return "failed"; }
1793
- try {
1794
- // #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
1795
- const { capComment } = await import("./lean/render.mjs");
1796
- await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
1797
- } catch { /* best effort: the label is on, the batch row keeps the evidence */ }
1798
- return true;
1899
+ // #4656: a mechanical park carries its code and PR on `parked:<code>` and joins the remediation queue, not the human queue.
1900
+ if (parked?.code) {
1901
+ const { parkedLabel, REMEDIATION_QUEUE_LABEL } = await import("./engine/outcome-codes.mjs");
1902
+ try { await gh(["label", "create", parkedLabel(parked.code), "-R", repo, "--force", "--color", "C5DEF5", "--description", "Mechanical park on a terminal code. Managed by ForgeDock."]); } catch { /* exists, or no rights: the edit says */ }
1903
+ try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `${parkedLabel(parked.code)},${REMEDIATION_QUEUE_LABEL}`, "--remove-label", "needs-human"]); } catch { return "failed"; }
1904
+ try {
1905
+ const { capComment } = await import("./lean/render.mjs");
1906
+ await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:PARKED code=${parked.code}${parked.pr ? ` pr=${parked.pr}` : ""} -->\n## Parked (\`${parked.code}\`)\nCode: \`${parked.code}\`\nPR: ${parked.pr ? `#${parked.pr}` : "none"}\nQueue: \`${REMEDIATION_QUEUE_LABEL}\` (\`/work-on:remediate\`)\n${evidence}`)]);
1907
+ } catch { /* best effort */ }
1908
+ return true;
1909
+ }
1910
+ // #4656: a human-owned wait is `needs-human:external` and its comment names the owner and the unblock condition.
1911
+ {
1912
+ const external = { owner: "", unblock: "" }; // the default non-parked relabel is a human-owned wait
1913
+ const { NEEDS_HUMAN_EXTERNAL_LABEL } = await import("./engine/outcome-codes.mjs");
1914
+ try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `needs-human,${NEEDS_HUMAN_EXTERNAL_LABEL}`]); } catch { return "failed"; }
1915
+ try {
1916
+ const { capComment } = await import("./lean/render.mjs");
1917
+ await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN external=true -->\n## Needs human (external)\nOwner: ${external.owner || "unassigned (name an owner)"}\nUnblock condition: ${external.unblock || "unstated (name the condition that unblocks this issue)"}\n${evidence}`)]);
1918
+ } catch { /* best effort */ }
1919
+ return true;
1920
+ }
1799
1921
  };
1800
1922
  }
1801
1923
 
@@ -1812,7 +1934,18 @@ async function scheddDue() {
1812
1934
  const repo = await resolveDefaultRepo(io);
1813
1935
  if (!repo) return;
1814
1936
  const { sweepDue } = await import("./lean/postdeploy-monitor.mjs");
1815
- await sweepDue({ repo, cwd: process.cwd(), io: (await import("./lean/effects.mjs")).makeLeanIo({ repo }), onResult: probeEventSink(repo) });
1937
+ const leanIo = (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1938
+ await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
1939
+ await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
1940
+ }
1941
+
1942
+ /** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
1943
+ async function sweepParkedFollowups({ repo, io, dryRun = false }) {
1944
+ try {
1945
+ const { sweepParked } = await import("./lean/parked-sweep.mjs");
1946
+ const branches = readBranches();
1947
+ return await sweepParked({ repo, cwd: process.cwd(), io, dryRun, defaultBranch: (await import("./lean/prefilter.mjs")).lazyDefaultBranch({ io, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() });
1948
+ } catch { return []; }
1816
1949
  }
1817
1950
 
1818
1951
  /**
@@ -1829,6 +1962,7 @@ export async function scheddFromCli(argv, deps = {}) {
1829
1962
  return { running: true, ...st };
1830
1963
  }
1831
1964
  if (argv.length) throw new Error("usage: forgedock schedd [status]");
1965
+ process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
1832
1966
  const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
1833
1967
  if (!d.running) return d;
1834
1968
  // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
@@ -1875,6 +2009,41 @@ export async function limitsFromCli(argv, deps = {}) {
1875
2009
  throw new Error(usage);
1876
2010
  }
1877
2011
 
2012
+ /**
2013
+ * `forgedock decisions list [--stale] [--refresh] [--repo OWNER/NAME] [--base REF]` (#4695): the anchored decisions of the
2014
+ * local index (`~/.forge/decisions/`); `--refresh` rebuilds it from the repo's FORGE:DECISION comments first; `--stale`
2015
+ * lists the active ones with an anchor that matches no file on the base ref (default HEAD). Read-only apart from the index.
2016
+ */
2017
+ export async function decisionsFromCli(argv, deps = {}) {
2018
+ const dec = await import("./lean/decisions.mjs");
2019
+ const usage = "usage: forgedock decisions list [--stale] [--refresh] [--repo OWNER/NAME] [--base REF]";
2020
+ const [sub, ...rest] = argv;
2021
+ if (sub !== "list") throw new Error(usage);
2022
+ const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : null; };
2023
+ const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024 })).stdout));
2024
+ const repo = flag("--repo") ?? String(await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])).trim();
2025
+ if (rest.includes("--refresh")) {
2026
+ const jq = '.[] | select(.body | startswith("<!-- FORGE:DECISION {")) | {body, user, author_association, issue_url} | tojson';
2027
+ const lines = String(await run("gh", ["api", "--paginate", `repos/${repo}/issues/comments?per_page=100`, "--jq", jq])).split("\n").filter(Boolean);
2028
+ const { isTrustedIssueComment } = await import("./lean/prefilter.mjs");
2029
+ const recs = [];
2030
+ for (const l of lines) {
2031
+ try {
2032
+ const c = JSON.parse(l);
2033
+ const r = isTrustedIssueComment(c) ? dec.parseDecisionComment(c.body, { issue: Number(/(\d+)$/.exec(c.issue_url ?? "")?.[1]) || null }) : null;
2034
+ if (r) recs.push(r);
2035
+ } catch { /* skip a malformed comment */ }
2036
+ }
2037
+ dec.writeDecisionIndex(repo, recs);
2038
+ }
2039
+ const all = dec.foldDecisions(dec.readDecisionIndex(repo));
2040
+ let rows = all.filter((d) => d.status !== "superseded").map((d) => ({ decision: d, dead: [] }));
2041
+ if (rest.includes("--stale")) rows = dec.staleDecisions(all, String(await run("git", ["ls-tree", "-r", "--name-only", flag("--base") ?? "HEAD"])).split("\n").filter(Boolean));
2042
+ for (const { decision: d, dead } of rows) console.log(`${d.id} #${d.issue ?? "?"} ${d.choice} [${d.anchors.join(", ")}]${dead.length ? ` stale: ${dead.join(", ")}` : ""}${d.supersedes ? ` supersedes ${d.supersedes}` : ""}`);
2043
+ if (!rows.length) console.log(rest.includes("--stale") ? "no stale decisions" : "no active decisions");
2044
+ return { rows };
2045
+ }
2046
+
1878
2047
  /**
1879
2048
  * `forgedock check [--repo DIR] [--base REF] [--json]` (#4364): runs in the lane worktree what the engine runs after the
1880
2049
  * builder: the frozen validate commands (.forge-lean/contract.json) and the change-scoped pre-PR checks, with the plan
@@ -2128,6 +2297,64 @@ export async function triageFromCli(argv, deps = {}) {
2128
2297
  * Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
2129
2298
  * declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
2130
2299
  */
2300
+ /** `forgedock integration run` (#4667): one pass of the milestone integration runs (lean/integration.mjs). Resolves the exit code. */
2301
+ export async function integrationFromCli(argv, deps = {}) {
2302
+ const { runIntegrationCli: run } = await import("./lean/integration.mjs");
2303
+ return run(argv, deps);
2304
+ }
2305
+
2306
+ /** `forgedock milestone-sync <slug> --reason ...` (#4663): on-demand milestone branch sync (lean/milestonesync.mjs). Resolves the exit code. */
2307
+ export async function milestoneSyncFromCli(argv, deps = {}) {
2308
+ const { runMilestoneSyncCli: run } = await import("./lean/milestonesync.mjs");
2309
+ return run(argv, deps);
2310
+ }
2311
+
2312
+ /**
2313
+ * `forgedock milestone train` (#4658): lean/train.mjs over `orchestrateFromCli` (one batch per milestone) and the #4657 lifecycle.
2314
+ * The train reads forge.yaml (`orchestration.global_parallel`, `milestones.close_on`) and never writes it.
2315
+ */
2316
+ export async function trainFromCli(argv, deps = {}) {
2317
+ const { trainCli } = await import("./lean/train.mjs");
2318
+ const batchMod = await import("./lean/batch.mjs");
2319
+ const cwd = process.cwd();
2320
+ const io = deps.io ?? makeIo();
2321
+ const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
2322
+ if (!repo) throw new Error("milestone train needs the repo: pass --repo or run inside a checkout with a GitHub remote");
2323
+ const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
2324
+ const { makeLifecycle } = await import("./lean/lifecycle.mjs");
2325
+ const { readLive } = await import("../scripts/plan-validate-live.mjs");
2326
+ const orchestrate = deps.orchestrate ?? orchestrateFromCli;
2327
+ return trainCli(argv, {
2328
+ root: deps.batchRoot ?? batchMod.batchRoot(), repo, id: deps.id, print: deps.print,
2329
+ globalParallel: readOrchestration(cwd).globalParallel,
2330
+ readLive: deps.readLive ?? (() => readLive({ gh: io.gh, repo })),
2331
+ onBatchEnd: (titles) => (deps.lifecycle ?? makeLifecycle({ gh: leanIo.gh, git: (a) => leanIo.git(a, { cwd }), repo, closeOn: readMilestoneCloseOn(cwd), defaultBranch: readBranches(cwd).default ?? "main" })).onBatchEnd(titles),
2332
+ runMilestone: async (title, { parallel, resume }) => {
2333
+ if (resume) return orchestrate(["--resume", resume, "--repo", repo]);
2334
+ const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "200", "--json", "number"])) || "[]");
2335
+ if (!open.length) return { ok: true, rows: [] };
2336
+ return orchestrate([...open.map((i) => String(i.number)), "--parallel", String(parallel), "--repo", repo]);
2337
+ },
2338
+ });
2339
+ }
2340
+
2341
+ /** `forgedock guards list|backfill` (#4692): fix guards from the FORGE:GUARD comments on GitHub. */
2342
+ export async function guardsFromCli(argv, deps = {}) {
2343
+ const { guardsCli } = await import("./lean/guards.mjs");
2344
+ const io = deps.io ?? makeIo();
2345
+ const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
2346
+ if (!repo) throw new Error("guards needs the repo: pass --repo or run inside a checkout with a GitHub remote");
2347
+ const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
2348
+ return guardsCli(argv.filter((a, i) => a !== "--repo" && argv[i - 1] !== "--repo"), { gh: (a) => leanIo.gh(a), git: (a) => leanIo.git(a, { cwd: process.cwd() }), repo, print: deps.print, root: deps.root });
2349
+ }
2350
+
2351
+ /** forge.yaml `ci` (#4667): the milestone integration config, or null when no `workflow` is set (feature off). */
2352
+ export function readIntegration(cwd = process.cwd()) {
2353
+ let ci = null;
2354
+ try { ci = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.ci ?? null; } catch { ci = null; }
2355
+ return import("./lean/integration.mjs").then((m) => m.parseIntegration(ci));
2356
+ }
2357
+
2131
2358
  export async function probeFromCli(argv, deps = {}) {
2132
2359
  const valued = ["--repo", "--limit"];
2133
2360
  const issues = [];
@@ -2147,7 +2374,11 @@ export async function probeFromCli(argv, deps = {}) {
2147
2374
  const rows = await sweepDue({ repo, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
2148
2375
  for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2149
2376
  console.log(formatDueSummary(rows) || "monitoring: nothing due");
2150
- return { rows };
2377
+ const parkedRows = await sweepParkedFollowups({ repo, io: leanIo, dryRun: argv.includes("--dry-run") }); // #4689
2378
+ for (const r of parkedRows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
2379
+ const { formatParkedSweepSummary } = await import("./lean/parked-sweep.mjs");
2380
+ console.log(formatParkedSweepSummary(parkedRows) || "parked: nothing released");
2381
+ return { rows, parkedRows };
2151
2382
  }
2152
2383
  const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
2153
2384
  const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), onResult: probeEventSink(repo), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
package/bin/engine.mjs CHANGED
@@ -506,12 +506,14 @@ export async function runIssue(opts) {
506
506
  // declaration or an untrusted body proceeds. An unreadable body/dependency fails closed.
507
507
  {
508
508
  let dep = null;
509
- try { dep = await openDependency(issue, io); } catch (e) {
509
+ try { dep = await openDependency(issue, io, { lane }); } catch (e) {
510
510
  dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` };
511
511
  }
512
512
  if (dep?.unreadable) {
513
513
  return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
514
514
  }
515
+ // #4686: an open dependency whose PR is already on this lane is satisfied; name the merge commit.
516
+ for (const x of dep?.satisfied ?? []) emitProgress({ event: "dependency_satisfied", detail: `#${x.issue} merged into ${lane} by PR #${x.pr} (merge commit ${x.sha.slice(0, 12)})` });
515
517
  if (dep?.open?.length) {
516
518
  return {
517
519
  terminalReason: "dependency-open",