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.
- package/bin/engine/outcome-codes.mjs +2 -0
- package/bin/engine-cli.mjs +90 -9
- package/bin/forgedock.mjs +30 -2
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +3 -3
- package/bin/lean/batch.test.mjs +236 -8
- package/bin/lean/conformance.mjs +50 -5
- package/bin/lean/conformance.test.mjs +39 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +11 -6
- package/bin/lean/execution.test.mjs +28 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/lean.mjs +28 -17
- package/bin/lean/lean.test.mjs +74 -13
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +124 -12
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -1
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- 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;
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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)
|
|
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:
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
+
}
|