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