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.
- package/bin/engine/outcome-codes.mjs +2 -0
- package/bin/engine-cli.mjs +123 -20
- package/bin/forgedock.mjs +31 -3
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +12 -3
- package/bin/lean/batch.test.mjs +304 -8
- package/bin/lean/conformance.mjs +79 -7
- package/bin/lean/conformance.test.mjs +63 -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 +23 -9
- package/bin/lean/execution.test.mjs +93 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lean.mjs +93 -36
- package/bin/lean/lean.test.mjs +143 -19
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +135 -17
- 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 +36 -1
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +9 -0
- 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/orchestrate.md +2 -1
- 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
|
/**
|
|
@@ -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)
|
|
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
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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");
|