forgedock 1.49.0 → 1.51.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 (44) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -0
  2. package/bin/engine-cli.mjs +123 -20
  3. package/bin/forgedock.mjs +31 -3
  4. package/bin/lean/batch-health.mjs +219 -0
  5. package/bin/lean/batch-health.test.mjs +156 -0
  6. package/bin/lean/batch.mjs +12 -3
  7. package/bin/lean/batch.test.mjs +304 -8
  8. package/bin/lean/conformance.mjs +79 -7
  9. package/bin/lean/conformance.test.mjs +63 -1
  10. package/bin/lean/context.mjs +8 -2
  11. package/bin/lean/context.test.mjs +11 -1
  12. package/bin/lean/credentials.mjs +18 -0
  13. package/bin/lean/execution.mjs +23 -9
  14. package/bin/lean/execution.test.mjs +93 -0
  15. package/bin/lean/fixclosereasons.mjs +52 -0
  16. package/bin/lean/fixclosereasons.test.mjs +64 -0
  17. package/bin/lean/heavy.mjs +14 -4
  18. package/bin/lean/heavy.test.mjs +55 -1
  19. package/bin/lean/lean.mjs +93 -36
  20. package/bin/lean/lean.test.mjs +143 -19
  21. package/bin/lean/live-execution.mjs +136 -0
  22. package/bin/lean/live-execution.test.mjs +83 -0
  23. package/bin/lean/orchestrate-blocked.test.mjs +47 -0
  24. package/bin/lean/orchestrate.mjs +135 -17
  25. package/bin/lean/prefilter.mjs +4 -3
  26. package/bin/lean/prefilter.test.mjs +5 -4
  27. package/bin/lean/prompts.mjs +4 -2
  28. package/bin/lean/recovery.test.mjs +5 -5
  29. package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
  30. package/bin/lean/sandbox-remote.mjs +6 -2
  31. package/bin/lean/schedd.mjs +9 -0
  32. package/bin/pool/agent.mjs +3 -1
  33. package/bin/pool/router.mjs +72 -7
  34. package/bin/review-staging.mjs +2 -0
  35. package/bin/runner.mjs +24 -0
  36. package/commands/orchestrate.md +2 -1
  37. package/commands/review-pr-staging.md +2 -2
  38. package/docs/CONFIG.md +39 -1
  39. package/docs/OUTCOME-CODES.md +2 -1
  40. package/package.json +1 -1
  41. package/pool/README.md +2 -0
  42. package/pool/image/cs-cred +8 -1
  43. package/scripts/review-staging-post.sh +70 -25
  44. package/scripts/review-staging-post.test.sh +40 -8
@@ -36,6 +36,7 @@ export const OUTCOME_CODES = Object.freeze({
36
36
  CI_TIMEOUT: "ci.timeout",
37
37
  BASE_CONFLICT: "base.conflict",
38
38
  RATE_LIMITED: "rate_limited",
39
+ BACKEND_AUTH: "backend.auth", // forge#5168: the claude CLI login failed ("Failed to authenticate"); deferred under the account hold, never engine-error, never the breaker
39
40
  HOST_BUSY: "host.busy",
40
41
  GH_UNAVAILABLE: "gh.unavailable", // forge#4157: `gh` unreachable after retries during the --repo identity check; run deferred, nothing posted
41
42
  GH_AUTH: "gh.auth", // forge#4662: gh answered HTTP 401 and one token refresh did not fix it; run deferred, a rerun resumes
@@ -149,6 +150,7 @@ export function codeForRunnerError(err) {
149
150
  if (isDiskQuotaError(err)) return OUTCOME_CODES.INFRA_DISK_QUOTA;
150
151
  if (err.usageLimit === true || (typeof err.resetAtEpochMs === "number" && Number.isFinite(err.resetAtEpochMs)))
151
152
  return OUTCOME_CODES.RATE_LIMITED;
153
+ if (err.authFailure === true) return OUTCOME_CODES.BACKEND_AUTH; // forge#5168
152
154
  switch (err.code) {
153
155
  case "CLI_SPAWN_TRANSIENT":
154
156
  return OUTCOME_CODES.INFRA_CLI_SPAWN_TRANSIENT;
@@ -14,7 +14,7 @@ import { memAvailableMb } from "./lean/heavy.mjs";
14
14
  import { join, resolve as resolvePath } from "node:path";
15
15
  import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
16
16
  import { makeProjector } from "./engine/projector.mjs";
17
- import { isTrustedActor, parseDependsOn } from "./engine/phases.mjs";
17
+ import { decomposedSubissues, isDecomposedRecord, isTrustedActor, parseDependsOn } from "./engine/phases.mjs";
18
18
  import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from "./engine/runlog.mjs";
19
19
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
20
20
  import { parseForgeYaml } from "./forge-utils.mjs";
@@ -34,6 +34,7 @@ import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lea
34
34
  import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
35
35
  import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
36
36
  import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
37
+ import * as batchHealth from "./lean/batch-health.mjs";
37
38
  import * as _autoChildren from "./lean/autochildren.mjs";
38
39
 
39
40
  /**
@@ -1183,6 +1184,7 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1183
1184
  * `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
1184
1185
  * forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
1185
1186
  * burst, and `--set-parallel <id> K` changes K of a running batch.
1187
+ * `--set-max-remote <id> N` and `--set-placement <id> remote|hybrid|local` (#5138) change its execution live (live-execution.mjs).
1186
1188
  * Maintenance budget (#4197), only with forge.yaml `maintenance.enabled: true` (or `deps.maintenance`): open issues carrying
1187
1189
  * a `maintenance.labels` label join the batch, each issue is classified from its labels (maintenance; `priority:P<n>`,
1188
1190
  * unlabelled = P2) and admission reserves `maintenance.share` of lane starts for maintenance (`when_idle` drains the rest).
@@ -1248,7 +1250,7 @@ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
1248
1250
  return { ok: true, batch: id };
1249
1251
  }
1250
1252
 
1251
- const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --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";
1253
+ const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --set-max-remote <id> <N> | --set-placement <id> remote|hybrid|local | --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";
1252
1254
  const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force", "--auto-children", "--epic"];
1253
1255
  const issuesOf = (argv) => {
1254
1256
  const issues = [];
@@ -1463,6 +1465,17 @@ async function orchestrateClient(argv, deps = {}) {
1463
1465
  console.log(`batch ${id}: parallel set to ${k}`);
1464
1466
  return { ok: true, batch: id, parallel: k };
1465
1467
  }
1468
+ if (argv.includes("--set-max-remote") || argv.includes("--set-placement")) {
1469
+ // #5138: through the daemon when one runs, else the batch's `execution` file directly (nothing hosts the batch).
1470
+ const liveExec = await import("./lean/live-execution.mjs");
1471
+ const { id, patch } = liveExec.parseSetExecution(argv);
1472
+ let sock = null, execution = null;
1473
+ try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1474
+ if (sock) execution = (await schedd.request(sock, { op: "set-execution", id, ...patch }, undefined, { timeoutMs: schedd.REQUEST_MS }))?.execution ?? null;
1475
+ else { const batchMod = await import("./lean/batch.mjs"); execution = liveExec.applySetExecution(batchMod.openBatch(batchMod.batchRoot(), id), patch); }
1476
+ console.log(`batch ${id}: ${liveExec.formatOverride(patch)}`);
1477
+ return { ok: true, batch: id, execution };
1478
+ }
1466
1479
  // #4408: --add, and the extra issues of --resume <id> N..., go to the batch (through the daemon when one runs).
1467
1480
  const addVia = (id) => async (issues) => {
1468
1481
  let sock = null;
@@ -1584,6 +1597,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1584
1597
  sink.line(`batch ${b.id}: parallel set to ${k}`);
1585
1598
  return { ok: true, batch: b.id, parallel: k };
1586
1599
  }
1600
+ if (argv.includes("--set-max-remote") || argv.includes("--set-placement")) {
1601
+ // #5138: live max_remote / placement mode of a batch, re-read by its runner on every admission.
1602
+ const liveExec = await import("./lean/live-execution.mjs");
1603
+ const { id, patch } = liveExec.parseSetExecution(argv);
1604
+ const execution = liveExec.applySetExecution(batchMod.openBatch(root, id), patch);
1605
+ sink.line(`batch ${id}: ${liveExec.formatOverride(patch)}`);
1606
+ return { ok: true, batch: id, execution };
1607
+ }
1587
1608
  if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root, print: (l) => sink.line(l) });
1588
1609
  if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, stopRemote: argv.includes("--stop-remote"), print: (l) => sink.line(l) });
1589
1610
  if (argv.includes("--requeue")) {
@@ -1655,7 +1676,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1655
1676
  }
1656
1677
  for (const [n, l] of baseWaits) sink.line(`held: #${n} waits for ${l.map((w) => `#${w.dep} (${w.sha.slice(0, 10)}) on ${w.base}`).join(", ")}; admitted once the commit lands`);
1657
1678
  unmet = unmet.filter((x) => x.kind !== "commit");
1658
- 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>".`);
1679
+ if (unmet.length) {
1680
+ const refusal = 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>".`);
1681
+ await alertRefusal({ root, issues, repo, cwd, argv, message: refusal.message, unmet, sink, gh: gateIo.gh, deps }); // #5167: recorded for `forgedock status`
1682
+ throw refusal;
1683
+ }
1659
1684
  }
1660
1685
  const base = deps.batchId ?? batchMod.newBatchId();
1661
1686
  batch = batchMod.openBatch(root, base);
@@ -1673,6 +1698,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1673
1698
  }
1674
1699
  sink.batch?.(batch);
1675
1700
  sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
1701
+ // #5167: early batch health at T+15/T+60 after plan.created and at batch end; the first unhealthy result alerts once
1702
+ const healthConf = deps.healthConfig ?? readHealthConfig(cwd), healthNow = deps.healthNow ?? (() => Date.now());
1703
+ const healthCreated = Number.isFinite(Date.parse(plan?.created ?? "")) ? Date.parse(plan.created) : healthNow();
1704
+ sink.line(batchHealth.scheduleLine(batch.id, healthCreated, healthConf.checksMin));
1676
1705
  const dir = deps.dir ?? runDir();
1677
1706
  migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
1678
1707
  const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
@@ -1774,13 +1803,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1774
1803
  const relays = Array.isArray(provider.relays) ? provider.relays : [];
1775
1804
  // #5112: the host-busy guard, shared by hybrid placement and the fallback_local capacity fallback.
1776
1805
  const hostHeadroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1777
- const place = !hybrid ? (n) => execMod.placeLane({ exec, needs: needsOf.get(n) ?? [], relays }) : await (async () => {
1778
- const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1779
- let weight = deps.validationWeight ?? null;
1780
- if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
1781
- const headroom = hostHeadroom;
1782
- return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
1783
- })();
1806
+ // #5138: placement and max_remote re-read the batch's live `execution` override (--set-placement / --set-max-remote).
1807
+ const liveExec = await import("./lean/live-execution.mjs");
1808
+ const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1809
+ const computeWeight = () => deps.validationWeight ?? execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] });
1810
+ const weight = liveExec.lazyWeight({ initial: hybrid ? (await Promise.resolve().then(computeWeight).catch(() => null)) ?? { heavy: false, signals: [] } : null, compute: computeWeight });
1811
+ const place = (e, n) => (e.mode !== "hybrid" ? execMod.placeLane({ exec: e, needs: needsOf.get(n) ?? [], relays })
1812
+ : execMod.placeLane({ exec: e, verifyMode, labels: labelsOf.get(n) ?? [], weight: weight.get(), headroom: hostHeadroom(), needs: needsOf.get(n) ?? [], relays }));
1813
+ if (batch.readExecution()?.provider !== true) batch.setExecution({ provider: true });
1784
1814
  // #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
1785
1815
  let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity, capFailed = false; // #5048: capFailed = last probe rejected // #4547: online = agents alive, null unknown
1786
1816
  const capacity = () => {
@@ -1792,18 +1822,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1792
1822
  }
1793
1823
  return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1794
1824
  };
1795
- remote = {
1796
- capacity, max: exec.maxRemote, isRemote: () => true, place, identity: () => holdId, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true, headroom: hostHeadroom, ...(exec.capacityFallbackAfter ? { capacityFallbackAfter: exec.capacityFallbackAfter } : {}) } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1825
+ remote = liveExec.liveRemote({
1826
+ capacity, isRemote: () => true, identity: () => holdId, ...(exec.fallbackLocal ? { offlineFallback: true, headroom: hostHeadroom, ...(exec.capacityFallbackAfter ? { capacityFallbackAfter: exec.capacityFallbackAfter } : {}) } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1797
1827
  admit: (u) => execBudget.admit(u),
1798
1828
  onUsage: (n, sb, rec) => {
1799
1829
  const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
1800
1830
  try { execBudget.record(`${batch.id}#${sb.id ?? n}#${startedAt}`, sb); } catch { /* the batch total still counts it */ }
1801
1831
  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(() => {});
1802
1832
  },
1803
- };
1833
+ }, { exec, readOverride: batch.readExecution, place, onChange: (e) => sink.line(`execution: live placement ${e.mode}, max_remote ${e.maxRemote}`) });
1804
1834
  sink.line(`execution: ${hybrid ? "hybrid" : "remote"} via ${provider.name}, max_remote ${exec.maxRemote}`);
1805
1835
  } else {
1806
1836
  lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env: laneEnv, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs, spawnFn: laneSpawn }));
1837
+ if (batch.readExecution()?.provider === true) batch.setExecution({ provider: false }); // #5138: a resume without a provider refuses live remote/hybrid
1807
1838
  }
1808
1839
  // #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).
1809
1840
  const { killLedgerPid } = await import("./lean/effects.mjs");
@@ -1814,7 +1845,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1814
1845
  // #4965: --auto-children overrides forge.yaml; it is part of plan.argv, so --resume keeps it
1815
1846
  const autoMod = _autoChildren, yamlAuto = readAutoChildren(cwd);
1816
1847
  const autoChildren = { mode: argv.includes("--auto-children") ? autoMod.parseAutoModeFlag(flag(argv, "--auto-children")) : yamlAuto.mode, pollS: yamlAuto.pollS,
1817
- epics: argv.flatMap((a, i) => (a === "--epic" && /^#?\d+$/.test(argv[i + 1] ?? "") ? [parseInt(argv[i + 1].replace("#", ""), 10)] : [])) };
1848
+ epics: epicsOf(argv) };
1818
1849
  // #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
1819
1850
  const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
1820
1851
  if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
@@ -1935,6 +1966,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1935
1966
  preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
1936
1967
  }
1937
1968
  let rows, childFields = null; // #4965: the host gh's accepted `issue list --json` fields, read once from its help
1969
+ const healthCheck = (checkpoint) => batchHealth.runCheck({ batch, checkpoint, config: healthConf, repo, now: healthNow,
1970
+ alert: (text, health) => batchHealth.deliverAlert({ text, batchId: batch.id, health, sink, gh: (a) => leanIo.gh(a), repo, config: healthConf, epics: epicsOf(argv), ...(deps.notify ? { notify: deps.notify } : {}) }) });
1971
+ let healthTimers = { cancel: () => {} };
1972
+ try { healthTimers = batchHealth.scheduleChecks({ batch, created: healthCreated, config: healthConf, now: healthNow, ...(deps.healthTimer ? { setTimer: deps.healthTimer.set, clearTimer: deps.healthTimer.clear } : {}), check: healthCheck }); } catch { /* health is advisory */ }
1938
1973
  try {
1939
1974
  rows = await orchestrate({
1940
1975
  ...(estimate ? { estimate } : {}),
@@ -1945,7 +1980,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1945
1980
  autoChildren,
1946
1981
  ...(autoMod.addsEpicChildren(autoChildren.mode) ? { getChildren: async () => ({ list: await autoMod.readChildList({ gh: (a) => leanIo.gh(a), repo, fields: childFields ??= await autoMod.hostListFields((a) => leanIo.gh(a)) }), elsewhere: autoMod.liveElsewhere({ batchMod, root, self: batch.id, repo }) }) } : {}),
1947
1982
  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, baseWait: baseWaits.size ? baseWaitOf(baseWaits, { io, leanIo, cwd }) : null, ...lanes,
1948
- breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, rotator, batchId: batch.id, drift, lifecycle, integration,
1983
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(Number.isInteger(orch.transientRetries) ? { transientRetries: orch.transientRetries } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, rotator, batchId: batch.id, drift, lifecycle, integration,
1949
1984
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null, { cfg: readAdmission(cwd) }), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1950
1985
  ...(remote ? { remote } : {}),
1951
1986
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
@@ -1962,7 +1997,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1962
1997
  // #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
1963
1998
  issueLabels: labelNames,
1964
1999
  // #4725: a closed (completed) dependency is satisfied even when it still carries a stale needs-human label
1965
- issueState: async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason"])); return v.state === "CLOSED" && String(v.stateReason ?? "COMPLETED").toUpperCase() !== "COMPLETED" ? "CLOSED_NOT_PLANNED" : v.state; },
2000
+ // #5152: a NOT_PLANNED close labelled workflow:merged, or a decomposition whose sub-issues all closed, is delivered: CLOSED
2001
+ issueState: async (n) => {
2002
+ const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason,labels"]));
2003
+ if (v.state !== "CLOSED" || String(v.stateReason ?? "COMPLETED").toUpperCase() === "COMPLETED") return v.state;
2004
+ return ["merged", "moved"].includes(await closedScope({ gh: (a) => leanIo.gh(a), repo, issue: n, labels: v.labels })) ? "CLOSED" : "CLOSED_NOT_PLANNED";
2005
+ },
1966
2006
  // #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
1967
2007
  ...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
1968
2008
  // #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
@@ -1990,7 +2030,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1990
2030
  : async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
1991
2031
  onUpdate: (r) => sink.row(r),
1992
2032
  });
1993
- } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
2033
+ } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); healthTimers.cancel(); }
1994
2034
  try { await integration?.settle?.(); } catch { /* best effort: the state block is the record */ }
1995
2035
  const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
1996
2036
  const pfLine = formatPrefilterSummary(rows);
@@ -2033,9 +2073,34 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
2033
2073
  let unfinished = false;
2034
2074
  try { unfinished = batch.pendingIssues().length > 0 && !batch.readCancel(); } catch { unfinished = false; } // a cancel ends the batch for good
2035
2075
  if (!unfinished) try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
2076
+ try { await healthCheck("end"); } catch { /* #5167: health never fails a batch */ }
2036
2077
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
2037
2078
  }
2038
2079
 
2080
+ /**
2081
+ * #5152: how a CLOSED issue's scope ended, read from its labels rather than its `stateReason` (a NOT_PLANNED close of
2082
+ * delivered work, legacy or not, is never dropped scope): "merged" (`workflow:merged`), "moved" (`workflow:decomposed`
2083
+ * whose trusted FORGE:DECOMPOSED sub-issues are all closed), "moving" (decomposed, a sub-issue open or unreadable), or null
2084
+ * (no delivery label: `stateReason` decides). `labels` is a `gh --json labels` or REST label list. Never throws.
2085
+ */
2086
+ export async function closedScope({ gh, repo, issue, labels }) {
2087
+ const names = (Array.isArray(labels) ? labels : []).map((l) => String(l?.name ?? l));
2088
+ if (names.includes("workflow:merged")) return "merged";
2089
+ if (!isDecomposedRecord({ labels })) return null;
2090
+ // decomposedSubissues reads `repos/{owner}/{repo}/...`: pin the placeholder to this batch's repo
2091
+ const io = { gh: (a) => gh(a.map((x) => String(x).replace("{owner}/{repo}", repo))) };
2092
+ let subs = null;
2093
+ try { subs = await decomposedSubissues(issue, io); } catch { subs = null; }
2094
+ if (!subs) return "moving";
2095
+ for (const s of subs) {
2096
+ try {
2097
+ const v = JSON.parse(String(await gh(["issue", "view", String(s), "-R", repo, "--json", "state"])));
2098
+ if (String(v?.state ?? "").toUpperCase() !== "CLOSED") return "moving";
2099
+ } catch { return "moving"; }
2100
+ }
2101
+ return "moved";
2102
+ }
2103
+
2039
2104
  /**
2040
2105
  * #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
2041
2106
  * (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
@@ -2053,7 +2118,7 @@ export function makeDispatchCheck({ gh, repo }) {
2053
2118
  } catch { return null; }
2054
2119
  };
2055
2120
  return async (n) => {
2056
- const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason"]);
2121
+ const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason,labels"]);
2057
2122
  const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
2058
2123
  const notPlanned = String(iss?.stateReason ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
2059
2124
  const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
@@ -2064,7 +2129,15 @@ export function makeDispatchCheck({ gh, repo }) {
2064
2129
  if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
2065
2130
  }
2066
2131
  if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
2067
- 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
2132
+ if (state === "CLOSED") {
2133
+ // #5152: workflow:merged / a fully closed decomposition is delivered whatever the stateReason; a decomposition still
2134
+ // moving is a completed close (its dependents' lanes wait on the sub-issues); only an unlabelled NOT_PLANNED is dropped scope
2135
+ const scope = await closedScope({ gh, repo, issue: n, labels: iss?.labels });
2136
+ if (scope === "merged") return { outcome: "merged", detail: "issue closed, workflow:merged" };
2137
+ if (scope === "moved") return { outcome: "merged", detail: "issue decomposed; all sub-issues closed" };
2138
+ if (scope === "moving") return { outcome: "invalid", detail: "issue closed: decomposed, sub-issues still open" };
2139
+ return notPlanned ? { outcome: "invalid", detail: "issue closed (not planned)", final: true } : { outcome: "invalid", detail: "issue closed" }; // #4687: final = its dependents stay skipped
2140
+ }
2068
2141
  // #4920: a live lease held by another lane, read from GitHub (any batch, any host, across restarts): no lane, no sandbox.
2069
2142
  // Release review #4931: never the open feat/issue-N-lean PR here. That branch is this issue's own lane, which a
2070
2143
  // relaunch or recovery lane resumes by design; a lane still running on it holds the lease.
@@ -2408,6 +2481,7 @@ export function readAutoChildren(cwd = process.cwd()) {
2408
2481
  * `mechanical_max_attempts`): the separate budget of env.*, base.conflict and conflicting-PR rows, default 2, never below `max_attempts`.
2409
2482
  * `orchestration.base_sync.enabled` (#4525; default true, false opts out) and `base_sync.union_globs` (comma list of globs of
2410
2483
  * append-only files whose two-sided line additions a parked PR's base sync keeps both of).
2484
+ * `orchestration.transient_retries` (#5161; default 4, 0 off): backoff retries of a transient (gateway 5xx) engine-error.
2411
2485
  * `orchestration.blocked_label` (#4773; default `blocked`, empty turns it off): an open issue carrying it is admitted but not dispatched.
2412
2486
  */
2413
2487
  export function readOrchestration(cwd = process.cwd()) {
@@ -2417,13 +2491,36 @@ export function readOrchestration(cwd = process.cwd()) {
2417
2491
  const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
2418
2492
  const gp = int(o.global_parallel, null);
2419
2493
  const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
2420
- return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), blockedLabel: String(o.blocked_label ?? "blocked").trim(), globalParallel: gp >= 1 ? gp : null,
2494
+ return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), transientRetries: int(o.transient_retries, 4), blockedLabel: String(o.blocked_label ?? "blocked").trim(), globalParallel: gp >= 1 ? gp : null,
2421
2495
  recovery: { maxAttempts: recoveryMax, mechanicalMaxAttempts: Math.max(int(o.recovery_mechanical_max_attempts ?? o.mechanical_max_attempts, 2), recoveryMax) }, // #4725: env/base/sequence codes have their own budget
2422
2496
  // #4525: parked-PR base sync. The parser flattens one level, so `base_sync.enabled|union_globs` arrive as `enabled|union_globs` (or flat `base_sync_*`).
2423
2497
  baseSync: { enabled: String(o.base_sync_enabled ?? o.enabled ?? "true").trim().toLowerCase() !== "false",
2424
2498
  unionGlobs: String(o.base_sync_union_globs ?? o.union_globs ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean) } };
2425
2499
  }
2426
2500
 
2501
+ /** `--epic N` operands of an orchestrate argv (#4965, #5167). */
2502
+ const epicsOf = (argv) => argv.flatMap((a, i) => (a === "--epic" && /^#?\d+$/.test(argv[i + 1] ?? "") ? [parseInt(argv[i + 1].replace("#", ""), 10)] : []));
2503
+
2504
+ /**
2505
+ * #5167: a refused milestone start leaves no batch dir: record it under the batch root for `forgedock status` and send
2506
+ * the BATCH UNHEALTHY channels. Best effort: the caller rethrows the refusal unchanged.
2507
+ */
2508
+ async function alertRefusal({ root, issues, repo, cwd, argv, message, unmet, sink, gh, deps }) {
2509
+ try {
2510
+ const rec = { at: new Date((deps.healthNow ?? Date.now)()).toISOString(), issues, repo, cwd, message, unmet: unmet.map(({ issue, dep, base }) => ({ issue, dep, base })) };
2511
+ try { batchHealth.recordRefusal(root, rec); } catch { /* the stderr line still reports it */ }
2512
+ const config = deps.healthConfig ?? readHealthConfig(cwd);
2513
+ const health = batchHealth.evaluateHealth(null, { refused: rec, config, repo });
2514
+ const text = batchHealth.formatAlert({ batchId: "(refused launch)", checkpoint: "launch", health });
2515
+ await batchHealth.deliverAlert({ text, batchId: null, health, sink, gh: gh ? (a) => gh(a) : null, repo, config, epics: epicsOf(argv), ...(deps.notify ? { notify: deps.notify } : {}) });
2516
+ } catch { /* never mask the refusal */ }
2517
+ }
2518
+
2519
+ /** forge.yaml `orchestrate.health.*` (flattened to `orchestrate.*`) and `notify.command` (#5167); defaults when unset. */
2520
+ export function readHealthConfig(cwd = process.cwd()) {
2521
+ try { return batchHealth.parseHealthConfig(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))); } catch { return batchHealth.parseHealthConfig(null); }
2522
+ }
2523
+
2427
2524
  /**
2428
2525
  * forge.yaml `orchestrate.drift` (#4161): `{pairGrowth, packageLines, fileLines, blockDispatch, generatedGlobs}` with
2429
2526
  * only the positive-integer thresholds set, or null when no threshold and no `block_dispatch: true` is set (summary
@@ -2558,6 +2655,12 @@ export async function milestoneSyncFromCli(argv, deps = {}) {
2558
2655
  return run(argv, deps);
2559
2656
  }
2560
2657
 
2658
+ /** `forgedock fix-close-reasons <owner/repo> [--milestone m] [--apply]` (#5177): re-close delivered `not planned` issues as completed (lean/fixclosereasons.mjs). Resolves the exit code. */
2659
+ export async function fixCloseReasonsFromCli(argv, deps = {}) {
2660
+ const { runFixCloseReasonsCli: run } = await import("./lean/fixclosereasons.mjs");
2661
+ return run(argv, deps);
2662
+ }
2663
+
2561
2664
  /**
2562
2665
  * `forgedock milestone train` (#4658): lean/train.mjs over `orchestrateFromCli` (one batch per milestone) and the #4657 lifecycle.
2563
2666
  * The train reads forge.yaml (`orchestration.global_parallel`, `milestones.close_on`) and never writes it.
package/bin/forgedock.mjs CHANGED
@@ -977,6 +977,18 @@ async function gatherDashboardData(cwd) {
977
977
  return result;
978
978
  }
979
979
 
980
+ /**
981
+ * #5167: unhealthy batches and recent launch refusals (or one batch's health with `--batch <id>`) under
982
+ * `forgedock status`, read from the batch root. Any read failure prints nothing.
983
+ */
984
+ async function batchHealthSection(c, batchId) {
985
+ try {
986
+ const [{ batchRoot }, { statusLines }] = await Promise.all([import("./lean/batch.mjs"), import("./lean/batch-health.mjs")]);
987
+ const lines = statusLines(batchRoot(), batchId ? { batch: batchId } : {});
988
+ if (lines.length) c.stdout.write(` batch health\n${lines.map((l) => (batchId ? ` ${l}` : l)).join("\n")}\n\n`);
989
+ } catch { /* status never fails on batch health */ }
990
+ }
991
+
980
992
  /** Compact status screen for configured/managed directories. */
981
993
  async function statusScreen(c) {
982
994
  const state = resolveState(c.cwd);
@@ -3375,13 +3387,14 @@ function help() {
3375
3387
  ["npx forgedock init --minimal", "Generate a minimal forge.yaml (required sections only)"],
3376
3388
  ["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
3377
3389
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
3390
+ ["npx forgedock fix-close-reasons <owner/repo> [--milestone <m>] [--apply]", "Re-close issues that were closed not planned but carry workflow:merged or workflow:decomposed (delivered, not dropped) as completed, with a comment; dry run unless --apply; workflow:invalid issues are never touched"],
3378
3391
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
3379
3392
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3380
3393
  ["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
3381
3394
  ["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3382
3395
  ["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
3383
3396
  ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
3384
- ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
3397
+ ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --set-max-remote <id> N | --set-placement <id> remote|hybrid|local | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, change its max_remote or placement mode live (remote/hybrid need a batch started with a remote provider; running lanes are not stopped), add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
3385
3398
  ["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
3386
3399
  ["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
3387
3400
  ["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
@@ -3980,7 +3993,7 @@ async function watch() {
3980
3993
  const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3981
3994
  const KNOWN_COMMANDS = new Set([
3982
3995
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3983
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "selftest", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
3996
+ "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "fix-close-reasons", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "selftest", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
3984
3997
  "version", "--version", "-v",
3985
3998
  ]);
3986
3999
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4062,8 +4075,12 @@ switch (command) {
4062
4075
  }
4063
4076
  case "status": {
4064
4077
  const c = ctx();
4065
- if (positional[1]) c.cwd = resolve(positional[1]);
4078
+ // #5167: `--batch <id>` is not the status directory
4079
+ const statusArgs = positional.slice(1), bi = statusArgs.indexOf("--batch");
4080
+ const statusDir = statusArgs.find((a, i) => !(bi !== -1 && (i === bi || i === bi + 1)));
4081
+ if (statusDir) c.cwd = resolve(statusDir);
4066
4082
  await statusScreen(c);
4083
+ await batchHealthSection(c, bi === -1 ? null : statusArgs[bi + 1] ?? null);
4067
4084
  break;
4068
4085
  }
4069
4086
  case "uninstall":
@@ -4334,6 +4351,17 @@ switch (command) {
4334
4351
  }
4335
4352
  break;
4336
4353
  }
4354
+ case "fix-close-reasons": {
4355
+ // Migration helper (#5177): delivered issues closed `not planned` are re-closed `completed`; dry run unless --apply.
4356
+ const { fixCloseReasonsFromCli } = await import("./engine-cli.mjs");
4357
+ try {
4358
+ exitCode = await fixCloseReasonsFromCli(restArgs);
4359
+ } catch (err) {
4360
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4361
+ exitCode = 1;
4362
+ }
4363
+ break;
4364
+ }
4337
4365
  case "milestone": {
4338
4366
  // Native unattended milestone train (#4658): `forgedock milestone train <slug...>`.
4339
4367
  const { trainFromCli } = await import("./engine-cli.mjs");