forgedock 1.50.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.
@@ -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
  /**
@@ -1675,7 +1676,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1675
1676
  }
1676
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`);
1677
1678
  unmet = unmet.filter((x) => x.kind !== "commit");
1678
- 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
+ }
1679
1684
  }
1680
1685
  const base = deps.batchId ?? batchMod.newBatchId();
1681
1686
  batch = batchMod.openBatch(root, base);
@@ -1693,6 +1698,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1693
1698
  }
1694
1699
  sink.batch?.(batch);
1695
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));
1696
1705
  const dir = deps.dir ?? runDir();
1697
1706
  migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
1698
1707
  const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
@@ -1836,7 +1845,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1836
1845
  // #4965: --auto-children overrides forge.yaml; it is part of plan.argv, so --resume keeps it
1837
1846
  const autoMod = _autoChildren, yamlAuto = readAutoChildren(cwd);
1838
1847
  const autoChildren = { mode: argv.includes("--auto-children") ? autoMod.parseAutoModeFlag(flag(argv, "--auto-children")) : yamlAuto.mode, pollS: yamlAuto.pollS,
1839
- epics: argv.flatMap((a, i) => (a === "--epic" && /^#?\d+$/.test(argv[i + 1] ?? "") ? [parseInt(argv[i + 1].replace("#", ""), 10)] : [])) };
1848
+ epics: epicsOf(argv) };
1840
1849
  // #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
1841
1850
  const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
1842
1851
  if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
@@ -1957,6 +1966,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1957
1966
  preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
1958
1967
  }
1959
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 */ }
1960
1973
  try {
1961
1974
  rows = await orchestrate({
1962
1975
  ...(estimate ? { estimate } : {}),
@@ -1967,7 +1980,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1967
1980
  autoChildren,
1968
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 }) }) } : {}),
1969
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,
1970
- 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,
1971
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)) } : {}),
1972
1985
  ...(remote ? { remote } : {}),
1973
1986
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
@@ -1984,7 +1997,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1984
1997
  // #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
1985
1998
  issueLabels: labelNames,
1986
1999
  // #4725: a closed (completed) dependency is satisfied even when it still carries a stale needs-human label
1987
- 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
+ },
1988
2006
  // #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
1989
2007
  ...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
1990
2008
  // #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
@@ -2012,7 +2030,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
2012
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
2013
2031
  onUpdate: (r) => sink.row(r),
2014
2032
  });
2015
- } 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(); }
2016
2034
  try { await integration?.settle?.(); } catch { /* best effort: the state block is the record */ }
2017
2035
  const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
2018
2036
  const pfLine = formatPrefilterSummary(rows);
@@ -2055,9 +2073,34 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
2055
2073
  let unfinished = false;
2056
2074
  try { unfinished = batch.pendingIssues().length > 0 && !batch.readCancel(); } catch { unfinished = false; } // a cancel ends the batch for good
2057
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 */ }
2058
2077
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
2059
2078
  }
2060
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
+
2061
2104
  /**
2062
2105
  * #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
2063
2106
  * (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
@@ -2075,7 +2118,7 @@ export function makeDispatchCheck({ gh, repo }) {
2075
2118
  } catch { return null; }
2076
2119
  };
2077
2120
  return async (n) => {
2078
- 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"]);
2079
2122
  const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
2080
2123
  const notPlanned = String(iss?.stateReason ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
2081
2124
  const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
@@ -2086,7 +2129,15 @@ export function makeDispatchCheck({ gh, repo }) {
2086
2129
  if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
2087
2130
  }
2088
2131
  if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
2089
- 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
+ }
2090
2141
  // #4920: a live lease held by another lane, read from GitHub (any batch, any host, across restarts): no lane, no sandbox.
2091
2142
  // Release review #4931: never the open feat/issue-N-lean PR here. That branch is this issue's own lane, which a
2092
2143
  // relaunch or recovery lane resumes by design; a lane still running on it holds the lease.
@@ -2430,6 +2481,7 @@ export function readAutoChildren(cwd = process.cwd()) {
2430
2481
  * `mechanical_max_attempts`): the separate budget of env.*, base.conflict and conflicting-PR rows, default 2, never below `max_attempts`.
2431
2482
  * `orchestration.base_sync.enabled` (#4525; default true, false opts out) and `base_sync.union_globs` (comma list of globs of
2432
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.
2433
2485
  * `orchestration.blocked_label` (#4773; default `blocked`, empty turns it off): an open issue carrying it is admitted but not dispatched.
2434
2486
  */
2435
2487
  export function readOrchestration(cwd = process.cwd()) {
@@ -2439,13 +2491,36 @@ export function readOrchestration(cwd = process.cwd()) {
2439
2491
  const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
2440
2492
  const gp = int(o.global_parallel, null);
2441
2493
  const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
2442
- 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,
2443
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
2444
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_*`).
2445
2497
  baseSync: { enabled: String(o.base_sync_enabled ?? o.enabled ?? "true").trim().toLowerCase() !== "false",
2446
2498
  unionGlobs: String(o.base_sync_union_globs ?? o.union_globs ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean) } };
2447
2499
  }
2448
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
+
2449
2524
  /**
2450
2525
  * forge.yaml `orchestrate.drift` (#4161): `{pairGrowth, packageLines, fileLines, blockDispatch, generatedGlobs}` with
2451
2526
  * only the positive-integer thresholds set, or null when no threshold and no `block_dispatch: true` is set (summary
@@ -2580,6 +2655,12 @@ export async function milestoneSyncFromCli(argv, deps = {}) {
2580
2655
  return run(argv, deps);
2581
2656
  }
2582
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
+
2583
2664
  /**
2584
2665
  * `forgedock milestone train` (#4658): lean/train.mjs over `orchestrateFromCli` (one batch per milestone) and the #4657 lifecycle.
2585
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,6 +3387,7 @@ 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"],
@@ -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");
@@ -0,0 +1,219 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Early batch health (#5167). A batch that fails in its first minutes (roots engine-error, the rest skipped) or is refused
5
+ * at launch used to look "launched" until someone read its log. The batch runner (`runBatch`, the schedd per-batch runner)
6
+ * evaluates the batch's `state.json` at T+15 and T+60 min after `plan.created` and once when the batch ends; the first
7
+ * unhealthy evaluation emits one `BATCH UNHEALTHY` alert (a runner line, a GitHub comment on `orchestrate.health.issue`
8
+ * or the batch's `--epic` issues, and `notify.command`) with its causes and recovery commands. `health.json` in the batch
9
+ * dir records the checkpoints done and the alert, so a restarted daemon re-arms only the remaining checks and never alerts
10
+ * twice. A refused launch has no batch dir: it is recorded in `<batch root>/refusals.json`. `forgedock status` reads both.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { spawn as spawnProc } from "node:child_process";
14
+ import { join } from "node:path";
15
+ import { listBatches, openBatch, REQUEUABLE, writeAtomic } from "./batch.mjs";
16
+
17
+ export const DEFAULT_HEALTH = Object.freeze({ rootFailed: true, terminalShare: 0.5, stalled: true, checksMin: Object.freeze([15, 60]), issue: null, notifyCommand: null });
18
+ /** Outcomes that end a row well: never counted against the batch. */
19
+ export const GOOD_END = new Set(["merged", "invalid", "decomposed"]);
20
+ export const REFUSALS_FILE = "refusals.json";
21
+ export const REFUSAL_KEEP = 20;
22
+ export const REFUSAL_SHOW_MS = 24 * 3600 * 1000;
23
+ export const NOTIFY_TIMEOUT_MS = 30 * 1000;
24
+
25
+ const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
26
+ const list = (ns) => ns.map((n) => `#${n}`).join(", ");
27
+
28
+ /** forge.yaml (parsed) → health config. The parser flattens `orchestrate.health.*` to `orchestrate.*`. */
29
+ export function parseHealthConfig(yaml) {
30
+ const o = yaml?.orchestrate && typeof yaml.orchestrate === "object" ? yaml.orchestrate : {};
31
+ const bool = (v, d) => (v === undefined || String(v).trim() === "" ? d : String(v).trim().toLowerCase() !== "false");
32
+ const share = Number(o.terminal_share);
33
+ const issue = Number(String(o.issue ?? "").replace(/^#/, ""));
34
+ const checks = String(o.checks_min ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => Number(x.trim())).filter((n) => Number.isFinite(n) && n > 0);
35
+ const cmd = String(yaml?.notify?.command ?? "").trim();
36
+ return { rootFailed: bool(o.root_failed, DEFAULT_HEALTH.rootFailed), terminalShare: o.terminal_share !== undefined && Number.isFinite(share) && share >= 0 && share <= 1 ? share : DEFAULT_HEALTH.terminalShare,
37
+ stalled: bool(o.stalled, DEFAULT_HEALTH.stalled), checksMin: checks.length ? checks : [...DEFAULT_HEALTH.checksMin], issue: Number.isSafeInteger(issue) && issue > 0 ? issue : null, notifyCommand: cmd || null };
38
+ }
39
+
40
+ /** Issues of the batch with no dependency edge to another issue of the batch. */
41
+ export function rootsOf(state) {
42
+ const order = (state?.order ?? []).map(Number), inBatch = new Set(order);
43
+ return order.filter((n) => !(state?.deps?.[n] ?? []).some((d) => inBatch.has(Number(d)) && Number(d) !== n));
44
+ }
45
+
46
+ /**
47
+ * `{healthy, causes:[{code, issues, detail}], recovery:[cmd]}` for one batch state. `held`: operator-held issues (not a
48
+ * stall); `refused`: a refusal record (the launch never started). Pure.
49
+ */
50
+ export function evaluateHealth(state, { held = [], refused = null, config = DEFAULT_HEALTH, batchId = "<id>", repo = null } = {}) {
51
+ const causes = [], recovery = [];
52
+ if (refused) {
53
+ causes.push({ code: "refused", issues: refused.issues ?? [], detail: refused.message ?? "launch refused" });
54
+ const relaunch = `forgedock orchestrate ${(refused.issues ?? []).join(" ")}${repo ? ` --repo ${repo}` : ""}`;
55
+ recovery.push(`merge the unmet dependencies, then: ${relaunch}`, `${relaunch} --force "<why the unmet dependencies are acceptable>"`);
56
+ return { healthy: false, causes, recovery };
57
+ }
58
+ const order = (state?.order ?? []).map(Number);
59
+ const running = new Set(Object.keys(state?.running ?? {}).map(Number));
60
+ const pending = new Set((state?.pending ?? []).map(Number));
61
+ const results = state?.results ?? {};
62
+ const outcomeOf = (n) => results[n]?.outcome;
63
+ // Terminal and not good: a final row not running and not waiting again (a held row stays pending but counts).
64
+ const bad = (n) => { const o = outcomeOf(n); return o !== undefined && !GOOD_END.has(o) && !running.has(n) && (!pending.has(n) || o === "held"); };
65
+ const merged = order.filter((n) => outcomeOf(n) === "merged").length;
66
+ const failedRoots = rootsOf(state).filter(bad);
67
+ if (config.rootFailed && failedRoots.length) causes.push({ code: "root-failed", issues: failedRoots, detail: `root ${failedRoots.map((n) => `#${n} ${outcomeOf(n)}`).join(", ")}` });
68
+ const badRows = order.filter(bad);
69
+ if (order.length && merged === 0 && badRows.length && badRows.length / order.length >= config.terminalShare) {
70
+ const by = {};
71
+ for (const n of badRows) by[outcomeOf(n)] = (by[outcomeOf(n)] ?? 0) + 1;
72
+ causes.push({ code: "terminal-share", issues: badRows, detail: `${badRows.length}/${order.length} rows ended non-merged (${Object.entries(by).map(([o, k]) => `${k} ${o}`).join(", ")}), 0 merged` });
73
+ }
74
+ const heldSet = new Set(held.map(Number));
75
+ const waiting = [...pending].filter((n) => !heldSet.has(n));
76
+ if (config.stalled && !running.size && waiting.length && state?.phase !== "setup" && !state?.cancelled) {
77
+ causes.push({ code: "stalled", issues: waiting, detail: `0 lanes running while ${waiting.length} issue${waiting.length === 1 ? "" : "s"} pending${state?.breaker ? " (breaker tripped)" : ""}` });
78
+ }
79
+ if (!causes.length) return { healthy: true, causes, recovery };
80
+ const failed = [...new Set(causes.filter((c) => c.code !== "stalled").flatMap((c) => c.issues))];
81
+ const requeue = failed.filter((n) => REQUEUABLE(outcomeOf(n))), relaunch = failed.filter((n) => !REQUEUABLE(outcomeOf(n)));
82
+ if (requeue.length) recovery.push(`forgedock orchestrate --requeue ${batchId} ${requeue.join(" ")} --reason "batch unhealthy"`);
83
+ if (relaunch.length) recovery.push(`forgedock orchestrate ${relaunch.join(" ")}${repo ? ` --repo ${repo}` : ""} # after fixing the cause (see lanes/<n>.log)`);
84
+ if (pending.size) recovery.push(`forgedock orchestrate --resume ${batchId}`);
85
+ recovery.push(`forgedock status --batch ${batchId}`, `forgedock orchestrate --cancel ${batchId}`);
86
+ return { healthy: false, causes, recovery };
87
+ }
88
+
89
+ /** The `BATCH UNHEALTHY` text: top causes, then the exact recovery commands. */
90
+ export function formatAlert({ batchId, checkpoint, health }) {
91
+ return [`BATCH UNHEALTHY ${batchId} (${checkpoint})`, ...health.causes.slice(0, 5).map((c) => ` cause: ${c.code}: ${c.detail}${c.issues?.length && c.code !== "refused" ? ` [${list(c.issues.slice(0, 12))}${c.issues.length > 12 ? ", …" : ""}]` : ""}`),
92
+ " recovery:", ...health.recovery.map((r) => ` ${r}`)].join("\n");
93
+ }
94
+
95
+ export const healthPath = (dir) => join(dir, "health.json");
96
+ export const readHealth = (dir) => { const h = readJson(healthPath(dir)); return h && typeof h === "object" && !Array.isArray(h) ? h : null; };
97
+ export const writeHealth = (dir, h) => writeAtomic(healthPath(dir), JSON.stringify(h) + "\n", { private: true });
98
+
99
+ /** The checkpoints `[{label, at}]` (`T+<min>`) for a batch created at `created` (ms). */
100
+ export const checkpointsOf = (created, checksMin = DEFAULT_HEALTH.checksMin) => checksMin.map((m) => ({ label: `T+${m}`, at: created + m * 60000 }));
101
+
102
+ /** `health checks: T+15 14:05Z, T+60 14:50Z; forgedock status --batch <id>` */
103
+ export function scheduleLine(batchId, created, checksMin = DEFAULT_HEALTH.checksMin) {
104
+ const hhmm = (ms) => `${new Date(ms).toISOString().slice(11, 16)}Z`;
105
+ return `health checks: ${checkpointsOf(created, checksMin).map((c) => `${c.label} ${hhmm(c.at)}`).join(", ")}, and at batch end; forgedock status --batch ${batchId}`;
106
+ }
107
+
108
+ /**
109
+ * One checkpoint: evaluate the batch, persist it, and call `alert(text, health)` on the first unhealthy result only.
110
+ * @returns the evaluation, plus `alerted` when this call raised the alert
111
+ */
112
+ export async function runCheck({ batch, checkpoint, config = DEFAULT_HEALTH, repo = null, now = () => Date.now(), alert = async () => {} }) {
113
+ const state = batch.store.load() ?? {};
114
+ let held = [];
115
+ try { held = batch.readHeld?.() ?? []; } catch { held = []; }
116
+ const health = evaluateHealth(state, { held, config, batchId: batch.id, repo });
117
+ const prev = readHealth(batch.dir) ?? {};
118
+ const at = new Date(now()).toISOString();
119
+ const next = { ...prev, checks: { ...(prev.checks ?? {}), [checkpoint]: at }, last: { at, checkpoint, healthy: health.healthy, causes: health.causes, recovery: health.recovery } };
120
+ const fire = !health.healthy && !prev.alertedAt;
121
+ if (fire) Object.assign(next, { alertedAt: at, alert: formatAlert({ batchId: batch.id, checkpoint, health }) });
122
+ try { writeHealth(batch.dir, next); } catch { /* health is advisory */ }
123
+ if (fire) { try { await alert(next.alert, health); } catch { /* every channel is best effort */ } }
124
+ return { ...health, alerted: fire };
125
+ }
126
+
127
+ /**
128
+ * Arm the timed checkpoints not yet done (per `health.json`); past-due ones run on the next tick. Timers are unref'd.
129
+ * @returns `{cancel()}`
130
+ */
131
+ export function scheduleChecks({ batch, created, config = DEFAULT_HEALTH, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout, check }) {
132
+ const done = readHealth(batch.dir)?.checks ?? {};
133
+ const timers = checkpointsOf(created, config.checksMin).filter((c) => !done[c.label]).map((c) => {
134
+ const t = setTimer(() => { Promise.resolve().then(() => check(c.label)).catch(() => {}); }, Math.max(0, c.at - now()));
135
+ t?.unref?.();
136
+ return t;
137
+ });
138
+ return { cancel: () => { for (const t of timers) clearTimer(t); } };
139
+ }
140
+
141
+ /** Run `notify.command` through the shell: alert JSON on stdin, FORGEDOCK_ALERT_* in env. Never throws. */
142
+ export function runNotify(command, payload, { spawn = spawnProc, timeoutMs = NOTIFY_TIMEOUT_MS, env = process.env } = {}) {
143
+ return new Promise((resolve) => {
144
+ let child;
145
+ try {
146
+ child = spawn(command, { shell: true, stdio: ["pipe", "ignore", "ignore"], env: { ...env, FORGEDOCK_ALERT: "BATCH_UNHEALTHY", FORGEDOCK_ALERT_BATCH: String(payload.batch ?? ""), FORGEDOCK_ALERT_TEXT: String(payload.text ?? "") } });
147
+ } catch (e) { return resolve({ ok: false, error: String(e?.message ?? e) }); }
148
+ const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } resolve({ ok: false, error: `timed out after ${Math.round(timeoutMs / 1000)}s` }); }, timeoutMs);
149
+ timer.unref?.();
150
+ child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, error: String(e?.message ?? e) }); });
151
+ child.on("close", (code) => { clearTimeout(timer); resolve(code === 0 ? { ok: true } : { ok: false, error: `exit ${code}` }); });
152
+ try { child.stdin?.on?.("error", () => {}); child.stdin?.end?.(JSON.stringify(payload) + "\n"); } catch { /* the command ignores stdin */ }
153
+ });
154
+ }
155
+
156
+ /**
157
+ * The alert channels: a runner line, a comment on the configured issue (else the batch's epics), and notify.command.
158
+ * Each is best effort.
159
+ */
160
+ export async function deliverAlert({ text, batchId, health, sink = { line: () => {} }, gh = null, repo = null, config = DEFAULT_HEALTH, epics = [], notify = runNotify }) {
161
+ sink.line(text);
162
+ const targets = config.issue ? [config.issue] : [...new Set(epics)];
163
+ if (gh && repo) for (const n of targets) {
164
+ try { await gh(["issue", "comment", String(n), "-R", repo, "--body", `<!-- FORGE:BATCH-HEALTH batch=${batchId} -->\n## BATCH UNHEALTHY\n\`\`\`text\n${text}\n\`\`\``]); } catch (e) { sink.line(`health: comment on #${n} failed (${String(e?.message ?? e).slice(0, 160)})`); }
165
+ }
166
+ if (config.notifyCommand) {
167
+ const r = await notify(config.notifyCommand, { alert: "BATCH_UNHEALTHY", batch: batchId, repo, text, causes: health?.causes ?? [], recovery: health?.recovery ?? [] });
168
+ if (!r?.ok) sink.line(`health: notify.command failed (${r?.error ?? "unknown"})`);
169
+ }
170
+ }
171
+
172
+ /** Record a refused launch under the batch root (no batch dir exists); keeps the newest `REFUSAL_KEEP`. */
173
+ export function recordRefusal(root, rec) {
174
+ const prev = readRefusals(root, { maxAgeMs: Infinity });
175
+ writeAtomic(join(root, REFUSALS_FILE), JSON.stringify([...prev, rec].slice(-REFUSAL_KEEP)) + "\n", { private: true });
176
+ }
177
+
178
+ /** Refusals newer than `maxAgeMs`, oldest first. */
179
+ export function readRefusals(root, { now = Date.now(), maxAgeMs = REFUSAL_SHOW_MS } = {}) {
180
+ const v = readJson(join(root, REFUSALS_FILE));
181
+ return (Array.isArray(v) ? v : []).filter((r) => r && typeof r.at === "string" && now - Date.parse(r.at) <= maxAgeMs);
182
+ }
183
+
184
+ /** Health of one batch for `forgedock status --batch <id>`. */
185
+ export function batchStatusLines(root, id, { now = Date.now() } = {}) {
186
+ const b = openBatch(root, id);
187
+ if (!b.exists()) return [`batch ${id}: not found in ${root}`];
188
+ const h = readHealth(b.dir), plan = b.readPlan() ?? {};
189
+ const created = Date.parse(plan.created ?? "");
190
+ const out = [];
191
+ if (h?.alertedAt) out.push(`batch ${id}: UNHEALTHY since ${h.alertedAt}`, ...String(h.alert ?? "").split("\n").slice(1));
192
+ else if (h?.last) out.push(`batch ${id}: healthy at ${h.last.checkpoint} (${h.last.at})`);
193
+ else out.push(`batch ${id}: no health check yet`);
194
+ if (h?.alertedAt && h.last && h.last.at !== h.alertedAt) out.push(` last check ${h.last.checkpoint} (${h.last.at}): ${h.last.healthy ? "healthy" : h.last.causes.map((c) => c.code).join(", ")}`);
195
+ if (Number.isFinite(created)) {
196
+ const due = checkpointsOf(created, DEFAULT_HEALTH.checksMin).filter((c) => !h?.checks?.[c.label]);
197
+ if (due.length && !b.readReport()) out.push(` next checks: ${due.map((c) => `${c.label} ${new Date(c.at).toISOString().slice(11, 16)}Z${c.at < now ? " (due)" : ""}`).join(", ")}`);
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * `forgedock status` lines: unhealthy batches still unresolved (no report, or alerted within a day) and recent refusals;
204
+ * with `batch`, that one batch's health.
205
+ */
206
+ export function statusLines(root, { now = Date.now(), batch = null } = {}) {
207
+ if (batch) return batchStatusLines(root, batch, { now });
208
+ const out = [];
209
+ for (const id of listBatches(root)) {
210
+ try {
211
+ const b = openBatch(root, id), h = readHealth(b.dir);
212
+ if (!h?.alertedAt || (b.readReport() && now - Date.parse(h.alertedAt) > REFUSAL_SHOW_MS)) continue;
213
+ const causes = (h.last?.healthy === false ? h.last.causes : null) ?? [];
214
+ out.push(` BATCH UNHEALTHY ${id} (since ${h.alertedAt})${causes.length ? `: ${causes.map((c) => c.detail).join("; ")}` : ""} — forgedock status --batch ${id}`);
215
+ } catch { /* an unreadable batch has no line */ }
216
+ }
217
+ for (const r of readRefusals(root, { now })) out.push(` LAUNCH REFUSED ${r.at}: ${String(r.message ?? "").slice(0, 300)}`);
218
+ return out;
219
+ }