forgedock 1.29.1 → 1.31.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 +5 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +356 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +799 -127
- package/bin/lean/lean.test.mjs +1749 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +77 -10
- package/bin/lean/verify.test.mjs +74 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/engine-cli.mjs
CHANGED
|
@@ -12,14 +12,16 @@ import { homedir, hostname } from "node:os";
|
|
|
12
12
|
import { join, resolve as resolvePath } from "node:path";
|
|
13
13
|
import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
|
|
14
14
|
import { makeProjector } from "./engine/projector.mjs";
|
|
15
|
-
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
|
+
import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from "./engine/runlog.mjs";
|
|
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
19
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
20
|
import { parseVerify } from "./lean/verify.mjs";
|
|
21
21
|
import { parseExecution } from "./lean/execution.mjs";
|
|
22
|
+
import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
|
|
22
23
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
24
|
+
import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
|
|
23
25
|
import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -112,6 +114,13 @@ export function readReviewIncomplete(cwd = process.cwd()) {
|
|
|
112
114
|
return ["needs-human", "degraded"].includes(v) ? v : undefined;
|
|
113
115
|
} catch { return undefined; }
|
|
114
116
|
}
|
|
117
|
+
/** forge.yaml `engine.lane_instructions` (#4395): repo-relative lane instruction file; undefined when absent, empty or unreadable. */
|
|
118
|
+
export function readLaneInstructions(cwd = process.cwd()) {
|
|
119
|
+
try {
|
|
120
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.lane_instructions ?? "").trim();
|
|
121
|
+
return v || undefined;
|
|
122
|
+
} catch { return undefined; }
|
|
123
|
+
}
|
|
115
124
|
const warnedContextPack = new Set();
|
|
116
125
|
const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
|
|
117
126
|
/**
|
|
@@ -129,7 +138,8 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
|
|
|
129
138
|
mode = "";
|
|
130
139
|
}
|
|
131
140
|
const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
|
|
132
|
-
...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {})
|
|
141
|
+
...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {}),
|
|
142
|
+
...(e.context_pack_pitfall_lanes != null ? { pitfallLanes: Number(e.context_pack_pitfall_lanes) } : {}) }; // #4375
|
|
133
143
|
return Object.keys(out).length ? out : undefined;
|
|
134
144
|
} catch { return undefined; }
|
|
135
145
|
}
|
|
@@ -159,6 +169,19 @@ export function readReviewTrigger(cwd = process.cwd()) {
|
|
|
159
169
|
}
|
|
160
170
|
return mode || riskPaths.length ? { mode: mode ?? "shadow", riskPaths } : undefined;
|
|
161
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* forge.yaml `review.verifier` (off|shadow|enforce) and `review.verifier_model`, else `engine.lean_verifier_model` (#4397).
|
|
174
|
+
* Returns {mode, model}; undefined when neither is set or valid (the core defaults to off).
|
|
175
|
+
*/
|
|
176
|
+
export function readVerifier(cwd = process.cwd()) {
|
|
177
|
+
let sec = {};
|
|
178
|
+
try { sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.review ?? {}; } catch { /* absent or unreadable */ }
|
|
179
|
+
const m = String(sec?.verifier ?? "").trim();
|
|
180
|
+
const mode = ["off", "shadow", "enforce"].includes(m) ? m : undefined;
|
|
181
|
+
const v = String(sec?.verifier_model ?? "").trim();
|
|
182
|
+
const model = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("verifier_model", cwd);
|
|
183
|
+
return mode || model ? { mode: mode ?? "off", ...(model ? { model } : {}) } : undefined;
|
|
184
|
+
}
|
|
162
185
|
/** forge.yaml `lean.repro_test` (off|prefer, #4271); undefined when absent or invalid (the core defaults to prefer). */
|
|
163
186
|
export function readReproTest(cwd = process.cwd()) {
|
|
164
187
|
try {
|
|
@@ -166,6 +189,59 @@ export function readReproTest(cwd = process.cwd()) {
|
|
|
166
189
|
return ["off", "prefer"].includes(v) ? v : undefined;
|
|
167
190
|
} catch { return undefined; }
|
|
168
191
|
}
|
|
192
|
+
/** forge.yaml `decomposition.close_parent` (#3736, #4337): true closes a decomposed parent at decomposition; anything else keeps it open. */
|
|
193
|
+
export function readCloseParent(cwd = process.cwd()) {
|
|
194
|
+
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.decomposition?.close_parent ?? "").trim() === "true"; } catch { return false; }
|
|
195
|
+
}
|
|
196
|
+
/** forge.yaml `lean.fix_model` (#4363, else `engine.lean_fix_model`): the model of fresh fix-round sessions; undefined when absent or invalid. */
|
|
197
|
+
export function readFixModel(cwd = process.cwd()) {
|
|
198
|
+
try {
|
|
199
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean?.fix_model ?? "").trim();
|
|
200
|
+
if (/^[A-Za-z0-9._:[\]-]+$/.test(v)) return v;
|
|
201
|
+
} catch { /* fall through to engine.lean_fix_model */ }
|
|
202
|
+
return readLeanSetting("fix_model", cwd);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* forge.yaml `lean.builder_routing` (on|off, #4394) and `lean.builder_mid_model` (else `engine.lean_builder_mid_model`):
|
|
206
|
+
* {mode?, midModel?}; undefined when neither is set or valid (the core defaults to on with claude-sonnet-5-5).
|
|
207
|
+
*/
|
|
208
|
+
export function readBuilderRouting(cwd = process.cwd()) {
|
|
209
|
+
let sec = {};
|
|
210
|
+
try { sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; } catch { /* absent or unreadable */ }
|
|
211
|
+
const m = String(sec?.builder_routing ?? "").trim().toLowerCase();
|
|
212
|
+
const mode = ["on", "off"].includes(m) ? m : undefined;
|
|
213
|
+
const v = String(sec?.builder_mid_model ?? "").trim();
|
|
214
|
+
const midModel = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("builder_mid_model", cwd);
|
|
215
|
+
return mode || midModel ? { ...(mode ? { mode } : {}), ...(midModel ? { midModel } : {}) } : undefined;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* forge.yaml `lean.sequence.regenerate` (#4386): the command that regenerates a schema-changing migration's snapshot
|
|
219
|
+
* after a deterministic renumber. The one-level parser flattens the nested block to `lean.regenerate`; flat
|
|
220
|
+
* `lean.sequence_regenerate` also works and wins. undefined when absent or unreadable.
|
|
221
|
+
*/
|
|
222
|
+
export function readSequenceRegenerate(cwd = process.cwd()) {
|
|
223
|
+
try { const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; return String(l.sequence_regenerate ?? l.regenerate ?? "").trim() || undefined; } catch { return undefined; }
|
|
224
|
+
}
|
|
225
|
+
/** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
|
|
226
|
+
export function readPreconditionCmd(cwd = process.cwd()) {
|
|
227
|
+
try {
|
|
228
|
+
const v = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.precondition_cmd;
|
|
229
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
230
|
+
} catch { return null; }
|
|
231
|
+
}
|
|
232
|
+
/** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
|
|
233
|
+
export function readExplore(cwd = process.cwd()) {
|
|
234
|
+
try {
|
|
235
|
+
const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {};
|
|
236
|
+
const mode = String(l.explore ?? "").trim().toLowerCase(), model = String(l.explore_model ?? "").trim();
|
|
237
|
+
const out = { ...(["on", "off"].includes(mode) ? { mode } : {}), ...(/^[A-Za-z0-9._:[\]-]+$/.test(model) ? { model } : {}) };
|
|
238
|
+
return Object.keys(out).length ? out : undefined;
|
|
239
|
+
} catch { return undefined; }
|
|
240
|
+
}
|
|
241
|
+
/** forge.yaml `lean.light` (#4448): {mode: off|shadow|on, maxLines, maxFiles} for bin/lean/light.mjs; the defaults when absent or unreadable. */
|
|
242
|
+
export function readLight(cwd = process.cwd()) {
|
|
243
|
+
try { return parseLightConfig(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}); } catch { return { ...LIGHT_DEFAULTS }; }
|
|
244
|
+
}
|
|
169
245
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
170
246
|
try {
|
|
171
247
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -205,6 +281,9 @@ export function readHeavy(cwd = process.cwd()) {
|
|
|
205
281
|
* forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
|
|
206
282
|
* `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
|
|
207
283
|
* `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
|
|
284
|
+
* `engine.cli_busy_minutes` (default 45, #4405): extra silence allowed while the builder has a live heavy step or heavy-queue entry.
|
|
285
|
+
* #4390: `engine.context_ceiling_tokens` (default 120000 in runCore; 0 = off).
|
|
286
|
+
* #4392: `engine.turn_budget_s|m|l` (implement turn budget per lane size; defaults 25/60/100, always returned; 0 = off).
|
|
208
287
|
*/
|
|
209
288
|
export function readCoreLimits(cwd = process.cwd()) {
|
|
210
289
|
try {
|
|
@@ -216,8 +295,11 @@ export function readCoreLimits(cwd = process.cwd()) {
|
|
|
216
295
|
...(mem || cpu ? { laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}) } } : {}),
|
|
217
296
|
...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
|
|
218
297
|
...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
|
|
298
|
+
...(posNum(y.engine?.cli_busy_minutes) ? { cliBusyMinutes: posNum(y.engine.cli_busy_minutes) } : {}),
|
|
299
|
+
...(/^\d+$/.test(String(y.engine?.context_ceiling_tokens ?? "").trim()) ? { contextCeilingTokens: Number(y.engine.context_ceiling_tokens) } : {}),
|
|
300
|
+
turnBudgets: parseTurnBudgets(y.engine) ?? { ...TURN_BUDGET_DEFAULTS }, // #4392: lanes are budgeted by default
|
|
219
301
|
};
|
|
220
|
-
} catch { return {}; }
|
|
302
|
+
} catch { return { turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
|
|
221
303
|
}
|
|
222
304
|
/** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
|
|
223
305
|
export function readMergeWait(cwd = process.cwd()) {
|
|
@@ -280,6 +362,23 @@ export function readPrecheckCommands(cwd = process.cwd()) {
|
|
|
280
362
|
}
|
|
281
363
|
return out.length ? out : undefined;
|
|
282
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* forge.yaml `lean.precheck.max_minutes` (#4331): the pre-PR check round budget in minutes (1..120). The one-level
|
|
367
|
+
* parser flattens a nested `precheck:` block under `lean:` to `lean.max_minutes`; flat `lean.precheck_max_minutes` also
|
|
368
|
+
* works and wins. undefined when absent, invalid or unreadable (runCore then uses verify.precheck_total_seconds, default 600 s).
|
|
369
|
+
*/
|
|
370
|
+
export function readPrecheckBudget(cwd = process.cwd()) {
|
|
371
|
+
try {
|
|
372
|
+
const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {};
|
|
373
|
+
const raw = String(l.precheck_max_minutes ?? l.max_minutes ?? "").trim(), n = Number(raw);
|
|
374
|
+
return raw !== "" && Number.isFinite(n) && n >= 1 && n <= 120 ? n : undefined;
|
|
375
|
+
} catch { return undefined; }
|
|
376
|
+
}
|
|
377
|
+
/** runCore `precheck` option: {commands, maxMinutes} from learned.test_commands and lean.precheck.max_minutes; {} when neither is set. */
|
|
378
|
+
function readPrecheckOptions(cwd = process.cwd()) {
|
|
379
|
+
const commands = readPrecheckCommands(cwd), maxMinutes = readPrecheckBudget(cwd);
|
|
380
|
+
return commands || maxMinutes ? { precheck: { ...(commands ? { commands } : {}), ...(maxMinutes ? { maxMinutes } : {}) } } : {};
|
|
381
|
+
}
|
|
283
382
|
/**
|
|
284
383
|
* forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
|
|
285
384
|
* per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
|
|
@@ -371,16 +470,17 @@ export function formatUsageLine(usage) {
|
|
|
371
470
|
*
|
|
372
471
|
* @param {string} dir - runDir() (or an injected override for tests)
|
|
373
472
|
* @param {number} issue
|
|
473
|
+
* @param {string|null} [repo] - "owner/repo" (#4432: the run log is keyed by repo + issue); null = legacy `<issue>.jsonl`
|
|
374
474
|
* @returns {string} multi-line diagnostic block (no trailing newline)
|
|
375
475
|
*/
|
|
376
|
-
export function formatTerminalDiagnostics(dir, issue) {
|
|
476
|
+
export function formatTerminalDiagnostics(dir, issue, repo = null) {
|
|
377
477
|
if (!Number.isInteger(issue)) {
|
|
378
478
|
return ` run-log: <invalid issue: ${JSON.stringify(issue)}>`;
|
|
379
479
|
}
|
|
380
|
-
const
|
|
480
|
+
const logPath = runLogPath(dir, issue, repo);
|
|
381
481
|
let events = [];
|
|
382
482
|
try {
|
|
383
|
-
events = readLog(dir, issue);
|
|
483
|
+
events = readLog(dir, issue, { repo });
|
|
384
484
|
} catch {
|
|
385
485
|
// Corrupt/unreadable log — degrade to just the run-log path line below.
|
|
386
486
|
}
|
|
@@ -396,7 +496,7 @@ export function formatTerminalDiagnostics(dir, issue) {
|
|
|
396
496
|
const committed = state.committed.length ? state.committed.join(",") : "";
|
|
397
497
|
lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
|
|
398
498
|
}
|
|
399
|
-
lines.push(` run-log: ${
|
|
499
|
+
lines.push(` run-log: ${logPath}`);
|
|
400
500
|
const usageLine = formatUsageLine(aggregateUsage(events));
|
|
401
501
|
if (usageLine) lines.push(usageLine);
|
|
402
502
|
return lines.join("\n");
|
|
@@ -584,10 +684,12 @@ export function lastLocalRun(dir) {
|
|
|
584
684
|
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
585
685
|
|
|
586
686
|
for (const { f } of withMtime) {
|
|
587
|
-
|
|
588
|
-
|
|
687
|
+
// #4432: `<issue>.jsonl` (legacy) or `<owner>__<repo>__<issue>.jsonl`; the issue is the trailing number.
|
|
688
|
+
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f);
|
|
689
|
+
if (!m) continue;
|
|
690
|
+
const issue = Number(m[1]);
|
|
589
691
|
try {
|
|
590
|
-
const events =
|
|
692
|
+
const events = readLogFile(join(dir, f));
|
|
591
693
|
if (events.length === 0) continue;
|
|
592
694
|
const state = deriveState(events);
|
|
593
695
|
return { issue, terminal: state.terminal, terminalReason: state.terminalReason };
|
|
@@ -687,12 +789,16 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
687
789
|
const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
|
|
688
790
|
// Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
|
|
689
791
|
const dir = deps.dir ?? runDir();
|
|
792
|
+
migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo (best effort, never throws)
|
|
793
|
+
// #4432: the run log is keyed by repo + issue. The core path needs the repo anyway; the full path tries once per
|
|
794
|
+
// backoff step without waiting (engine.mjs resolves it again itself), and an unresolved repo keeps the legacy name.
|
|
795
|
+
const logRepo = repo ?? (await resolveDefaultRepo(io, enginePath === "full" ? { sleep: async () => {} } : {}));
|
|
690
796
|
// forge#2240: print the run-log path at the very start of the run, not only
|
|
691
797
|
// in the non-merged completion diagnostics (formatTerminalDiagnostics prints
|
|
692
798
|
// it too, but only post-completion and only for non-"merged" outcomes). A
|
|
693
799
|
// caller tailing this process's stdout should be able to find the run-log
|
|
694
800
|
// to inspect immediately, without waiting for the run to finish.
|
|
695
|
-
console.log(`run-log: ${
|
|
801
|
+
console.log(`run-log: ${runLogPath(dir, issue, logRepo)}`);
|
|
696
802
|
// forge#2240: phase-boundary progress lines — the only stdout emitted
|
|
697
803
|
// during the run itself. `runIssue()`'s `onProgress` callback defaults to a
|
|
698
804
|
// no-op, so this is purely additive; engine.mjs never calls console.log
|
|
@@ -710,7 +816,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
710
816
|
};
|
|
711
817
|
if (enginePath !== "full") {
|
|
712
818
|
// Core work-on (lean): one builder session, one reviewer, GitHub as the register.
|
|
713
|
-
const leanRepo =
|
|
819
|
+
const leanRepo = logRepo;
|
|
714
820
|
if (!leanRepo) throw new Error("core path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
715
821
|
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
716
822
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
@@ -736,7 +842,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
736
842
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
737
843
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
738
844
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
739
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(
|
|
845
|
+
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() } : {}), ...(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() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
740
846
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
741
847
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
742
848
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -782,7 +888,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
782
888
|
// run-log runIssue() just wrote. Skipped for "merged" (the success case)
|
|
783
889
|
// to avoid regressing/cluttering the existing happy-path output.
|
|
784
890
|
if (res.terminalReason !== "merged") {
|
|
785
|
-
console.log(formatTerminalDiagnostics(dir, issue));
|
|
891
|
+
console.log(formatTerminalDiagnostics(dir, issue, logRepo));
|
|
786
892
|
} else {
|
|
787
893
|
// forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
|
|
788
894
|
// block) intentionally stays gated to non-merged outcomes above, but the
|
|
@@ -790,7 +896,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
790
896
|
// hold on the common success case too — surface just the usage line here
|
|
791
897
|
// rather than the full diagnostic block.
|
|
792
898
|
try {
|
|
793
|
-
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
|
|
899
|
+
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue, { repo: logRepo })));
|
|
794
900
|
if (usageLine) console.log(usageLine);
|
|
795
901
|
} catch {
|
|
796
902
|
// Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
|
|
@@ -909,10 +1015,12 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
909
1015
|
|
|
910
1016
|
/**
|
|
911
1017
|
* `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] [--no-prefilter]`
|
|
912
|
-
* `forgedock orchestrate --resume <batch-id
|
|
1018
|
+
* `forgedock orchestrate --resume <batch-id> [issue...]` · `forgedock orchestrate --set-parallel <batch-id> <K>` · `forgedock orchestrate --add <batch-id> <issue...>`
|
|
1019
|
+
* `forgedock orchestrate --cancel <batch-id>`
|
|
913
1020
|
* Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
|
|
914
1021
|
* A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
|
|
915
1022
|
* The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
|
|
1023
|
+
* Declared acceptance probes (#4399, `lean/probe.mjs`) are swept once at batch start unless `--no-probes`.
|
|
916
1024
|
* Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
|
|
917
1025
|
* mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
|
|
918
1026
|
* Durable (#4121): the batch is kept in `~/.forge/batches/<id>/` and each lane is a detached `run-issue` process, so
|
|
@@ -929,6 +1037,10 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
929
1037
|
* for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
|
|
930
1038
|
* running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
|
|
931
1039
|
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
1040
|
+
* `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
|
|
1041
|
+
* (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
|
|
1042
|
+
* `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
|
|
1043
|
+
* SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
|
|
932
1044
|
*/
|
|
933
1045
|
export async function orchestrateFromCli(argv, deps = {}) {
|
|
934
1046
|
const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
|
|
@@ -940,7 +1052,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
940
1052
|
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
941
1053
|
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
942
1054
|
|
|
943
|
-
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
|
|
1055
|
+
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --add <id> <issue...> | --cancel <id>";
|
|
944
1056
|
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
945
1057
|
const issuesOf = (argv) => {
|
|
946
1058
|
const issues = [];
|
|
@@ -950,12 +1062,72 @@ const issuesOf = (argv) => {
|
|
|
950
1062
|
}
|
|
951
1063
|
return issues;
|
|
952
1064
|
};
|
|
1065
|
+
/** #4469: the operands among `tokens` (`--add`/`--resume` extras): no flags, and no value of a valued flag (`--parallel 5` is not issue #5). */
|
|
1066
|
+
const operandsOf = (tokens) => {
|
|
1067
|
+
const out = [];
|
|
1068
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1069
|
+
if (ORCH_VALUED.includes(tokens[i])) { i++; continue; }
|
|
1070
|
+
if (!String(tokens[i]).startsWith("--")) out.push(tokens[i]);
|
|
1071
|
+
}
|
|
1072
|
+
return out;
|
|
1073
|
+
};
|
|
953
1074
|
const parallelOf = (argv) => {
|
|
954
1075
|
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
955
1076
|
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
956
1077
|
return parallel;
|
|
957
1078
|
};
|
|
958
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* #4408: add `tokens` to batch `id`. Not a plain integer: skipped; not OPEN on the batch's repo (`gh issue view --json
|
|
1082
|
+
* state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
|
|
1083
|
+
* when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
|
|
1084
|
+
*/
|
|
1085
|
+
async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1086
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
1087
|
+
const b = batchMod.openBatch(root, id);
|
|
1088
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1089
|
+
const repo = b.readPlan()?.repo ?? null;
|
|
1090
|
+
const skipped = [], ints = [], state = new Map();
|
|
1091
|
+
for (const t of tokens) {
|
|
1092
|
+
if (/^#?\d+$/.test(String(t))) ints.push(parseInt(String(t).replace("#", ""), 10));
|
|
1093
|
+
else skipped.push({ issue: t, reason: "not an integer" });
|
|
1094
|
+
}
|
|
1095
|
+
for (const n of new Set(ints)) {
|
|
1096
|
+
let st = null;
|
|
1097
|
+
try { st = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state"]))?.state ?? null; } catch { st = null; }
|
|
1098
|
+
state.set(n, st);
|
|
1099
|
+
}
|
|
1100
|
+
const open = [];
|
|
1101
|
+
for (const n of ints) {
|
|
1102
|
+
const st = state.get(n);
|
|
1103
|
+
if (st === "OPEN") open.push(n);
|
|
1104
|
+
else if (!skipped.some((x) => x.issue === n)) skipped.push({ issue: n, reason: st ? "closed" : "unreadable" });
|
|
1105
|
+
}
|
|
1106
|
+
let res = { added: [], skipped: [] };
|
|
1107
|
+
if (open.length) res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
|
|
1108
|
+
const out = { ok: true, batch: id, added: res.added ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
|
|
1109
|
+
print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || "(none)"}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
|
|
1115
|
+
* A finished batch (report written) is left alone.
|
|
1116
|
+
*/
|
|
1117
|
+
async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1118
|
+
if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
|
|
1119
|
+
let res = viaDaemon ? await viaDaemon() : null;
|
|
1120
|
+
if (!res) {
|
|
1121
|
+
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1122
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1123
|
+
if (b.readReport()) res = { cancelled: false, finished: true };
|
|
1124
|
+
else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
|
|
1125
|
+
}
|
|
1126
|
+
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1127
|
+
: `batch ${id}: already finished`);
|
|
1128
|
+
return { ok: true, batch: id, cancelled: !!res.cancelled };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
959
1131
|
/**
|
|
960
1132
|
* Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
|
|
961
1133
|
* streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
|
|
@@ -981,7 +1153,31 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
981
1153
|
console.log(`batch ${id}: parallel set to ${k}`);
|
|
982
1154
|
return { ok: true, batch: id, parallel: k };
|
|
983
1155
|
}
|
|
1156
|
+
// #4408: --add, and the extra issues of --resume <id> N..., go to the batch (through the daemon when one runs).
|
|
1157
|
+
const addVia = (id) => async (issues) => {
|
|
1158
|
+
let sock = null;
|
|
1159
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1160
|
+
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1161
|
+
};
|
|
1162
|
+
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1163
|
+
if (argv.includes("--cancel")) {
|
|
1164
|
+
const id = flag(argv, "--cancel");
|
|
1165
|
+
return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
|
|
1166
|
+
let sock = null;
|
|
1167
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1168
|
+
return sock ? schedd.request(sock, { op: "cancel", id }) : null;
|
|
1169
|
+
} });
|
|
1170
|
+
}
|
|
1171
|
+
if (argv.includes("--add")) {
|
|
1172
|
+
const i = argv.indexOf("--add"), id = argv[i + 1];
|
|
1173
|
+
if (!id || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
1174
|
+
return addToBatch({ id, tokens: operandsOf(argv.slice(i + 2)), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(id) });
|
|
1175
|
+
}
|
|
984
1176
|
const resumeId = flag(argv, "--resume");
|
|
1177
|
+
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1178
|
+
if (resumeExtra.length) {
|
|
1179
|
+
await addToBatch({ id: resumeId, tokens: resumeExtra, root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(resumeId) });
|
|
1180
|
+
}
|
|
985
1181
|
let msg;
|
|
986
1182
|
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
987
1183
|
else {
|
|
@@ -1018,7 +1214,7 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1018
1214
|
* `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
|
|
1019
1215
|
* (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
|
|
1020
1216
|
*/
|
|
1021
|
-
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
|
|
1217
|
+
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false, signal = null }) {
|
|
1022
1218
|
const batchMod = await import("./lean/batch.mjs");
|
|
1023
1219
|
const root = deps.batchRoot ?? batchMod.batchRoot();
|
|
1024
1220
|
if (argv.includes("--set-parallel")) {
|
|
@@ -1030,7 +1226,17 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1030
1226
|
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
1031
1227
|
return { ok: true, batch: b.id, parallel: k };
|
|
1032
1228
|
}
|
|
1229
|
+
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
|
|
1230
|
+
if (argv.includes("--add")) {
|
|
1231
|
+
const i = argv.indexOf("--add");
|
|
1232
|
+
if (!argv[i + 1] || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
1233
|
+
return addToBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1234
|
+
}
|
|
1033
1235
|
const resumeId = flag(argv, "--resume");
|
|
1236
|
+
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1237
|
+
if (resumeExtra.length) {
|
|
1238
|
+
await addToBatch({ id: resumeId, tokens: resumeExtra, root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1239
|
+
}
|
|
1034
1240
|
let batch, plan = null;
|
|
1035
1241
|
if (resumeId) {
|
|
1036
1242
|
batch = batchMod.openBatch(root, resumeId);
|
|
@@ -1065,18 +1271,30 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1065
1271
|
// A resume is the operator's decision to continue: the breaker starts closed again.
|
|
1066
1272
|
const st = batch.store.load();
|
|
1067
1273
|
if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1274
|
+
if (!keepBreaker) batch.clearCancel(); // #4376: the operator's resume continues a cancelled batch
|
|
1068
1275
|
if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
|
|
1069
1276
|
batch.clearReport();
|
|
1070
1277
|
}
|
|
1071
1278
|
sink.batch?.(batch);
|
|
1072
1279
|
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
1073
1280
|
const dir = deps.dir ?? runDir();
|
|
1074
|
-
|
|
1281
|
+
migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
|
|
1282
|
+
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
|
|
1283
|
+
const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
|
|
1284
|
+
const { formatFirstPassSummary } = await import("./lean/fixround.mjs");
|
|
1285
|
+
const { formatDecompositionSummary } = await import("./lean/decompose.mjs");
|
|
1286
|
+
const { formatShadowSummary } = await import("./lean/conformance.mjs");
|
|
1075
1287
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1076
|
-
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1288
|
+
// #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
|
|
1289
|
+
const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
|
|
1290
|
+
if (sweep && !argv.includes("--no-probes")) {
|
|
1291
|
+
try { const s = (await import("./lean/probe.mjs")).formatProbeSummary(await sweep({ repo, cwd, io: leanIo })); if (s) sink.line(s); } catch (e) { sink.line(`probes: sweep failed (${String(e?.message ?? e).slice(0, 160)})`); }
|
|
1292
|
+
}
|
|
1293
|
+
const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1079
1294
|
const branches = readBranches(cwd);
|
|
1295
|
+
// #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
|
|
1296
|
+
const prefilter = argv.includes("--no-prefilter") ? null
|
|
1297
|
+
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd) });
|
|
1080
1298
|
const fetched = new Set();
|
|
1081
1299
|
const laneOf = async (n) => {
|
|
1082
1300
|
const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
|
|
@@ -1092,7 +1310,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1092
1310
|
const { runCore } = deps.leanModule;
|
|
1093
1311
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1094
1312
|
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,
|
|
1095
|
-
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) } : {}), ...(
|
|
1313
|
+
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) } : {}), ...(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) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1096
1314
|
} else {
|
|
1097
1315
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1098
1316
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1152,6 +1370,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1152
1370
|
} else {
|
|
1153
1371
|
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1154
1372
|
}
|
|
1373
|
+
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
|
|
1374
|
+
const { killLedgerPid } = await import("./lean/effects.mjs");
|
|
1375
|
+
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.pid && !rec.remote && !rec.handle ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
|
|
1376
|
+
lanes = { ...lanes, stopLane };
|
|
1155
1377
|
}
|
|
1156
1378
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1157
1379
|
// #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
|
|
@@ -1198,13 +1420,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1198
1420
|
const crossLane = [];
|
|
1199
1421
|
// #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
|
|
1200
1422
|
const gcMod = await import("./gc.mjs");
|
|
1423
|
+
// #4361: GC never holds admission for long; what it cannot finish in the budget resumes at the next beforeAdmit interval.
|
|
1424
|
+
const GC_BUDGET_MS = 60 * 1000;
|
|
1201
1425
|
const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
|
|
1202
1426
|
const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
|
|
1203
1427
|
const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
|
|
1204
|
-
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
|
|
1428
|
+
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene, budgetMs: GC_BUDGET_MS });
|
|
1205
1429
|
const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
|
|
1206
1430
|
const nowMs = deps.now ?? (() => Date.now());
|
|
1207
|
-
let lastGcAt = null;
|
|
1431
|
+
let lastGcAt = null, gcPending = false;
|
|
1208
1432
|
const runGcPhase = async (phase) => {
|
|
1209
1433
|
if (!gcRun) return;
|
|
1210
1434
|
lastGcAt = nowMs();
|
|
@@ -1214,6 +1438,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1214
1438
|
reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
|
|
1215
1439
|
reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
|
|
1216
1440
|
for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
|
|
1441
|
+
gcPending = Number(r.deferred) > 0;
|
|
1442
|
+
if (gcPending) console.log(`gc (${phase}): ${Number(r.deferred)} item(s) deferred to the next admission`);
|
|
1217
1443
|
} catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1218
1444
|
};
|
|
1219
1445
|
await runGcPhase("preflight");
|
|
@@ -1226,42 +1452,64 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1226
1452
|
const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
|
|
1227
1453
|
near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
|
|
1228
1454
|
} catch { near = false; }
|
|
1229
|
-
if (near) await runGcPhase("admission");
|
|
1455
|
+
if (near || gcPending) await runGcPhase("admission");
|
|
1230
1456
|
};
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1457
|
+
// #4318: SIGINT/SIGTERM of a foreground dispatcher run the batch-exit relabel, then exit 128+signo (the lane pattern above).
|
|
1458
|
+
let exitRelabel = null, interrupted = false;
|
|
1459
|
+
const on = deps.processOn ?? ((s, f) => process.on(s, f)), off = deps.processOff ?? ((s, f) => process.removeListener(s, f));
|
|
1460
|
+
const exitProc = deps.exit ?? ((c) => process.exit(c));
|
|
1461
|
+
const SIGNO = { SIGINT: 2, SIGTERM: 15 };
|
|
1462
|
+
const handlers = daemon ? {} : Object.fromEntries(Object.keys(SIGNO).map((sig) => [sig, async () => {
|
|
1463
|
+
if (interrupted) return;
|
|
1464
|
+
interrupted = true;
|
|
1465
|
+
try { if (exitRelabel) await exitRelabel(`interrupted by ${sig}`); } catch { /* exit anyway */ }
|
|
1466
|
+
exitProc(128 + SIGNO[sig]);
|
|
1467
|
+
}]));
|
|
1468
|
+
for (const [sig, f] of Object.entries(handlers)) on(sig, f);
|
|
1469
|
+
// #4409: longest-first admission; lane estimates from the size label, a post-deploy-only body and this repo's run history.
|
|
1470
|
+
let estimate = deps.estimate;
|
|
1471
|
+
if (estimate === undefined) {
|
|
1472
|
+
let history = {};
|
|
1473
|
+
try { const { readLocalRuns } = await import("./stats/local-source.mjs"); history = laneHistory(readLocalRuns(dir), repo); } catch { history = {}; }
|
|
1474
|
+
estimate = async (n, { body }) => estimateLaneMinutes({ labels: await labelNames(n), body, history });
|
|
1475
|
+
}
|
|
1476
|
+
let rows;
|
|
1477
|
+
try {
|
|
1478
|
+
rows = await orchestrate({
|
|
1479
|
+
...(estimate ? { estimate } : {}),
|
|
1480
|
+
beforeAdmit,
|
|
1481
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
|
|
1482
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1483
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1484
|
+
...(remote ? { remote } : {}),
|
|
1485
|
+
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1486
|
+
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
1487
|
+
decide: deps.decide ?? (async (d) => {
|
|
1488
|
+
if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
|
|
1489
|
+
const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
|
|
1490
|
+
try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
|
|
1491
|
+
}),
|
|
1492
|
+
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1493
|
+
relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
|
|
1494
|
+
// #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
|
|
1495
|
+
issueLabels: labelNames,
|
|
1496
|
+
// #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
|
|
1497
|
+
...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
|
|
1498
|
+
// #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
|
|
1499
|
+
...(signal ? { signal } : {}),
|
|
1500
|
+
conflicting: deps.conflicting ?? (async (_n, pr) => {
|
|
1501
|
+
try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
|
|
1502
|
+
}),
|
|
1503
|
+
onCrossLane: (e) => {
|
|
1504
|
+
crossLane.push(e);
|
|
1505
|
+
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
1506
|
+
},
|
|
1507
|
+
readBody: maint || registry || hybrid
|
|
1508
|
+
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
|
|
1509
|
+
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1510
|
+
onUpdate: (r) => sink.row(r),
|
|
1511
|
+
});
|
|
1512
|
+
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
1265
1513
|
const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
|
|
1266
1514
|
const pfLine = formatPrefilterSummary(rows);
|
|
1267
1515
|
if (pfLine) report.push(pfLine);
|
|
@@ -1269,6 +1517,22 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1269
1517
|
try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
|
|
1270
1518
|
const waitLine = formatWaitSummary(rows, heavyStats);
|
|
1271
1519
|
if (waitLine) report.push(waitLine);
|
|
1520
|
+
const precheckLine = formatPrecheckSummary(rows); // #4331
|
|
1521
|
+
if (precheckLine) report.push(precheckLine);
|
|
1522
|
+
const firstPassLine = formatFirstPassSummary(rows); // #4362
|
|
1523
|
+
if (firstPassLine) report.push(firstPassLine);
|
|
1524
|
+
const turnLine = formatTurnSummary(rows); // #4392
|
|
1525
|
+
if (turnLine) report.push(turnLine);
|
|
1526
|
+
const estLine = formatEstimateSummary(rows); // #4409
|
|
1527
|
+
if (estLine) report.push(estLine);
|
|
1528
|
+
try { // #4402: $/merge, turns, context, retries by cause, no-action lanes, issue quality, acceptance; never fails a batch
|
|
1529
|
+
const { batchEfficiency, formatEfficiencyLines } = await import("./stats/efficiency.mjs");
|
|
1530
|
+
report.push(...formatEfficiencyLines(await (deps.efficiency ?? batchEfficiency)({ batchDir: batch.dir, runsDir: dir, repo, gh: (a) => leanIo.gh(a), rows })));
|
|
1531
|
+
} catch (e) { report.push(`efficiency: n/a (${String(e?.message ?? e).split("\n")[0].slice(0, 160)})`); }
|
|
1532
|
+
const shadowLine = formatShadowSummary(rows); // #4398
|
|
1533
|
+
if (shadowLine) report.push(shadowLine);
|
|
1534
|
+
const decompLine = formatDecompositionSummary(rows); // #4396
|
|
1535
|
+
if (decompLine) report.push(decompLine);
|
|
1272
1536
|
let day = null;
|
|
1273
1537
|
try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
|
|
1274
1538
|
const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
|
|
@@ -1276,12 +1540,40 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1276
1540
|
if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
|
|
1277
1541
|
if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
|
|
1278
1542
|
report.push(formatReclaimed(reclaimed));
|
|
1543
|
+
try { // #4354: fail-soft alerts for the batch's repo from the local run logs, last; nothing when none fired
|
|
1544
|
+
const [{ computeAlerts }, { readLanes }, { readAlertConfig }] = await Promise.all([import("./stats/aggregate.mjs"), import("./stats/local-source.mjs"), import("./stats/index.mjs")]);
|
|
1545
|
+
report.push(...formatAlertLines(computeAlerts(readLanes(dir), { repo, ...(deps.alertConfig ?? readAlertConfig(cwd)) })));
|
|
1546
|
+
} catch { /* alerts never fail a batch */ }
|
|
1279
1547
|
for (const l of report) sink.line(l);
|
|
1280
1548
|
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1281
1549
|
try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1282
1550
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1283
1551
|
}
|
|
1284
1552
|
|
|
1553
|
+
/**
|
|
1554
|
+
* #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
|
|
1555
|
+
* never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
|
|
1556
|
+
* resume retries); then the capped FORGE:NEEDS_HUMAN comment, whose failure keeps the label → true.
|
|
1557
|
+
*/
|
|
1558
|
+
export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
|
|
1559
|
+
return async ({ issue, evidence }) => {
|
|
1560
|
+
const { stateOf } = await import("./lean/context.mjs");
|
|
1561
|
+
try {
|
|
1562
|
+
const v = JSON.parse(await gh(["issue", "view", String(issue), "-R", repo, "--json", "state,body"]));
|
|
1563
|
+
if (String(v?.state ?? "").toUpperCase() === "CLOSED") return "skipped";
|
|
1564
|
+
const lease = stateOf(v?.body)?.lease;
|
|
1565
|
+
if (lease && Number(lease.until) > now()) return "skipped";
|
|
1566
|
+
} catch { /* unreadable: relabel as before */ }
|
|
1567
|
+
try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); } catch { return "failed"; }
|
|
1568
|
+
try {
|
|
1569
|
+
// #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
|
|
1570
|
+
const { capComment } = await import("./lean/render.mjs");
|
|
1571
|
+
await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
|
|
1572
|
+
} catch { /* best effort: the label is on, the batch row keeps the evidence */ }
|
|
1573
|
+
return true;
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1285
1577
|
/** The batch report's "reclaimed" line (#4183). */
|
|
1286
1578
|
export function formatReclaimed(r) {
|
|
1287
1579
|
if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
|
|
@@ -1305,7 +1597,8 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1305
1597
|
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1306
1598
|
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1307
1599
|
if (!d.running) return d;
|
|
1308
|
-
|
|
1600
|
+
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
1601
|
+
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
1309
1602
|
await d.closed;
|
|
1310
1603
|
return { running: false };
|
|
1311
1604
|
}
|
|
@@ -1338,6 +1631,71 @@ export async function limitsFromCli(argv, deps = {}) {
|
|
|
1338
1631
|
throw new Error(usage);
|
|
1339
1632
|
}
|
|
1340
1633
|
|
|
1634
|
+
/**
|
|
1635
|
+
* `forgedock check [--repo DIR] [--base REF] [--json]` (#4364): runs in the lane worktree what the engine runs after the
|
|
1636
|
+
* builder: the frozen validate commands (.forge-lean/contract.json) and the change-scoped pre-PR checks, with the plan
|
|
1637
|
+
* the engine wrote (.forge-lean/check-plan.json; else this checkout's forge.yaml) and the caller's env (lane TMPDIR,
|
|
1638
|
+
* pinned toolchain, heavy shims). Local only: no gh, no push, no workflow. Prints a pass/fail table, records the run in
|
|
1639
|
+
* .forge-lean/self-check.json, and returns {ok, exitCode} (0 pass, 1 fail, 2 usage).
|
|
1640
|
+
*/
|
|
1641
|
+
export async function checkFromCli(argv, deps = {}) {
|
|
1642
|
+
const usage = "usage: forgedock check [--repo DIR] [--base REF] [--json]";
|
|
1643
|
+
const pre = await import("./lean/precheck.mjs");
|
|
1644
|
+
const { readTestMap, readTestMapAtRef } = await import("./lean/testmap.mjs");
|
|
1645
|
+
const fsp = await import("node:fs/promises");
|
|
1646
|
+
const out = deps.out ?? ((l) => console.log(l)), env = deps.env ?? process.env;
|
|
1647
|
+
const o = {};
|
|
1648
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1649
|
+
const t = argv[i];
|
|
1650
|
+
if (t === "--repo" || t === "--base") { if (!argv[i + 1]) throw Object.assign(new Error(usage), { exitCode: 2 }); o[t.slice(2)] = argv[++i]; }
|
|
1651
|
+
else if (t === "--json") o.json = true;
|
|
1652
|
+
else throw Object.assign(new Error(usage), { exitCode: 2 });
|
|
1653
|
+
}
|
|
1654
|
+
const run0 = deps.git ?? (async (args, cwd) => (await promisify(execFile)("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 })).stdout);
|
|
1655
|
+
const repo = resolvePath(o.repo ?? String(await run0(["rev-parse", "--show-toplevel"], deps.cwd ?? process.cwd())).trim());
|
|
1656
|
+
const git = (args) => run0(args, repo);
|
|
1657
|
+
const readJson = async (f) => { try { return JSON.parse(await (deps.readFile ?? ((p) => fsp.readFile(p, "utf8")))(join(repo, f))); } catch { return null; } };
|
|
1658
|
+
const contract = await readJson(".forge-lean/contract.json");
|
|
1659
|
+
if (!contract) throw Object.assign(new Error(`check: no .forge-lean/contract.json in ${repo}\n${usage}`), { exitCode: 2 });
|
|
1660
|
+
const plan = await readJson(pre.CHECK_PLAN_FILE);
|
|
1661
|
+
const base = o.base ?? plan?.base;
|
|
1662
|
+
if (!base) throw Object.assign(new Error(`check: no check plan from the engine; pass --base REF\n${usage}`), { exitCode: 2 });
|
|
1663
|
+
const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
|
|
1664
|
+
const learned = plan ? plan.learned : readPrecheckCommands(repo) ?? [];
|
|
1665
|
+
const maxMinutes = plan ? plan.maxMinutes : readPrecheckBudget(repo);
|
|
1666
|
+
// the engine's changed files (lean.mjs changedFiles: base...HEAD still differing from base), plus uncommitted work
|
|
1667
|
+
const names = (x) => String(x ?? "").split("\n").map((f) => f.trim()).filter(Boolean);
|
|
1668
|
+
const touched = names(await git(["diff", "--name-only", `${base}...HEAD`])), differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
1669
|
+
const files = [...new Set([...touched.filter((f) => differs.has(f)), ...names(await git(["diff", "--name-only", "HEAD"])), ...names(await git(["ls-files", "-o", "--exclude-standard"]))])]
|
|
1670
|
+
.filter((f) => !f.startsWith(".forge-lean/"));
|
|
1671
|
+
const testMap = (await readTestMapAtRef(git, base)) ?? (await readTestMap([repo], (p) => fsp.readFile(p, "utf8")));
|
|
1672
|
+
const picked = pre.planPrecheck({ testMap, files, learned, maxMinutes, verify });
|
|
1673
|
+
const par = String(env.FORGE_HOST_PARALLELISM ?? "").trim(), pinned = /^\d+$/.test(par) && Number(par) > 0;
|
|
1674
|
+
const runCommand = deps.runCommand ?? (await import("./lean/effects.mjs")).runCheckCommand;
|
|
1675
|
+
const r = await pre.selfCheck({ contract, plan: { mode: verify.mode, commands: picked.commands, budgetMs: picked.budgetMs, perCommandMs: picked.perCommandMs },
|
|
1676
|
+
parallelism: pinned ? Number(par) : 1, now: deps.now,
|
|
1677
|
+
run: ({ command, timeoutMs, workers }) => runCommand({ command, cwd: repo, timeoutMs, env: pinned ? { FORGE_HOST_PARALLELISM: String(workers) } : null }) });
|
|
1678
|
+
// #4390: each failure's full output goes to .forge-lean/logs/ (git-excluded); the table shows its tail, ids and the path
|
|
1679
|
+
const failing = [...r.validate.map((x) => [x, `check-${x.id}`]), ...r.commands.map((x, i) => [x, `check-cmd${i + 1}`])].filter(([x]) => !x.status && x.rc !== 0);
|
|
1680
|
+
if (failing.length) {
|
|
1681
|
+
try {
|
|
1682
|
+
await (deps.mkdir ?? ((p) => fsp.mkdir(p, { recursive: true })))(join(repo, pre.CHECK_LOG_DIR));
|
|
1683
|
+
for (const [x, name] of failing) {
|
|
1684
|
+
const rel = `${pre.CHECK_LOG_DIR}/${name.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
|
|
1685
|
+
await (deps.writeFile ?? ((p, t) => fsp.writeFile(p, t)))(join(repo, rel), String(x.output ?? ""));
|
|
1686
|
+
x.logPath = rel;
|
|
1687
|
+
}
|
|
1688
|
+
} catch { /* no log files: the table still shows the tails */ }
|
|
1689
|
+
}
|
|
1690
|
+
let fingerprint = null;
|
|
1691
|
+
try { fingerprint = await pre.worktreeFingerprint({ git, base }); } catch { /* recorded without one: the engine reads it as skipped */ }
|
|
1692
|
+
const strip = (x) => (x.status ? { ...(x.id ? { id: x.id } : {}), command: x.command, status: x.status } : { ...(x.id ? { id: x.id } : {}), command: x.command, rc: x.rc, ms: x.ms });
|
|
1693
|
+
const record = { v: 1, at: new Date((deps.now ?? Date.now)()).toISOString(), base, fingerprint, ok: r.ok, validate: r.validate.map(strip), commands: [...r.commands.map(strip), ...r.deferred.map((command) => ({ command, status: "deferred-to-ci" }))] };
|
|
1694
|
+
try { await (deps.writeFile ?? ((p, t) => fsp.writeFile(p, t)))(join(repo, pre.SELF_CHECK_FILE), `${JSON.stringify(record)}\n`); } catch { /* .forge-lean missing: still print */ }
|
|
1695
|
+
out(o.json ? JSON.stringify({ ...r, base, fingerprint }, null, 2) : pre.formatCheckTable(r));
|
|
1696
|
+
return { ...r, base, fingerprint, files, exitCode: r.ok ? 0 : 1 };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1341
1699
|
/**
|
|
1342
1700
|
* `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
|
|
1343
1701
|
* The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
|
|
@@ -1458,8 +1816,8 @@ function prefilterModel(argv) {
|
|
|
1458
1816
|
* `forgedock triage <issue...> [--parallel K] [--lane L] [--repo owner/repo] [--dry-run] [--prefilter-model M]` (#4123)
|
|
1459
1817
|
* Issues are checked through a bounded pool of K (default 8, #4141); each lane is fetched once; rows print in input order.
|
|
1460
1818
|
* Each issue is checked against its own lane (#4125); `--lane` is an assertion, a refused lane is reported and dropped.
|
|
1461
|
-
* Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence)
|
|
1462
|
-
* still need a lane as the last line (`lanes: #1 #2`). `--dry-run` reports candidates without a model call or close.
|
|
1819
|
+
* Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence), parks post-deploy follow-ups
|
|
1820
|
+
* whose parent fix is not on the default branch (#4389), and prints the issues that still need a lane as the last line (`lanes: #1 #2`). `--dry-run` reports candidates without a model call or close.
|
|
1463
1821
|
*/
|
|
1464
1822
|
export async function triageFromCli(argv, deps = {}) {
|
|
1465
1823
|
const valued = ["--lane", "--repo", "--prefilter-model", "--parallel"];
|
|
@@ -1474,8 +1832,9 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1474
1832
|
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
1475
1833
|
if (!repo) throw new Error("triage needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
1476
1834
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1477
|
-
const { prefilterIssue, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
1835
|
+
const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1478
1836
|
const branches = readBranches();
|
|
1837
|
+
const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd() }; // #4389
|
|
1479
1838
|
const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
|
|
1480
1839
|
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1481
1840
|
// #4141: bounded pool. Each lane is fetched once (memoized promise); output stays in input order.
|
|
@@ -1491,7 +1850,8 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1491
1850
|
const ln = await resolveLane({ issue: n, repo, io: leanIo, cwd: process.cwd(), branches, requested: lane, override: argv.includes("--lane-override") });
|
|
1492
1851
|
if (!ln.ok) return { line: `#${n} → lane-refused (${ln.code}: ${ln.detail})`, row: { issue: n, outcome: "lane-refused", cost_usd: 0, prefilter: null } };
|
|
1493
1852
|
await fetchLane(ln.lane);
|
|
1494
|
-
const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv) });
|
|
1853
|
+
const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv), ...gate });
|
|
1854
|
+
if (pf.parked) return { line: `#${n} → parked (${pf.kind}: ${pf.detail})`, row: { issue: n, outcome: pf.kind, cost_usd: 0, prefilter: pf } };
|
|
1495
1855
|
return { line: `#${n} → ${pf.closed ? "closed" : pf.candidate ? "candidate" : "lane"} (${pf.detail})`, row: { issue: n, outcome: pf.closed ? "invalid" : "pending", cost_usd: pf.cost_usd, prefilter: pf } };
|
|
1496
1856
|
};
|
|
1497
1857
|
let next = 0, printed = 0;
|
|
@@ -1506,8 +1866,35 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1506
1866
|
}
|
|
1507
1867
|
}));
|
|
1508
1868
|
const rows = out.map((r) => r.row);
|
|
1509
|
-
const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed).map((r) => r.issue);
|
|
1869
|
+
const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed && !r.prefilter.parked).map((r) => r.issue);
|
|
1510
1870
|
console.log(formatPrefilterSummary(rows));
|
|
1871
|
+
const parked = formatParkedLine(rows);
|
|
1872
|
+
if (parked) console.log(parked);
|
|
1511
1873
|
console.log(`lanes: ${lanes.map((n) => `#${n}`).join(" ") || "(none)"}`);
|
|
1512
1874
|
return { rows, lanes };
|
|
1513
1875
|
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
|
|
1879
|
+
* Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
|
|
1880
|
+
* declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
|
|
1881
|
+
*/
|
|
1882
|
+
export async function probeFromCli(argv, deps = {}) {
|
|
1883
|
+
const valued = ["--repo", "--limit"];
|
|
1884
|
+
const issues = [];
|
|
1885
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1886
|
+
if (valued.includes(argv[i])) { i++; continue; }
|
|
1887
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
1888
|
+
}
|
|
1889
|
+
const io = deps.io ?? makeIo();
|
|
1890
|
+
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
1891
|
+
if (!repo) throw new Error("probe needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
1892
|
+
const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
|
|
1893
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
|
|
1894
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1895
|
+
const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
|
|
1896
|
+
const rows = await sweepProbes({ repo, cwd: process.cwd(), io: leanIo, issues: [...new Set(issues)], limit, dryRun: argv.includes("--dry-run"), ...(deps.spawn ? { spawn: deps.spawn } : {}) });
|
|
1897
|
+
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
1898
|
+
console.log(formatProbeSummary(rows) || "probes: nothing to run");
|
|
1899
|
+
return { rows };
|
|
1900
|
+
}
|