forgedock 1.30.0 → 1.32.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 +3 -2
- 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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -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 +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -15
- 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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- 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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- 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 +72 -10
- package/bin/lean/verify.test.mjs +68 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- 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 +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -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/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,11 +114,18 @@ 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
|
/**
|
|
118
127
|
* forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
|
|
119
|
-
* (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
128
|
+
* (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
120
129
|
* `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
|
|
121
130
|
* (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
|
|
122
131
|
*/
|
|
@@ -124,12 +133,13 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
|
|
|
124
133
|
try {
|
|
125
134
|
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
126
135
|
let mode = String(e.context_pack ?? "").trim().toLowerCase();
|
|
127
|
-
if (mode && !["shadow", "on", "off"].includes(mode)) {
|
|
128
|
-
warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
|
|
136
|
+
if (mode && !["shadow", "on", "off", "ab"].includes(mode)) {
|
|
137
|
+
warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off|ab); using the default, other context_pack_* keys kept`);
|
|
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 {
|
|
@@ -170,6 +193,62 @@ export function readReproTest(cwd = process.cwd()) {
|
|
|
170
193
|
export function readCloseParent(cwd = process.cwd()) {
|
|
171
194
|
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.decomposition?.close_parent ?? "").trim() === "true"; } catch { return false; }
|
|
172
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
|
+
/** #4425: forge.yaml `post_deploy.min_age` ("24h", "7d"; opt-in soak window for parked post-deploy follow-ups) in ms, or null. */
|
|
233
|
+
export async function readPostDeployMinAge(cwd = process.cwd()) {
|
|
234
|
+
try {
|
|
235
|
+
const { parseMinAge } = await import("./lean/prefilter.mjs");
|
|
236
|
+
return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
|
|
237
|
+
} catch { return null; }
|
|
238
|
+
}
|
|
239
|
+
/** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
|
|
240
|
+
export function readExplore(cwd = process.cwd()) {
|
|
241
|
+
try {
|
|
242
|
+
const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {};
|
|
243
|
+
const mode = String(l.explore ?? "").trim().toLowerCase(), model = String(l.explore_model ?? "").trim();
|
|
244
|
+
const out = { ...(["on", "off"].includes(mode) ? { mode } : {}), ...(/^[A-Za-z0-9._:[\]-]+$/.test(model) ? { model } : {}) };
|
|
245
|
+
return Object.keys(out).length ? out : undefined;
|
|
246
|
+
} catch { return undefined; }
|
|
247
|
+
}
|
|
248
|
+
/** forge.yaml `lean.light` (#4448): {mode: off|shadow|on, maxLines, maxFiles} for bin/lean/light.mjs; the defaults when absent or unreadable. */
|
|
249
|
+
export function readLight(cwd = process.cwd()) {
|
|
250
|
+
try { return parseLightConfig(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}); } catch { return { ...LIGHT_DEFAULTS }; }
|
|
251
|
+
}
|
|
173
252
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
174
253
|
try {
|
|
175
254
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -209,6 +288,9 @@ export function readHeavy(cwd = process.cwd()) {
|
|
|
209
288
|
* forge.yaml lane caps and CLI timeouts for the core path (#4115, #4112). The parser flattens one nested level, so
|
|
210
289
|
* `pipeline:`/`engine:` then `lane_limits:` with `memory_max`/`cpu_quota` arrives as `memory_max`/`cpu_quota` (or flat
|
|
211
290
|
* `lane_limits_memory_max`/`lane_limits_cpu_quota`). `engine.cli_idle_minutes` (default 15) and `engine.cli_max_minutes` (default 180).
|
|
291
|
+
* `engine.cli_busy_minutes` (default 45, #4405): extra silence allowed while the builder has a live heavy step or heavy-queue entry.
|
|
292
|
+
* #4390: `engine.context_ceiling_tokens` (default 120000 in runCore; 0 = off).
|
|
293
|
+
* #4392: `engine.turn_budget_s|m|l` (implement turn budget per lane size; defaults 25/60/100, always returned; 0 = off).
|
|
212
294
|
*/
|
|
213
295
|
export function readCoreLimits(cwd = process.cwd()) {
|
|
214
296
|
try {
|
|
@@ -220,8 +302,11 @@ export function readCoreLimits(cwd = process.cwd()) {
|
|
|
220
302
|
...(mem || cpu ? { laneLimits: { ...(mem ? { memory_max: mem } : {}), ...(cpu ? { cpu_quota: cpu } : {}) } } : {}),
|
|
221
303
|
...(posNum(y.engine?.cli_idle_minutes) ? { cliIdleMinutes: posNum(y.engine.cli_idle_minutes) } : {}),
|
|
222
304
|
...(posNum(y.engine?.cli_max_minutes) ? { cliMaxMinutes: posNum(y.engine.cli_max_minutes) } : {}),
|
|
305
|
+
...(posNum(y.engine?.cli_busy_minutes) ? { cliBusyMinutes: posNum(y.engine.cli_busy_minutes) } : {}),
|
|
306
|
+
...(/^\d+$/.test(String(y.engine?.context_ceiling_tokens ?? "").trim()) ? { contextCeilingTokens: Number(y.engine.context_ceiling_tokens) } : {}),
|
|
307
|
+
turnBudgets: parseTurnBudgets(y.engine) ?? { ...TURN_BUDGET_DEFAULTS }, // #4392: lanes are budgeted by default
|
|
223
308
|
};
|
|
224
|
-
} catch { return {}; }
|
|
309
|
+
} catch { return { turnBudgets: { ...TURN_BUDGET_DEFAULTS } }; }
|
|
225
310
|
}
|
|
226
311
|
/** forge.yaml `engine.merge_wait` (required|all|none); undefined when absent or invalid. */
|
|
227
312
|
export function readMergeWait(cwd = process.cwd()) {
|
|
@@ -392,16 +477,17 @@ export function formatUsageLine(usage) {
|
|
|
392
477
|
*
|
|
393
478
|
* @param {string} dir - runDir() (or an injected override for tests)
|
|
394
479
|
* @param {number} issue
|
|
480
|
+
* @param {string|null} [repo] - "owner/repo" (#4432: the run log is keyed by repo + issue); null = legacy `<issue>.jsonl`
|
|
395
481
|
* @returns {string} multi-line diagnostic block (no trailing newline)
|
|
396
482
|
*/
|
|
397
|
-
export function formatTerminalDiagnostics(dir, issue) {
|
|
483
|
+
export function formatTerminalDiagnostics(dir, issue, repo = null) {
|
|
398
484
|
if (!Number.isInteger(issue)) {
|
|
399
485
|
return ` run-log: <invalid issue: ${JSON.stringify(issue)}>`;
|
|
400
486
|
}
|
|
401
|
-
const
|
|
487
|
+
const logPath = runLogPath(dir, issue, repo);
|
|
402
488
|
let events = [];
|
|
403
489
|
try {
|
|
404
|
-
events = readLog(dir, issue);
|
|
490
|
+
events = readLog(dir, issue, { repo });
|
|
405
491
|
} catch {
|
|
406
492
|
// Corrupt/unreadable log — degrade to just the run-log path line below.
|
|
407
493
|
}
|
|
@@ -417,7 +503,7 @@ export function formatTerminalDiagnostics(dir, issue) {
|
|
|
417
503
|
const committed = state.committed.length ? state.committed.join(",") : "";
|
|
418
504
|
lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
|
|
419
505
|
}
|
|
420
|
-
lines.push(` run-log: ${
|
|
506
|
+
lines.push(` run-log: ${logPath}`);
|
|
421
507
|
const usageLine = formatUsageLine(aggregateUsage(events));
|
|
422
508
|
if (usageLine) lines.push(usageLine);
|
|
423
509
|
return lines.join("\n");
|
|
@@ -605,10 +691,12 @@ export function lastLocalRun(dir) {
|
|
|
605
691
|
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
606
692
|
|
|
607
693
|
for (const { f } of withMtime) {
|
|
608
|
-
|
|
609
|
-
|
|
694
|
+
// #4432: `<issue>.jsonl` (legacy) or `<owner>__<repo>__<issue>.jsonl`; the issue is the trailing number.
|
|
695
|
+
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f);
|
|
696
|
+
if (!m) continue;
|
|
697
|
+
const issue = Number(m[1]);
|
|
610
698
|
try {
|
|
611
|
-
const events =
|
|
699
|
+
const events = readLogFile(join(dir, f));
|
|
612
700
|
if (events.length === 0) continue;
|
|
613
701
|
const state = deriveState(events);
|
|
614
702
|
return { issue, terminal: state.terminal, terminalReason: state.terminalReason };
|
|
@@ -708,12 +796,16 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
708
796
|
const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
|
|
709
797
|
// Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
|
|
710
798
|
const dir = deps.dir ?? runDir();
|
|
799
|
+
migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo (best effort, never throws)
|
|
800
|
+
// #4432: the run log is keyed by repo + issue. The core path needs the repo anyway; the full path tries once per
|
|
801
|
+
// backoff step without waiting (engine.mjs resolves it again itself), and an unresolved repo keeps the legacy name.
|
|
802
|
+
const logRepo = repo ?? (await resolveDefaultRepo(io, enginePath === "full" ? { sleep: async () => {} } : {}));
|
|
711
803
|
// forge#2240: print the run-log path at the very start of the run, not only
|
|
712
804
|
// in the non-merged completion diagnostics (formatTerminalDiagnostics prints
|
|
713
805
|
// it too, but only post-completion and only for non-"merged" outcomes). A
|
|
714
806
|
// caller tailing this process's stdout should be able to find the run-log
|
|
715
807
|
// to inspect immediately, without waiting for the run to finish.
|
|
716
|
-
console.log(`run-log: ${
|
|
808
|
+
console.log(`run-log: ${runLogPath(dir, issue, logRepo)}`);
|
|
717
809
|
// forge#2240: phase-boundary progress lines — the only stdout emitted
|
|
718
810
|
// during the run itself. `runIssue()`'s `onProgress` callback defaults to a
|
|
719
811
|
// no-op, so this is purely additive; engine.mjs never calls console.log
|
|
@@ -731,7 +823,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
731
823
|
};
|
|
732
824
|
if (enginePath !== "full") {
|
|
733
825
|
// Core work-on (lean): one builder session, one reviewer, GitHub as the register.
|
|
734
|
-
const leanRepo =
|
|
826
|
+
const leanRepo = logRepo;
|
|
735
827
|
if (!leanRepo) throw new Error("core path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
736
828
|
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
737
829
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
@@ -757,7 +849,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
757
849
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
758
850
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
759
851
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
760
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
852
|
+
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() } : {}), 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() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
761
853
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
762
854
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
763
855
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -803,7 +895,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
803
895
|
// run-log runIssue() just wrote. Skipped for "merged" (the success case)
|
|
804
896
|
// to avoid regressing/cluttering the existing happy-path output.
|
|
805
897
|
if (res.terminalReason !== "merged") {
|
|
806
|
-
console.log(formatTerminalDiagnostics(dir, issue));
|
|
898
|
+
console.log(formatTerminalDiagnostics(dir, issue, logRepo));
|
|
807
899
|
} else {
|
|
808
900
|
// forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
|
|
809
901
|
// block) intentionally stays gated to non-merged outcomes above, but the
|
|
@@ -811,7 +903,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
811
903
|
// hold on the common success case too — surface just the usage line here
|
|
812
904
|
// rather than the full diagnostic block.
|
|
813
905
|
try {
|
|
814
|
-
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
|
|
906
|
+
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue, { repo: logRepo })));
|
|
815
907
|
if (usageLine) console.log(usageLine);
|
|
816
908
|
} catch {
|
|
817
909
|
// Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
|
|
@@ -930,10 +1022,13 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
930
1022
|
|
|
931
1023
|
/**
|
|
932
1024
|
* `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] [--no-prefilter]`
|
|
933
|
-
* `forgedock orchestrate --resume <batch-id
|
|
1025
|
+
* `forgedock orchestrate --resume <batch-id> [issue...]` · `forgedock orchestrate --set-parallel <batch-id> <K>` · `forgedock orchestrate --add <batch-id> <issue...>`
|
|
1026
|
+
* `forgedock orchestrate --cancel <batch-id>`
|
|
934
1027
|
* Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
|
|
935
1028
|
* A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
|
|
936
|
-
* The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
|
|
1029
|
+
* The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`. Every start, requeues included, first
|
|
1030
|
+
* checks GitHub (#4481, `makeDispatchCheck`): a closed issue or a merged `feat/issue-N-lean` PR ends the row without a lane.
|
|
1031
|
+
* Declared acceptance probes (#4399, `lean/probe.mjs`) are swept once at batch start unless `--no-probes`.
|
|
937
1032
|
* Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
|
|
938
1033
|
* mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
|
|
939
1034
|
* Durable (#4121): the batch is kept in `~/.forge/batches/<id>/` and each lane is a detached `run-issue` process, so
|
|
@@ -950,6 +1045,10 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
|
|
|
950
1045
|
* for every batch on the host. This command submits it and streams the rows; Ctrl-C detaches and the batch keeps
|
|
951
1046
|
* running; `--resume <id>` reattaches (or replays the report of a finished batch); `--set-parallel` goes through the
|
|
952
1047
|
* daemon. `--foreground` or `FORGEDOCK_SCHEDD=0` runs the batch in this process as before (also for injected test deps).
|
|
1048
|
+
* `--add <id> N...` (#4408) adds issues to that batch instead of starting a second one: they run under its K and DAG
|
|
1049
|
+
* (a running batch picks them up on its next pass, a stopped one on its next `--resume`); `--resume <id> N...` adds N too.
|
|
1050
|
+
* `--cancel <id>` (#4376) stops that one batch, never the shared daemon: it admits nothing more, its running local lanes get
|
|
1051
|
+
* SIGTERM (their teardown saves the work), remote lanes run to their result and the rest end `held`; `--resume <id>` continues it.
|
|
953
1052
|
*/
|
|
954
1053
|
export async function orchestrateFromCli(argv, deps = {}) {
|
|
955
1054
|
const inProcess = deps.inProcess || deps.leanModule || deps.laneScript || deps.batchRoot || deps.leanIo
|
|
@@ -961,7 +1060,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
961
1060
|
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
962
1061
|
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
963
1062
|
|
|
964
|
-
const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>";
|
|
1063
|
+
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>";
|
|
965
1064
|
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
966
1065
|
const issuesOf = (argv) => {
|
|
967
1066
|
const issues = [];
|
|
@@ -971,12 +1070,72 @@ const issuesOf = (argv) => {
|
|
|
971
1070
|
}
|
|
972
1071
|
return issues;
|
|
973
1072
|
};
|
|
1073
|
+
/** #4469: the operands among `tokens` (`--add`/`--resume` extras): no flags, and no value of a valued flag (`--parallel 5` is not issue #5). */
|
|
1074
|
+
const operandsOf = (tokens) => {
|
|
1075
|
+
const out = [];
|
|
1076
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1077
|
+
if (ORCH_VALUED.includes(tokens[i])) { i++; continue; }
|
|
1078
|
+
if (!String(tokens[i]).startsWith("--")) out.push(tokens[i]);
|
|
1079
|
+
}
|
|
1080
|
+
return out;
|
|
1081
|
+
};
|
|
974
1082
|
const parallelOf = (argv) => {
|
|
975
1083
|
const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
|
|
976
1084
|
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
977
1085
|
return parallel;
|
|
978
1086
|
};
|
|
979
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* #4408: add `tokens` to batch `id`. Not a plain integer: skipped; not OPEN on the batch's repo (`gh issue view --json
|
|
1090
|
+
* state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
|
|
1091
|
+
* when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
|
|
1092
|
+
*/
|
|
1093
|
+
async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1094
|
+
const batchMod = await import("./lean/batch.mjs");
|
|
1095
|
+
const b = batchMod.openBatch(root, id);
|
|
1096
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1097
|
+
const repo = b.readPlan()?.repo ?? null;
|
|
1098
|
+
const skipped = [], ints = [], state = new Map();
|
|
1099
|
+
for (const t of tokens) {
|
|
1100
|
+
if (/^#?\d+$/.test(String(t))) ints.push(parseInt(String(t).replace("#", ""), 10));
|
|
1101
|
+
else skipped.push({ issue: t, reason: "not an integer" });
|
|
1102
|
+
}
|
|
1103
|
+
for (const n of new Set(ints)) {
|
|
1104
|
+
let st = null;
|
|
1105
|
+
try { st = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state"]))?.state ?? null; } catch { st = null; }
|
|
1106
|
+
state.set(n, st);
|
|
1107
|
+
}
|
|
1108
|
+
const open = [];
|
|
1109
|
+
for (const n of ints) {
|
|
1110
|
+
const st = state.get(n);
|
|
1111
|
+
if (st === "OPEN") open.push(n);
|
|
1112
|
+
else if (!skipped.some((x) => x.issue === n)) skipped.push({ issue: n, reason: st ? "closed" : "unreadable" });
|
|
1113
|
+
}
|
|
1114
|
+
let res = { added: [], skipped: [] };
|
|
1115
|
+
if (open.length) res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
|
|
1116
|
+
const out = { ok: true, batch: id, added: res.added ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
|
|
1117
|
+
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(", ")}` : ""}`);
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
|
|
1123
|
+
* A finished batch (report written) is left alone.
|
|
1124
|
+
*/
|
|
1125
|
+
async function cancelBatch({ id, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1126
|
+
if (!id || String(id).startsWith("--")) throw new Error(ORCH_USAGE);
|
|
1127
|
+
let res = viaDaemon ? await viaDaemon() : null;
|
|
1128
|
+
if (!res) {
|
|
1129
|
+
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1130
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1131
|
+
if (b.readReport()) res = { cancelled: false, finished: true };
|
|
1132
|
+
else { b.requestCancel({ reason: "operator" }); res = { cancelled: true }; }
|
|
1133
|
+
}
|
|
1134
|
+
print(res.cancelled ? `batch ${id}: cancel requested; running lanes stop through their teardown, the rest stay held (forgedock orchestrate --resume ${id} continues)`
|
|
1135
|
+
: `batch ${id}: already finished`);
|
|
1136
|
+
return { ok: true, batch: id, cancelled: !!res.cancelled };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
980
1139
|
/**
|
|
981
1140
|
* Daemon client (#4192): argument checks here, then submit / attach / set-parallel over the schedd socket. Prints the
|
|
982
1141
|
* streamed rows and lines like the in-process run and returns the same `{rows, crossLane, batch, ok}`.
|
|
@@ -1002,7 +1161,31 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1002
1161
|
console.log(`batch ${id}: parallel set to ${k}`);
|
|
1003
1162
|
return { ok: true, batch: id, parallel: k };
|
|
1004
1163
|
}
|
|
1164
|
+
// #4408: --add, and the extra issues of --resume <id> N..., go to the batch (through the daemon when one runs).
|
|
1165
|
+
const addVia = (id) => async (issues) => {
|
|
1166
|
+
let sock = null;
|
|
1167
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1168
|
+
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1169
|
+
};
|
|
1170
|
+
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1171
|
+
if (argv.includes("--cancel")) {
|
|
1172
|
+
const id = flag(argv, "--cancel");
|
|
1173
|
+
return cancelBatch({ id, root: await batchRootOf(), viaDaemon: async () => {
|
|
1174
|
+
let sock = null;
|
|
1175
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1176
|
+
return sock ? schedd.request(sock, { op: "cancel", id }) : null;
|
|
1177
|
+
} });
|
|
1178
|
+
}
|
|
1179
|
+
if (argv.includes("--add")) {
|
|
1180
|
+
const i = argv.indexOf("--add"), id = argv[i + 1];
|
|
1181
|
+
if (!id || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
1182
|
+
return addToBatch({ id, tokens: operandsOf(argv.slice(i + 2)), root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(id) });
|
|
1183
|
+
}
|
|
1005
1184
|
const resumeId = flag(argv, "--resume");
|
|
1185
|
+
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1186
|
+
if (resumeExtra.length) {
|
|
1187
|
+
await addToBatch({ id: resumeId, tokens: resumeExtra, root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(resumeId) });
|
|
1188
|
+
}
|
|
1006
1189
|
let msg;
|
|
1007
1190
|
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
1008
1191
|
else {
|
|
@@ -1039,7 +1222,7 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1039
1222
|
* `daemon: true` and the allowlisted env in its plan, so a restarted daemon resumes it on the same account; `keepBreaker`
|
|
1040
1223
|
* (a daemon restart, not the operator's resume) leaves a tripped breaker tripped. Writes `report.json` when done.
|
|
1041
1224
|
*/
|
|
1042
|
-
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false }) {
|
|
1225
|
+
export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: callerEnv = process.env, sink = consoleSink(), deps = {}, keepBreaker = false, daemon = false, signal = null }) {
|
|
1043
1226
|
const batchMod = await import("./lean/batch.mjs");
|
|
1044
1227
|
const root = deps.batchRoot ?? batchMod.batchRoot();
|
|
1045
1228
|
if (argv.includes("--set-parallel")) {
|
|
@@ -1051,7 +1234,17 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1051
1234
|
sink.line(`batch ${b.id}: parallel set to ${k}`);
|
|
1052
1235
|
return { ok: true, batch: b.id, parallel: k };
|
|
1053
1236
|
}
|
|
1237
|
+
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
|
|
1238
|
+
if (argv.includes("--add")) {
|
|
1239
|
+
const i = argv.indexOf("--add");
|
|
1240
|
+
if (!argv[i + 1] || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
1241
|
+
return addToBatch({ id: argv[i + 1], tokens: operandsOf(argv.slice(i + 2)), root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1242
|
+
}
|
|
1054
1243
|
const resumeId = flag(argv, "--resume");
|
|
1244
|
+
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1245
|
+
if (resumeExtra.length) {
|
|
1246
|
+
await addToBatch({ id: resumeId, tokens: resumeExtra, root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1247
|
+
}
|
|
1055
1248
|
let batch, plan = null;
|
|
1056
1249
|
if (resumeId) {
|
|
1057
1250
|
batch = batchMod.openBatch(root, resumeId);
|
|
@@ -1086,19 +1279,30 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1086
1279
|
// A resume is the operator's decision to continue: the breaker starts closed again.
|
|
1087
1280
|
const st = batch.store.load();
|
|
1088
1281
|
if (st && !keepBreaker) batch.store.save({ ...st, breaker: null, errorTimes: [] });
|
|
1282
|
+
if (!keepBreaker) batch.clearCancel(); // #4376: the operator's resume continues a cancelled batch
|
|
1089
1283
|
if (daemon && !plan.daemon) batch.writePlan({ ...plan, daemon: true }); // a daemon restart resumes it from now on
|
|
1090
1284
|
batch.clearReport();
|
|
1091
1285
|
}
|
|
1092
1286
|
sink.batch?.(batch);
|
|
1093
1287
|
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
1094
1288
|
const dir = deps.dir ?? runDir();
|
|
1095
|
-
|
|
1289
|
+
migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
|
|
1290
|
+
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
|
|
1096
1291
|
const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
|
|
1292
|
+
const { formatFirstPassSummary } = await import("./lean/fixround.mjs");
|
|
1293
|
+
const { formatDecompositionSummary } = await import("./lean/decompose.mjs");
|
|
1294
|
+
const { formatShadowSummary } = await import("./lean/conformance.mjs");
|
|
1097
1295
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1296
|
+
// #4399: declared acceptance probes of closed issues whose fix reached the default branch; never fails the batch
|
|
1297
|
+
const sweep = deps.sweepProbes !== undefined ? deps.sweepProbes : (await import("./lean/probe.mjs")).sweepProbes;
|
|
1298
|
+
if (sweep && !argv.includes("--no-probes")) {
|
|
1299
|
+
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)})`); }
|
|
1300
|
+
}
|
|
1301
|
+
const { makePrefilter, formatPrefilterSummary, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1101
1302
|
const branches = readBranches(cwd);
|
|
1303
|
+
// #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
|
|
1304
|
+
const prefilter = argv.includes("--no-prefilter") ? null
|
|
1305
|
+
: makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
|
|
1102
1306
|
const fetched = new Set();
|
|
1103
1307
|
const laneOf = async (n) => {
|
|
1104
1308
|
const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
|
|
@@ -1114,7 +1318,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1114
1318
|
const { runCore } = deps.leanModule;
|
|
1115
1319
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1116
1320
|
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,
|
|
1117
|
-
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) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(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) } : {}) }) };
|
|
1321
|
+
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) } : {}), 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) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1118
1322
|
} else {
|
|
1119
1323
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1120
1324
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1174,6 +1378,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1174
1378
|
} else {
|
|
1175
1379
|
lanes = withPlacement(batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs }));
|
|
1176
1380
|
}
|
|
1381
|
+
// #4376: a cancel stops detached host lanes by SIGTERM to their group (pid-reuse checked by the result path); sandboxes run on.
|
|
1382
|
+
const { killLedgerPid } = await import("./lean/effects.mjs");
|
|
1383
|
+
const stopLane = deps.stopLane ?? ((n, rec) => (rec?.pid && !rec.remote && !rec.handle ? killLedgerPid(rec.pid, batch.resultFile(n)) : false));
|
|
1384
|
+
lanes = { ...lanes, stopLane };
|
|
1177
1385
|
}
|
|
1178
1386
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1179
1387
|
// #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
|
|
@@ -1220,13 +1428,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1220
1428
|
const crossLane = [];
|
|
1221
1429
|
// #4183: owned-resource GC in preflight and before an admission when the host is near the ceiling (forge.yaml hygiene).
|
|
1222
1430
|
const gcMod = await import("./gc.mjs");
|
|
1431
|
+
// #4361: GC never holds admission for long; what it cannot finish in the budget resumes at the next beforeAdmit interval.
|
|
1432
|
+
const GC_BUDGET_MS = 60 * 1000;
|
|
1223
1433
|
const hygiene = deps.hygiene ?? gcMod.readHygiene(cwd);
|
|
1224
1434
|
const testDeps = deps.leanIo || deps.leanModule || deps.laneScript;
|
|
1225
1435
|
const gcRun = !hygiene.auto_gc ? null : deps.gc !== undefined ? deps.gc
|
|
1226
|
-
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene });
|
|
1436
|
+
: testDeps ? null : async () => gcMod.runOwnedGc({ probes: gcMod.makeGcProbes({ cwd }), hygiene, budgetMs: GC_BUDGET_MS });
|
|
1227
1437
|
const reclaimed = { enabled: !!hygiene.auto_gc, items: 0, bytes: 0, preflight: 0, admissions: 0, runs: 0 };
|
|
1228
1438
|
const nowMs = deps.now ?? (() => Date.now());
|
|
1229
|
-
let lastGcAt = null;
|
|
1439
|
+
let lastGcAt = null, gcPending = false;
|
|
1230
1440
|
const runGcPhase = async (phase) => {
|
|
1231
1441
|
if (!gcRun) return;
|
|
1232
1442
|
lastGcAt = nowMs();
|
|
@@ -1236,6 +1446,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1236
1446
|
reclaimed.items += n; reclaimed.bytes += Number(r.bytes) || 0; reclaimed.runs++;
|
|
1237
1447
|
reclaimed[phase === "preflight" ? "preflight" : "admissions"] += n;
|
|
1238
1448
|
for (const note of r.plan?.notes ?? []) console.log(`gc (${phase}): ${note}`);
|
|
1449
|
+
gcPending = Number(r.deferred) > 0;
|
|
1450
|
+
if (gcPending) console.log(`gc (${phase}): ${Number(r.deferred)} item(s) deferred to the next admission`);
|
|
1239
1451
|
} catch (e) { console.log(`gc (${phase}) failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1240
1452
|
};
|
|
1241
1453
|
await runGcPhase("preflight");
|
|
@@ -1248,7 +1460,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1248
1460
|
const host = deps.host ?? (await import("./lean/effects.mjs")).makeHostProbe();
|
|
1249
1461
|
near = await nearCeiling({ cfg: readAdmission(cwd), host, ratio: hygiene.max_host_load_ratio });
|
|
1250
1462
|
} catch { near = false; }
|
|
1251
|
-
if (near) await runGcPhase("admission");
|
|
1463
|
+
if (near || gcPending) await runGcPhase("admission");
|
|
1252
1464
|
};
|
|
1253
1465
|
// #4318: SIGINT/SIGTERM of a foreground dispatcher run the batch-exit relabel, then exit 128+signo (the lane pattern above).
|
|
1254
1466
|
let exitRelabel = null, interrupted = false;
|
|
@@ -1262,13 +1474,21 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1262
1474
|
exitProc(128 + SIGNO[sig]);
|
|
1263
1475
|
}]));
|
|
1264
1476
|
for (const [sig, f] of Object.entries(handlers)) on(sig, f);
|
|
1477
|
+
// #4409: longest-first admission; lane estimates from the size label, a post-deploy-only body and this repo's run history.
|
|
1478
|
+
let estimate = deps.estimate;
|
|
1479
|
+
if (estimate === undefined) {
|
|
1480
|
+
let history = {};
|
|
1481
|
+
try { const { readLocalRuns } = await import("./stats/local-source.mjs"); history = laneHistory(readLocalRuns(dir), repo); } catch { history = {}; }
|
|
1482
|
+
estimate = async (n, { body }) => estimateLaneMinutes({ labels: await labelNames(n), body, history });
|
|
1483
|
+
}
|
|
1265
1484
|
let rows;
|
|
1266
1485
|
try {
|
|
1267
1486
|
rows = await orchestrate({
|
|
1487
|
+
...(estimate ? { estimate } : {}),
|
|
1268
1488
|
beforeAdmit,
|
|
1269
|
-
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
|
|
1489
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
|
|
1270
1490
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1271
|
-
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1491
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1272
1492
|
...(remote ? { remote } : {}),
|
|
1273
1493
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1274
1494
|
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
@@ -1279,8 +1499,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1279
1499
|
}),
|
|
1280
1500
|
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1281
1501
|
relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
|
|
1502
|
+
// #4481: a requeued or resumed issue that was closed or whose lane PR merged meanwhile is not run again
|
|
1503
|
+
dispatchCheck: deps.dispatchCheck !== undefined ? deps.dispatchCheck : makeDispatchCheck({ gh: (a) => leanIo.gh(a), repo }),
|
|
1504
|
+
// #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
|
|
1505
|
+
issueLabels: labelNames,
|
|
1282
1506
|
// #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
|
|
1283
1507
|
...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
|
|
1508
|
+
// #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
|
|
1509
|
+
...(signal ? { signal } : {}),
|
|
1284
1510
|
conflicting: deps.conflicting ?? (async (_n, pr) => {
|
|
1285
1511
|
try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
|
|
1286
1512
|
}),
|
|
@@ -1303,6 +1529,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1303
1529
|
if (waitLine) report.push(waitLine);
|
|
1304
1530
|
const precheckLine = formatPrecheckSummary(rows); // #4331
|
|
1305
1531
|
if (precheckLine) report.push(precheckLine);
|
|
1532
|
+
const firstPassLine = formatFirstPassSummary(rows); // #4362
|
|
1533
|
+
if (firstPassLine) report.push(firstPassLine);
|
|
1534
|
+
const turnLine = formatTurnSummary(rows); // #4392
|
|
1535
|
+
if (turnLine) report.push(turnLine);
|
|
1536
|
+
const estLine = formatEstimateSummary(rows); // #4409
|
|
1537
|
+
if (estLine) report.push(estLine);
|
|
1538
|
+
try { // #4402: $/merge, turns, context, retries by cause, no-action lanes, issue quality, acceptance; never fails a batch
|
|
1539
|
+
const { batchEfficiency, formatEfficiencyLines } = await import("./stats/efficiency.mjs");
|
|
1540
|
+
report.push(...formatEfficiencyLines(await (deps.efficiency ?? batchEfficiency)({ batchDir: batch.dir, runsDir: dir, repo, gh: (a) => leanIo.gh(a), rows })));
|
|
1541
|
+
} catch (e) { report.push(`efficiency: n/a (${String(e?.message ?? e).split("\n")[0].slice(0, 160)})`); }
|
|
1542
|
+
const shadowLine = formatShadowSummary(rows); // #4398
|
|
1543
|
+
if (shadowLine) report.push(shadowLine);
|
|
1544
|
+
const decompLine = formatDecompositionSummary(rows); // #4396
|
|
1545
|
+
if (decompLine) report.push(decompLine);
|
|
1306
1546
|
let day = null;
|
|
1307
1547
|
try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
|
|
1308
1548
|
const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
|
|
@@ -1310,12 +1550,48 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1310
1550
|
if (crossLane.length) report.push(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
|
|
1311
1551
|
if (rows.some((r) => r.outcome === "held")) report.push(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
|
|
1312
1552
|
report.push(formatReclaimed(reclaimed));
|
|
1553
|
+
try { // #4354: fail-soft alerts for the batch's repo from the local run logs, last; nothing when none fired
|
|
1554
|
+
const [{ computeAlerts }, { readLanes }, { readAlertConfig }] = await Promise.all([import("./stats/aggregate.mjs"), import("./stats/local-source.mjs"), import("./stats/index.mjs")]);
|
|
1555
|
+
report.push(...formatAlertLines(computeAlerts(readLanes(dir), { repo, ...(deps.alertConfig ?? readAlertConfig(cwd)) })));
|
|
1556
|
+
} catch { /* alerts never fail a batch */ }
|
|
1313
1557
|
for (const l of report) sink.line(l);
|
|
1314
1558
|
const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
|
|
1315
1559
|
try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
|
|
1316
1560
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1317
1561
|
}
|
|
1318
1562
|
|
|
1563
|
+
/**
|
|
1564
|
+
* #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
|
|
1565
|
+
* (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
|
|
1566
|
+
* unreadable (no state, non-JSON, a non-array PR list, a gh error) is null: the lane runs.
|
|
1567
|
+
* Release review #4500: an OPEN issue reopened after that PR merged (a failed acceptance probe, #4399, or an operator
|
|
1568
|
+
* reopening it for rework) is new work, so the lane runs; an unreadable reopen history also runs the lane.
|
|
1569
|
+
*/
|
|
1570
|
+
export function makeDispatchCheck({ gh, repo }) {
|
|
1571
|
+
const json = async (args) => { try { return JSON.parse(String(await gh(args))); } catch { return null; } };
|
|
1572
|
+
/** Latest `reopened` event time (ms) on the issue, 0 when never reopened, null when unreadable. */
|
|
1573
|
+
const lastReopen = async (n) => {
|
|
1574
|
+
try {
|
|
1575
|
+
const out = String(await gh(["api", `repos/${repo}/issues/${n}/events`, "--paginate", "--jq", '.[] | select(.event == "reopened") | .created_at']));
|
|
1576
|
+
return out.split("\n").map((l) => Date.parse(l.trim())).filter(Number.isFinite).reduce((a, b) => Math.max(a, b), 0);
|
|
1577
|
+
} catch { return null; }
|
|
1578
|
+
};
|
|
1579
|
+
return async (n) => {
|
|
1580
|
+
const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
|
|
1581
|
+
const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
|
|
1582
|
+
const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
|
|
1583
|
+
const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
|
|
1584
|
+
if (pr !== null && state === "OPEN") {
|
|
1585
|
+
const mergedAt = Date.parse(prs[0]?.mergedAt ?? "");
|
|
1586
|
+
const reopened = await lastReopen(n);
|
|
1587
|
+
if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
|
|
1588
|
+
}
|
|
1589
|
+
if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
|
|
1590
|
+
if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
|
|
1591
|
+
return null;
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1319
1595
|
/**
|
|
1320
1596
|
* #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
|
|
1321
1597
|
* never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
|
|
@@ -1363,7 +1639,8 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1363
1639
|
if (argv.length) throw new Error("usage: forgedock schedd [status]");
|
|
1364
1640
|
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }) });
|
|
1365
1641
|
if (!d.running) return d;
|
|
1366
|
-
|
|
1642
|
+
// #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
|
|
1643
|
+
schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
|
|
1367
1644
|
await d.closed;
|
|
1368
1645
|
return { running: false };
|
|
1369
1646
|
}
|
|
@@ -1396,6 +1673,71 @@ export async function limitsFromCli(argv, deps = {}) {
|
|
|
1396
1673
|
throw new Error(usage);
|
|
1397
1674
|
}
|
|
1398
1675
|
|
|
1676
|
+
/**
|
|
1677
|
+
* `forgedock check [--repo DIR] [--base REF] [--json]` (#4364): runs in the lane worktree what the engine runs after the
|
|
1678
|
+
* builder: the frozen validate commands (.forge-lean/contract.json) and the change-scoped pre-PR checks, with the plan
|
|
1679
|
+
* the engine wrote (.forge-lean/check-plan.json; else this checkout's forge.yaml) and the caller's env (lane TMPDIR,
|
|
1680
|
+
* pinned toolchain, heavy shims). Local only: no gh, no push, no workflow. Prints a pass/fail table, records the run in
|
|
1681
|
+
* .forge-lean/self-check.json, and returns {ok, exitCode} (0 pass, 1 fail, 2 usage).
|
|
1682
|
+
*/
|
|
1683
|
+
export async function checkFromCli(argv, deps = {}) {
|
|
1684
|
+
const usage = "usage: forgedock check [--repo DIR] [--base REF] [--json]";
|
|
1685
|
+
const pre = await import("./lean/precheck.mjs");
|
|
1686
|
+
const { readTestMap, readTestMapAtRef } = await import("./lean/testmap.mjs");
|
|
1687
|
+
const fsp = await import("node:fs/promises");
|
|
1688
|
+
const out = deps.out ?? ((l) => console.log(l)), env = deps.env ?? process.env;
|
|
1689
|
+
const o = {};
|
|
1690
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1691
|
+
const t = argv[i];
|
|
1692
|
+
if (t === "--repo" || t === "--base") { if (!argv[i + 1]) throw Object.assign(new Error(usage), { exitCode: 2 }); o[t.slice(2)] = argv[++i]; }
|
|
1693
|
+
else if (t === "--json") o.json = true;
|
|
1694
|
+
else throw Object.assign(new Error(usage), { exitCode: 2 });
|
|
1695
|
+
}
|
|
1696
|
+
const run0 = deps.git ?? (async (args, cwd) => (await promisify(execFile)("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 })).stdout);
|
|
1697
|
+
const repo = resolvePath(o.repo ?? String(await run0(["rev-parse", "--show-toplevel"], deps.cwd ?? process.cwd())).trim());
|
|
1698
|
+
const git = (args) => run0(args, repo);
|
|
1699
|
+
const readJson = async (f) => { try { return JSON.parse(await (deps.readFile ?? ((p) => fsp.readFile(p, "utf8")))(join(repo, f))); } catch { return null; } };
|
|
1700
|
+
const contract = await readJson(".forge-lean/contract.json");
|
|
1701
|
+
if (!contract) throw Object.assign(new Error(`check: no .forge-lean/contract.json in ${repo}\n${usage}`), { exitCode: 2 });
|
|
1702
|
+
const plan = await readJson(pre.CHECK_PLAN_FILE);
|
|
1703
|
+
const base = o.base ?? plan?.base;
|
|
1704
|
+
if (!base) throw Object.assign(new Error(`check: no check plan from the engine; pass --base REF\n${usage}`), { exitCode: 2 });
|
|
1705
|
+
const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
|
|
1706
|
+
const learned = plan ? plan.learned : readPrecheckCommands(repo) ?? [];
|
|
1707
|
+
const maxMinutes = plan ? plan.maxMinutes : readPrecheckBudget(repo);
|
|
1708
|
+
// the engine's changed files (lean.mjs changedFiles: base...HEAD still differing from base), plus uncommitted work
|
|
1709
|
+
const names = (x) => String(x ?? "").split("\n").map((f) => f.trim()).filter(Boolean);
|
|
1710
|
+
const touched = names(await git(["diff", "--name-only", `${base}...HEAD`])), differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
1711
|
+
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"]))])]
|
|
1712
|
+
.filter((f) => !f.startsWith(".forge-lean/"));
|
|
1713
|
+
const testMap = (await readTestMapAtRef(git, base)) ?? (await readTestMap([repo], (p) => fsp.readFile(p, "utf8")));
|
|
1714
|
+
const picked = pre.planPrecheck({ testMap, files, learned, maxMinutes, verify });
|
|
1715
|
+
const par = String(env.FORGE_HOST_PARALLELISM ?? "").trim(), pinned = /^\d+$/.test(par) && Number(par) > 0;
|
|
1716
|
+
const runCommand = deps.runCommand ?? (await import("./lean/effects.mjs")).runCheckCommand;
|
|
1717
|
+
const r = await pre.selfCheck({ contract, plan: { mode: verify.mode, commands: picked.commands, budgetMs: picked.budgetMs, perCommandMs: picked.perCommandMs },
|
|
1718
|
+
parallelism: pinned ? Number(par) : 1, now: deps.now,
|
|
1719
|
+
run: ({ command, timeoutMs, workers }) => runCommand({ command, cwd: repo, timeoutMs, env: pinned ? { FORGE_HOST_PARALLELISM: String(workers) } : null }) });
|
|
1720
|
+
// #4390: each failure's full output goes to .forge-lean/logs/ (git-excluded); the table shows its tail, ids and the path
|
|
1721
|
+
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);
|
|
1722
|
+
if (failing.length) {
|
|
1723
|
+
try {
|
|
1724
|
+
await (deps.mkdir ?? ((p) => fsp.mkdir(p, { recursive: true })))(join(repo, pre.CHECK_LOG_DIR));
|
|
1725
|
+
for (const [x, name] of failing) {
|
|
1726
|
+
const rel = `${pre.CHECK_LOG_DIR}/${name.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
|
|
1727
|
+
await (deps.writeFile ?? ((p, t) => fsp.writeFile(p, t)))(join(repo, rel), String(x.output ?? ""));
|
|
1728
|
+
x.logPath = rel;
|
|
1729
|
+
}
|
|
1730
|
+
} catch { /* no log files: the table still shows the tails */ }
|
|
1731
|
+
}
|
|
1732
|
+
let fingerprint = null;
|
|
1733
|
+
try { fingerprint = await pre.worktreeFingerprint({ git, base }); } catch { /* recorded without one: the engine reads it as skipped */ }
|
|
1734
|
+
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 });
|
|
1735
|
+
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" }))] };
|
|
1736
|
+
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 */ }
|
|
1737
|
+
out(o.json ? JSON.stringify({ ...r, base, fingerprint }, null, 2) : pre.formatCheckTable(r));
|
|
1738
|
+
return { ...r, base, fingerprint, files, exitCode: r.ok ? 0 : 1 };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1399
1741
|
/**
|
|
1400
1742
|
* `forgedock sched show` · `forgedock sched cap <N>|--clear` (#4191) · `forgedock sched weight <owner/repo> <N>|--clear` (#4193)
|
|
1401
1743
|
* The host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`) shared by every `forgedock orchestrate` batch.
|
|
@@ -1516,8 +1858,8 @@ function prefilterModel(argv) {
|
|
|
1516
1858
|
* `forgedock triage <issue...> [--parallel K] [--lane L] [--repo owner/repo] [--dry-run] [--prefilter-model M]` (#4123)
|
|
1517
1859
|
* Issues are checked through a bounded pool of K (default 8, #4141); each lane is fetched once; rows print in input order.
|
|
1518
1860
|
* Each issue is checked against its own lane (#4125); `--lane` is an assertion, a refused lane is reported and dropped.
|
|
1519
|
-
* Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence)
|
|
1520
|
-
* still need a lane as the last line (`lanes: #1 #2`). `--dry-run` reports candidates without a model call or close.
|
|
1861
|
+
* Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence), parks post-deploy follow-ups
|
|
1862
|
+
* 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.
|
|
1521
1863
|
*/
|
|
1522
1864
|
export async function triageFromCli(argv, deps = {}) {
|
|
1523
1865
|
const valued = ["--lane", "--repo", "--prefilter-model", "--parallel"];
|
|
@@ -1532,8 +1874,9 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1532
1874
|
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
1533
1875
|
if (!repo) throw new Error("triage needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
1534
1876
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1535
|
-
const { prefilterIssue, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
1877
|
+
const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
|
|
1536
1878
|
const branches = readBranches();
|
|
1879
|
+
const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() }; // #4389, #4425
|
|
1537
1880
|
const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
|
|
1538
1881
|
if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
|
|
1539
1882
|
// #4141: bounded pool. Each lane is fetched once (memoized promise); output stays in input order.
|
|
@@ -1549,7 +1892,8 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1549
1892
|
const ln = await resolveLane({ issue: n, repo, io: leanIo, cwd: process.cwd(), branches, requested: lane, override: argv.includes("--lane-override") });
|
|
1550
1893
|
if (!ln.ok) return { line: `#${n} → lane-refused (${ln.code}: ${ln.detail})`, row: { issue: n, outcome: "lane-refused", cost_usd: 0, prefilter: null } };
|
|
1551
1894
|
await fetchLane(ln.lane);
|
|
1552
|
-
const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv) });
|
|
1895
|
+
const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv), ...gate });
|
|
1896
|
+
if (pf.parked) return { line: `#${n} → parked (${pf.kind}: ${pf.detail})`, row: { issue: n, outcome: pf.kind, cost_usd: 0, prefilter: pf } };
|
|
1553
1897
|
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 } };
|
|
1554
1898
|
};
|
|
1555
1899
|
let next = 0, printed = 0;
|
|
@@ -1564,8 +1908,35 @@ export async function triageFromCli(argv, deps = {}) {
|
|
|
1564
1908
|
}
|
|
1565
1909
|
}));
|
|
1566
1910
|
const rows = out.map((r) => r.row);
|
|
1567
|
-
const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed).map((r) => r.issue);
|
|
1911
|
+
const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed && !r.prefilter.parked).map((r) => r.issue);
|
|
1568
1912
|
console.log(formatPrefilterSummary(rows));
|
|
1913
|
+
const parked = formatParkedLine(rows);
|
|
1914
|
+
if (parked) console.log(parked);
|
|
1569
1915
|
console.log(`lanes: ${lanes.map((n) => `#${n}`).join(" ") || "(none)"}`);
|
|
1570
1916
|
return { rows, lanes };
|
|
1571
1917
|
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* `forgedock probe [issue...] [--repo owner/repo] [--dry-run] [--limit N]` (#4399)
|
|
1921
|
+
* Runs the declared acceptance probes (`lean/probe.mjs`) of the given issues, or of the recently closed issues that
|
|
1922
|
+
* declare one, once their fix is on the default branch; records FORGE:PROBE and reopens on a fail. No model call.
|
|
1923
|
+
*/
|
|
1924
|
+
export async function probeFromCli(argv, deps = {}) {
|
|
1925
|
+
const valued = ["--repo", "--limit"];
|
|
1926
|
+
const issues = [];
|
|
1927
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1928
|
+
if (valued.includes(argv[i])) { i++; continue; }
|
|
1929
|
+
if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
|
|
1930
|
+
}
|
|
1931
|
+
const io = deps.io ?? makeIo();
|
|
1932
|
+
const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
|
|
1933
|
+
if (!repo) throw new Error("probe needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
1934
|
+
const limit = flag(argv, "--limit") == null ? 50 : parseInt(flag(argv, "--limit"), 10);
|
|
1935
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer");
|
|
1936
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1937
|
+
const { sweepProbes, formatProbeSummary } = await import("./lean/probe.mjs");
|
|
1938
|
+
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 } : {}) });
|
|
1939
|
+
for (const r of rows) console.log(`#${r.issue} → ${r.outcome}${r.detail ? ` (${r.detail})` : ""}`);
|
|
1940
|
+
console.log(formatProbeSummary(rows) || "probes: nothing to run");
|
|
1941
|
+
return { rows };
|
|
1942
|
+
}
|