forgedock 1.34.0 → 1.35.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-cli.mjs +72 -12
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +33 -5
- package/bin/lean/batch.mjs +24 -1
- package/bin/lean/batch.test.mjs +75 -0
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +16 -6
- package/bin/lean/conformance.test.mjs +25 -0
- package/bin/lean/effects.mjs +11 -3
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lean.mjs +111 -33
- package/bin/lean/lean.test.mjs +197 -5
- package/bin/lean/orchestrate.mjs +81 -8
- package/bin/lean/precheck.mjs +56 -8
- package/bin/lean/precheck.test.mjs +62 -3
- package/bin/lean/prompts.mjs +27 -5
- package/bin/lean/schedd.mjs +10 -1
- package/bin/lean/schedd.test.mjs +30 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/docs/CONFIG.md +12 -2
- package/package.json +1 -1
package/bin/engine-cli.mjs
CHANGED
|
@@ -214,6 +214,19 @@ export function readBuilderRouting(cwd = process.cwd()) {
|
|
|
214
214
|
const midModel = /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : readLeanSetting("builder_mid_model", cwd);
|
|
215
215
|
return mode || midModel ? { ...(mode ? { mode } : {}), ...(midModel ? { midModel } : {}) } : undefined;
|
|
216
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* forge.yaml `lean.base_merge_*` (#4588): deterministic base-merge resolution of sibling-lane conflicts. Comma lists of globs
|
|
219
|
+
* `union_globs` (append-only), `json_globs` (JSON maps, key union), `generated_globs` + `generated_command` (base side taken,
|
|
220
|
+
* then regenerated) (flat keys only). undefined when nothing is configured.
|
|
221
|
+
*/
|
|
222
|
+
export function readBaseMerge(cwd = process.cwd()) {
|
|
223
|
+
let l = {};
|
|
224
|
+
try { l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {}; } catch { return undefined; }
|
|
225
|
+
const list = (v) => String(v ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
226
|
+
const cfg = { unionGlobs: list(l.base_merge_union_globs), jsonGlobs: list(l.base_merge_json_globs), generatedGlobs: list(l.base_merge_generated_globs), generatedCommand: String(l.base_merge_generated_command ?? "").trim() };
|
|
227
|
+
return cfg.unionGlobs.length || cfg.jsonGlobs.length || cfg.generatedGlobs.length ? cfg : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
217
230
|
/**
|
|
218
231
|
* forge.yaml `lean.sequence.regenerate` (#4386): the command that regenerates a schema-changing migration's snapshot
|
|
219
232
|
* after a deterministic renumber. The one-level parser flattens the nested block to `lean.regenerate`; flat
|
|
@@ -323,7 +336,9 @@ export function readMergeWait(cwd = process.cwd()) {
|
|
|
323
336
|
export function readVerify(cwd = process.cwd()) {
|
|
324
337
|
try {
|
|
325
338
|
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
|
|
326
|
-
|
|
339
|
+
if (!sec || typeof sec !== "object") return undefined;
|
|
340
|
+
const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky");
|
|
341
|
+
return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}) });
|
|
327
342
|
} catch { return undefined; }
|
|
328
343
|
}
|
|
329
344
|
/** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
|
|
@@ -350,20 +365,28 @@ export function readExecution(cwd = process.cwd()) {
|
|
|
350
365
|
* scalars only. undefined when absent, empty or unreadable.
|
|
351
366
|
*/
|
|
352
367
|
export function readPrecheckCommands(cwd = process.cwd()) {
|
|
368
|
+
return readYamlList(cwd, "learned", "test_commands");
|
|
369
|
+
}
|
|
370
|
+
/** #4585: forge.yaml `verify.invariants`, a block list of repo-wide check commands; undefined when absent or empty. */
|
|
371
|
+
export function readInvariants(cwd = process.cwd()) {
|
|
372
|
+
return readYamlList(cwd, "verify", "invariants");
|
|
373
|
+
}
|
|
374
|
+
function readYamlList(cwd, section, key) {
|
|
353
375
|
let raw;
|
|
354
376
|
try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
|
|
355
377
|
const out = [];
|
|
356
|
-
|
|
378
|
+
const head = new RegExp(`^${section}:\\s*(#.*)?$`), keyRe = new RegExp(`^\\s+${key}:\\s*(#.*)?$`);
|
|
379
|
+
let inSection = false, listIndent = null;
|
|
357
380
|
for (const line of raw.split(/\r?\n/)) {
|
|
358
381
|
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
359
|
-
if (/^\S/.test(line)) {
|
|
360
|
-
if (!
|
|
382
|
+
if (/^\S/.test(line)) { inSection = head.test(line); listIndent = null; continue; }
|
|
383
|
+
if (!inSection) continue;
|
|
361
384
|
const indent = line.length - line.trimStart().length;
|
|
362
385
|
if (listIndent === null) {
|
|
363
|
-
if (
|
|
386
|
+
if (keyRe.test(line)) listIndent = indent;
|
|
364
387
|
continue;
|
|
365
388
|
}
|
|
366
|
-
if (indent <= listIndent) { listIndent =
|
|
389
|
+
if (indent <= listIndent) { listIndent = keyRe.test(line) ? indent : null; continue; }
|
|
367
390
|
const m = /^\s+-\s+(.*)$/.exec(line);
|
|
368
391
|
if (!m) continue;
|
|
369
392
|
let v = m[1].trim();
|
|
@@ -854,7 +877,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
854
877
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
855
878
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
856
879
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
857
|
-
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() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
|
|
880
|
+
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() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}) });
|
|
858
881
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
859
882
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
860
883
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1065,7 +1088,7 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
1065
1088
|
const formatRowLine = (r) => (r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`);
|
|
1066
1089
|
const consoleSink = () => ({ batch: () => {}, row: (r) => console.log(formatRowLine(r)), line: (t) => console.log(t) });
|
|
1067
1090
|
|
|
1068
|
-
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>";
|
|
1091
|
+
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> | --hold <id> <issue...> | --release <id> <issue...>";
|
|
1069
1092
|
const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
|
|
1070
1093
|
const issuesOf = (argv) => {
|
|
1071
1094
|
const issues = [];
|
|
@@ -1123,6 +1146,29 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1123
1146
|
return out;
|
|
1124
1147
|
}
|
|
1125
1148
|
|
|
1149
|
+
/** #4592: a resume's release line, only when it freed something. */
|
|
1150
|
+
const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
|
|
1154
|
+
* when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
|
|
1155
|
+
* names it) lets it run again. @returns {{ok, batch, changed:number[]}}
|
|
1156
|
+
*/
|
|
1157
|
+
async function holdBatch({ op, id, tokens, root, viaDaemon = null, print = (l) => console.log(l) }) {
|
|
1158
|
+
const issues = tokens.filter((t) => /^#?\d+$/.test(String(t))).map((t) => parseInt(String(t).replace("#", ""), 10));
|
|
1159
|
+
if (!id || String(id).startsWith("--") || !issues.length) throw new Error(ORCH_USAGE);
|
|
1160
|
+
let res = viaDaemon ? await viaDaemon(op, issues) : null;
|
|
1161
|
+
if (!res) {
|
|
1162
|
+
const b = (await import("./lean/batch.mjs")).openBatch(root, id);
|
|
1163
|
+
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1164
|
+
res = { changed: op === "hold" ? b.holdIssues(issues) : b.releaseIssues(issues) };
|
|
1165
|
+
}
|
|
1166
|
+
const list = (res.changed ?? []).map((n) => `#${n}`).join(" ") || "(none)";
|
|
1167
|
+
print(op === "hold" ? `batch ${id}: held ${list}; forgedock orchestrate --release ${id} <issue...> (or --resume ${id} <issue...>) re-admits them`
|
|
1168
|
+
: `batch ${id}: released ${list}`);
|
|
1169
|
+
return { ok: true, batch: id, changed: res.changed ?? [] };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1126
1172
|
/**
|
|
1127
1173
|
* `--cancel <id>` (#4376): through the daemon (`viaDaemon`, null when none runs) or the batch's `cancel` file directly.
|
|
1128
1174
|
* A finished batch (report written) is left alone.
|
|
@@ -1172,6 +1218,11 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1172
1218
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1173
1219
|
return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
|
|
1174
1220
|
};
|
|
1221
|
+
const holdVia = (id) => async (op, issues) => {
|
|
1222
|
+
let sock = null;
|
|
1223
|
+
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
|
|
1224
|
+
return sock ? schedd.request(sock, { op, id, issues }) : null;
|
|
1225
|
+
};
|
|
1175
1226
|
const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
|
|
1176
1227
|
if (argv.includes("--cancel")) {
|
|
1177
1228
|
const id = flag(argv, "--cancel");
|
|
@@ -1181,6 +1232,10 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1181
1232
|
return sock ? schedd.request(sock, { op: "cancel", id }) : null;
|
|
1182
1233
|
} });
|
|
1183
1234
|
}
|
|
1235
|
+
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1236
|
+
const id = flag(argv, `--${op}`);
|
|
1237
|
+
return holdBatch({ op, id, tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root: await batchRootOf(), viaDaemon: holdVia(id) });
|
|
1238
|
+
}
|
|
1184
1239
|
if (argv.includes("--add")) {
|
|
1185
1240
|
const i = argv.indexOf("--add"), id = argv[i + 1];
|
|
1186
1241
|
if (!id || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
@@ -1190,9 +1245,10 @@ async function orchestrateClient(argv, deps = {}) {
|
|
|
1190
1245
|
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1191
1246
|
if (resumeExtra.length) {
|
|
1192
1247
|
await addToBatch({ id: resumeId, tokens: resumeExtra, root: await batchRootOf(), io: deps.io ?? makeIo(), viaDaemon: addVia(resumeId) });
|
|
1248
|
+
await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root: await batchRootOf(), viaDaemon: holdVia(resumeId), print: (l) => onlyReleased(l) }); // #4592: a named, held issue is re-admitted
|
|
1193
1249
|
}
|
|
1194
1250
|
let msg;
|
|
1195
|
-
if (resumeId) msg = { op: "attach", id: resumeId };
|
|
1251
|
+
if (resumeId) msg = { op: "attach", id: resumeId, resume: true };
|
|
1196
1252
|
else {
|
|
1197
1253
|
if (!issuesOf(argv).length) throw new Error(ORCH_USAGE);
|
|
1198
1254
|
parallelOf(argv);
|
|
@@ -1255,6 +1311,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1255
1311
|
return { ok: true, batch: b.id, parallel: k };
|
|
1256
1312
|
}
|
|
1257
1313
|
if (argv.includes("--cancel")) return cancelBatch({ id: flag(argv, "--cancel"), root, print: (l) => sink.line(l) });
|
|
1314
|
+
for (const op of ["hold", "release"]) if (argv.includes(`--${op}`)) {
|
|
1315
|
+
return holdBatch({ op, id: flag(argv, `--${op}`), tokens: operandsOf(argv.slice(argv.indexOf(`--${op}`) + 2)), root, print: (l) => sink.line(l) });
|
|
1316
|
+
}
|
|
1258
1317
|
if (argv.includes("--add")) {
|
|
1259
1318
|
const i = argv.indexOf("--add");
|
|
1260
1319
|
if (!argv[i + 1] || i + 2 >= argv.length) throw new Error(ORCH_USAGE);
|
|
@@ -1264,6 +1323,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1264
1323
|
const resumeExtra = resumeId ? operandsOf(argv.slice(argv.indexOf("--resume") + 2)) : [];
|
|
1265
1324
|
if (resumeExtra.length) {
|
|
1266
1325
|
await addToBatch({ id: resumeId, tokens: resumeExtra, root, io: deps.io ?? makeIo(), print: (l) => sink.line(l) });
|
|
1326
|
+
await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
|
|
1267
1327
|
}
|
|
1268
1328
|
let batch, plan = null;
|
|
1269
1329
|
if (resumeId) {
|
|
@@ -1340,7 +1400,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1340
1400
|
const { runCore } = deps.leanModule;
|
|
1341
1401
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1342
1402
|
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,
|
|
1343
|
-
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) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
|
|
1403
|
+
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) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}) }) };
|
|
1344
1404
|
} else {
|
|
1345
1405
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1346
1406
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
@@ -1520,7 +1580,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1520
1580
|
rows = await orchestrate({
|
|
1521
1581
|
...(estimate ? { estimate } : {}),
|
|
1522
1582
|
beforeAdmit,
|
|
1523
|
-
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
|
|
1583
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, getHeld: batch.readHeld, ...lanes,
|
|
1524
1584
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1525
1585
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1526
1586
|
...(remote ? { remote } : {}),
|
|
@@ -1757,7 +1817,7 @@ export async function checkFromCli(argv, deps = {}) {
|
|
|
1757
1817
|
const plan = await readJson(pre.CHECK_PLAN_FILE);
|
|
1758
1818
|
const base = o.base ?? plan?.base;
|
|
1759
1819
|
if (!base) throw Object.assign(new Error(`check: no check plan from the engine; pass --base REF\n${usage}`), { exitCode: 2 });
|
|
1760
|
-
const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
|
|
1820
|
+
const verify = plan ? { mode: plan.mode, precheckTotalSeconds: plan.precheckTotalSeconds, precheckTimeoutSeconds: plan.precheckTimeoutSeconds, invariants: Array.isArray(plan.invariants) ? plan.invariants : [] } : { ...parseVerify({}), ...(readVerify(repo) ?? {}) };
|
|
1761
1821
|
const learned = plan ? plan.learned : readPrecheckCommands(repo) ?? [];
|
|
1762
1822
|
const maxMinutes = plan ? plan.maxMinutes : readPrecheckBudget(repo);
|
|
1763
1823
|
// the engine's changed files (lean.mjs changedFiles: base...HEAD still differing from base), plus uncommitted work
|
package/bin/forgedock.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
renameSync,
|
|
14
14
|
unlinkSync,
|
|
15
15
|
} from "fs";
|
|
16
|
+
import { gitignoreAdditions } from "./lean/toolstate.mjs";
|
|
16
17
|
import { execSync, execFileSync, spawnSync } from "child_process";
|
|
17
18
|
import { homedir } from "os";
|
|
18
19
|
import https from "https";
|
|
@@ -1025,6 +1026,20 @@ async function statusScreen(c) {
|
|
|
1025
1026
|
* Independent of FORGEDOCK_BACKEND, which controls
|
|
1026
1027
|
* the separate `forgedock run` engine backend.
|
|
1027
1028
|
*/
|
|
1029
|
+
/** #4590: append the tool-state deny-list (.forge-lean/, tool caches) to the repo's .gitignore; append-only, idempotent. */
|
|
1030
|
+
function ensureToolStateIgnored(c) {
|
|
1031
|
+
try {
|
|
1032
|
+
const file = join(c.cwd, ".gitignore");
|
|
1033
|
+
const text = existsSync(file) ? readFileSync(file, "utf-8") : "";
|
|
1034
|
+
let next = false;
|
|
1035
|
+
try { next = Boolean(JSON.parse(readFileSync(join(c.cwd, "package.json"), "utf-8"))?.dependencies?.next); } catch { /* no package.json */ }
|
|
1036
|
+
const add = gitignoreAdditions(text, { next });
|
|
1037
|
+
if (!add.length) return;
|
|
1038
|
+
writeFileSync(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}# ForgeDock lane scratch and tool caches\n${add.join("\n")}\n`);
|
|
1039
|
+
c.stdout.write(` .gitignore: added ${add.join(", ")}\n`);
|
|
1040
|
+
} catch { /* .gitignore is best effort */ }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1028
1043
|
async function initFlow(c) {
|
|
1029
1044
|
const outputPath = join(c.cwd, "forge.yaml");
|
|
1030
1045
|
const hasExisting = existsSync(outputPath);
|
|
@@ -1078,6 +1093,7 @@ async function initFlow(c) {
|
|
|
1078
1093
|
stagingBranch: draft.branches.staging.value,
|
|
1079
1094
|
});
|
|
1080
1095
|
atomicWriteFile(outputPath, content);
|
|
1096
|
+
ensureToolStateIgnored(c);
|
|
1081
1097
|
celebrate(c, { written: true, todoCount: 0, isMinimal: true });
|
|
1082
1098
|
await maybeOfferDemo(c);
|
|
1083
1099
|
return 0;
|
|
@@ -1107,12 +1123,14 @@ async function initFlow(c) {
|
|
|
1107
1123
|
if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
1108
1124
|
const lowKeys = manualLowConfidenceKeys(draft, description, v);
|
|
1109
1125
|
const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
|
|
1126
|
+
ensureToolStateIgnored(c);
|
|
1110
1127
|
celebrate(c, { written: true, todoCount });
|
|
1111
1128
|
await maybeOfferDemo(c);
|
|
1112
1129
|
return 0;
|
|
1113
1130
|
}
|
|
1114
1131
|
const { draft, description } = await setupAfterRead(c, await read(c));
|
|
1115
1132
|
const reviewed = await review(c, draft, description);
|
|
1133
|
+
if (!reviewed.aborted) ensureToolStateIgnored(c);
|
|
1116
1134
|
celebrate(c, reviewed);
|
|
1117
1135
|
if (!reviewed.aborted) await maybeOfferDemo(c);
|
|
1118
1136
|
return reviewed.aborted ? 1 : 0;
|
|
@@ -3297,7 +3315,7 @@ function help() {
|
|
|
3297
3315
|
["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
|
|
3298
3316
|
["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3299
3317
|
["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
|
|
3300
|
-
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id>", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume)"],
|
|
3318
|
+
["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
|
|
3301
3319
|
["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
|
|
3302
3320
|
["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
|
|
3303
3321
|
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
|
|
7
7
|
* definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
|
|
8
8
|
* `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
|
|
9
|
-
*
|
|
9
|
+
* #4580: the same script is also a PreToolUse guard on Read: a full-file Read (no offset/limit) of a path already read
|
|
10
|
+
* REREAD_MIN-1 times whose mtime is unchanged since its last read is denied with a reason that points at notes or a line
|
|
11
|
+
* range. Ranged reads and Bash never deny, and any error allows. A changed mtime resets the path's count.
|
|
12
|
+
* State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json plus {path: mtimeMs} in <session_id>.mtime.json (per-run caches).
|
|
10
13
|
*/
|
|
11
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
15
|
import { tmpdir } from "node:os";
|
|
13
16
|
import { dirname, join, resolve } from "node:path";
|
|
14
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -38,18 +41,43 @@ export function nudgeFor(payload, counts = {}) {
|
|
|
38
41
|
export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
|
|
39
42
|
join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
|
|
40
43
|
|
|
44
|
+
const mtimeOf = (p) => { try { return statSync(p).mtimeMs; } catch { return null; } };
|
|
45
|
+
const loadJson = (file) => { try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v : {}; } catch { return {}; } };
|
|
46
|
+
|
|
47
|
+
/** Pure core of the guard: the deny reason for a payload, or null. `mtimeNow(path)` is the file's current mtime. */
|
|
48
|
+
export function guardFor(payload, counts = {}, mtimes = {}, mtimeNow = mtimeOf) {
|
|
49
|
+
const inp = payload?.tool_input ?? {};
|
|
50
|
+
if (payload?.tool_name !== "Read" || inp.offset != null || inp.limit != null) return null;
|
|
51
|
+
for (const p of payloadReads(payload)) {
|
|
52
|
+
const now = mtimeNow(p);
|
|
53
|
+
if (now != null && (counts[p] ?? 0) >= REREAD_MIN - 1 && mtimes[p] === now)
|
|
54
|
+
return `${p} has already been read ${counts[p]} times in this session and has not changed: use your notes or the excerpt already in context; `
|
|
55
|
+
+ "if you need a different part, read only that line range (offset and limit).";
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
/** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
|
|
42
61
|
export function main(input, { dir } = {}) {
|
|
43
62
|
try {
|
|
44
63
|
const payload = JSON.parse(input);
|
|
45
64
|
if (!payload?.session_id) return "";
|
|
46
65
|
if (!payloadReads(payload).length) return "";
|
|
47
|
-
const file = stateFile(payload.session_id, dir);
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
const file = stateFile(payload.session_id, dir), mfile = file.replace(/\.json$/, ".mtime.json");
|
|
67
|
+
const counts = loadJson(file), mtimes = loadJson(mfile);
|
|
68
|
+
if (payload.hook_event_name === "PreToolUse") {
|
|
69
|
+
const reason = guardFor(payload, counts, mtimes);
|
|
70
|
+
return reason ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } })}\n` : "";
|
|
71
|
+
}
|
|
72
|
+
for (const p of payloadReads(payload)) {
|
|
73
|
+
const now = mtimeOf(p);
|
|
74
|
+
if (now != null && mtimes[p] !== now) delete counts[p]; // the file changed (or is new): earlier reads no longer count
|
|
75
|
+
if (now != null) mtimes[p] = now;
|
|
76
|
+
}
|
|
50
77
|
const r = nudgeFor(payload, counts);
|
|
51
78
|
mkdirSync(dirname(file), { recursive: true });
|
|
52
79
|
writeFileSync(file, JSON.stringify(r.counts));
|
|
80
|
+
writeFileSync(mfile, JSON.stringify(mtimes));
|
|
53
81
|
return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
|
|
54
82
|
} catch {
|
|
55
83
|
return "";
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
|
|
7
7
|
* scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K),
|
|
8
8
|
* `add` (issues added to the batch by `--add`, #4408: a cumulative JSON set the runner re-reads every pass),
|
|
9
|
+
* `hold` (issues held by `--hold`, #4592: a cumulative JSON set; the runner admits none of them until `--release` or a `--resume` names them),
|
|
9
10
|
* `cancel` (`{at, reason}` written by `--cancel`, #4376: the runner stops admitting and stops its local lanes; a resume clears it),
|
|
10
11
|
* `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
|
|
11
12
|
* when the scheduler loop returns; `forgedock orchestrate --resume` on a finished batch replays it, #4192). Every write is tmp + fsync + rename, and the root must be
|
|
@@ -68,6 +69,9 @@ const readAdded = (file) => {
|
|
|
68
69
|
return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
|
|
69
70
|
};
|
|
70
71
|
|
|
72
|
+
/** The positive integers among `list` (numbers, `7`, `#7`). */
|
|
73
|
+
const ints = (list) => (Array.isArray(list) ? list : []).map((x) => (typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN)).filter((n) => Number.isSafeInteger(n) && n > 0);
|
|
74
|
+
|
|
71
75
|
export function newBatchId(now = Date.now(), pid = process.pid) {
|
|
72
76
|
return `b${new Date(now).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${pid}`;
|
|
73
77
|
}
|
|
@@ -118,6 +122,22 @@ export function openBatch(root, id) {
|
|
|
118
122
|
if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
|
|
119
123
|
return { added, skipped };
|
|
120
124
|
},
|
|
125
|
+
/** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
|
|
126
|
+
readHeld: () => readAdded(f("hold")),
|
|
127
|
+
/** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
|
|
128
|
+
holdIssues: (list) => {
|
|
129
|
+
const have = new Set(readAdded(f("hold")));
|
|
130
|
+
const added = [...new Set(ints(list))].filter((n) => !have.has(n)).sort((a, b) => a - b);
|
|
131
|
+
if (added.length) writeAtomic(f("hold"), JSON.stringify([...have, ...added].sort((a, b) => a - b)) + "\n");
|
|
132
|
+
return added;
|
|
133
|
+
},
|
|
134
|
+
/** Remove `list` from the `hold` file. @returns {number[]} the issues that were held */
|
|
135
|
+
releaseIssues: (list) => {
|
|
136
|
+
const have = new Set(readAdded(f("hold")));
|
|
137
|
+
const freed = [...new Set(ints(list))].filter((n) => have.has(n)).sort((a, b) => a - b);
|
|
138
|
+
if (freed.length) writeAtomic(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
|
|
139
|
+
return freed;
|
|
140
|
+
},
|
|
121
141
|
/** `--cancel` (#4376): `{at, reason}` in the `cancel` file; the runner re-reads it every pass. */
|
|
122
142
|
requestCancel: ({ reason = "operator" } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason) }) + "\n"),
|
|
123
143
|
/** The cancel record, or null when missing or invalid. */
|
|
@@ -146,7 +166,10 @@ export function isLaneAlive(pid, marker, { kill = process.kill.bind(process), pr
|
|
|
146
166
|
if (!existsSync(procDir)) return true;
|
|
147
167
|
let cmd;
|
|
148
168
|
try { cmd = readFileSync(join(procDir, String(pid), "cmdline"), "utf8"); } catch { return false; }
|
|
149
|
-
|
|
169
|
+
if (marker && !cmd.split("\0").includes(marker)) return false;
|
|
170
|
+
// A zombie (exited, not yet reaped: PID 1 may not reap) answers `kill(pid, 0)` but is not running.
|
|
171
|
+
try { if (/^\d+ \(.*\) Z /s.test(readFileSync(join(procDir, String(pid), "stat"), "utf8"))) return false; } catch { /* no stat: treat as alive */ }
|
|
172
|
+
return true;
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
/**
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -213,6 +213,14 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
213
213
|
assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
|
|
214
214
|
assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
|
|
215
215
|
assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
|
|
216
|
+
// #4592: a zombie answers kill(pid, 0) but is not a live lane.
|
|
217
|
+
const proc = tmpDir("proc-"), pd = join(proc, "42");
|
|
218
|
+
mkdirSync(pd, { recursive: true });
|
|
219
|
+
writeFileSync(join(pd, "cmdline"), "node\0lane\0");
|
|
220
|
+
writeFileSync(join(pd, "stat"), "42 (node) S 1 42 42 0 -1\n");
|
|
221
|
+
assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), true);
|
|
222
|
+
writeFileSync(join(pd, "stat"), "42 (no) de) Z 1 42 42 0 -1\n");
|
|
223
|
+
assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), false);
|
|
216
224
|
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
217
225
|
writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
|
|
218
226
|
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
@@ -760,3 +768,70 @@ describe("#4547: opt-in local fallback in mode remote when no pool agent is onli
|
|
|
760
768
|
}
|
|
761
769
|
});
|
|
762
770
|
});
|
|
771
|
+
|
|
772
|
+
describe("#4592: forgedock orchestrate --hold / --release", () => {
|
|
773
|
+
it("the hold file: holdIssues / readHeld / releaseIssues merge sorted unique positive integers; invalid content reads as []", () => {
|
|
774
|
+
const b = openBatch(tmpDir("batch-hold-"), "b1");
|
|
775
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
776
|
+
assert.deepEqual(b.readHeld(), []);
|
|
777
|
+
assert.deepEqual(b.holdIssues([5, "#3", "x", 0, 5, "7"]), [3, 5, 7]);
|
|
778
|
+
assert.deepEqual(b.holdIssues([3, 9]), [9], "already held is not newly held");
|
|
779
|
+
assert.deepEqual(b.readHeld(), [3, 5, 7, 9]);
|
|
780
|
+
assert.deepEqual(b.releaseIssues([5, 4, "#9"]), [5, 9]);
|
|
781
|
+
assert.deepEqual(b.readHeld(), [3, 7]);
|
|
782
|
+
writeFileSync(join(b.dir, "hold"), "not json");
|
|
783
|
+
assert.deepEqual(b.readHeld(), []);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("a held issue is never admitted; the rest run, the batch ends with it held, and a release (resume) runs it", async () => {
|
|
787
|
+
let saved = null, held = [2];
|
|
788
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
789
|
+
const started = [], log = [];
|
|
790
|
+
const run = (issues) => orchestrate({ issues, parallel: 2, pollMs: 5, readBody: async () => "", store, getHeld: () => held, onUpdate: (r) => log.push(r),
|
|
791
|
+
runOne: async (n) => { started.push(n); return { terminalReason: "merged" }; } });
|
|
792
|
+
const rows = await run([1, 2, 3]);
|
|
793
|
+
assert.deepEqual(started.sort(), [1, 3], "#2 is not admitted");
|
|
794
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "held"], [3, "merged"]]);
|
|
795
|
+
assert.equal(rows[1].detail, "held by operator");
|
|
796
|
+
assert.equal(log.filter((r) => r.outcome === "hold").length, 1, "one row when the set changes");
|
|
797
|
+
held = [];
|
|
798
|
+
const resumed = await run([]);
|
|
799
|
+
assert.deepEqual(resumed.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
|
|
800
|
+
assert.deepEqual(started.sort(), [1, 2, 3]);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("a release while another lane runs admits the held issue in the same batch", async () => {
|
|
804
|
+
let saved = null, held = [2], free;
|
|
805
|
+
const gate = new Promise((r) => { free = r; });
|
|
806
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
807
|
+
const started = [];
|
|
808
|
+
const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 5, readBody: async () => "", store, getHeld: () => held,
|
|
809
|
+
runOne: async (n) => { started.push(n); if (n === 1) await gate; return { terminalReason: "merged" }; } });
|
|
810
|
+
await until(() => started.includes(1));
|
|
811
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
812
|
+
assert.deepEqual(started, [1]);
|
|
813
|
+
held = [];
|
|
814
|
+
await until(() => started.includes(2));
|
|
815
|
+
free();
|
|
816
|
+
assert.deepEqual((await done).map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("through the CLI: --hold / --release write the hold file (usage and unknown batch reject); --resume <id> N releases N", async () => {
|
|
820
|
+
const root = tmpDir("batch-hold-cli-");
|
|
821
|
+
const b = openBatch(root, "b1");
|
|
822
|
+
b.writePlan({ argv: [], repo: "o/r" });
|
|
823
|
+
b.store.save({ order: [1, 2] });
|
|
824
|
+
const io = { gh: async () => JSON.stringify({ state: "OPEN" }) };
|
|
825
|
+
let res = await quiet(() => orchestrateFromCli(["--hold", "b1", "1", "2"], { batchRoot: root, io }));
|
|
826
|
+
assert.deepEqual(res.changed, [1, 2]);
|
|
827
|
+
res = await quiet(() => orchestrateFromCli(["--release", "b1", "1"], { batchRoot: root, io }));
|
|
828
|
+
assert.deepEqual(res.changed, [1]);
|
|
829
|
+
assert.deepEqual(b.readHeld(), [2]);
|
|
830
|
+
await assert.rejects(orchestrateFromCli(["--hold", "b1"], { batchRoot: root, io }), /usage/);
|
|
831
|
+
await assert.rejects(orchestrateFromCli(["--hold", "nope", "1"], { batchRoot: root, io }), /no batch nope/);
|
|
832
|
+
// A resume naming a held issue re-admits it (the plan has no issues, so the resume stops with the usage error after that step).
|
|
833
|
+
await assert.rejects(quiet(() => orchestrateFromCli(["--resume", "b1", "2"], { batchRoot: root, io })), /usage/);
|
|
834
|
+
assert.deepEqual(b.readHeld(), []);
|
|
835
|
+
assert.deepEqual(b.readAdded(), [], "an issue already in the batch is not added again");
|
|
836
|
+
});
|
|
837
|
+
});
|
package/bin/lean/budget.mjs
CHANGED
|
@@ -42,6 +42,17 @@ export const nextSize = (size) => (SIZES.includes(size) ? SIZES[Math.min(SIZES.i
|
|
|
42
42
|
/** #4505: a `budget.exceeded` recovery lane runs at the next size's budget; any other lane keeps its size. */
|
|
43
43
|
export const recoverySize = (size, recover) => (recover?.code === "budget.exceeded" ? nextSize(size) : size);
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Extra implement turns for a big L lane (#4587): +10 per distinct deliverable path above 5, capped at +2x the L base;
|
|
47
|
+
* a `budget.exceeded` recovery of an L lane adds +ceil(base/2) so it gets more than the pass that failed.
|
|
48
|
+
* 0 for S/M, an unsized lane or a disabled (0) base.
|
|
49
|
+
*/
|
|
50
|
+
export function scaledExtra({ size, contract = null, recover = null, base = TURN_BUDGET_DEFAULTS.L } = {}) {
|
|
51
|
+
if (size !== "L" || !(base > 0)) return 0;
|
|
52
|
+
const paths = Array.isArray(contract?.deliverables) ? new Set(contract.deliverables.map((d) => d?.path).filter((p) => typeof p === "string" && p.trim())).size : 0;
|
|
53
|
+
return Math.min(10 * Math.max(0, paths - 5), 2 * base) + (recover?.code === "budget.exceeded" ? Math.ceil(base / 2) : 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/** Turns the wrap-up call may use after the budget is spent. */
|
|
46
57
|
export const wrapupGrant = (budget) => Math.max(5, Math.ceil(budget / 5));
|
|
47
58
|
|
|
@@ -52,10 +63,10 @@ export const wrapupGrant = (budget) => Math.max(5, Math.ceil(budget / 5));
|
|
|
52
63
|
*/
|
|
53
64
|
export function makeTurnBudget(budgets = TURN_BUDGET_DEFAULTS) {
|
|
54
65
|
const b = { ...TURN_BUDGET_DEFAULTS, ...(budgets ?? {}) };
|
|
55
|
-
let size = null, build = 0, total = 0, exceeded = false;
|
|
56
|
-
const budget = () => (size && Number.isFinite(b[size]) && b[size] > 0 ? b[size] : 0);
|
|
66
|
+
let size = null, extra = 0, build = 0, total = 0, exceeded = false;
|
|
67
|
+
const budget = () => (size && Number.isFinite(b[size]) && b[size] > 0 ? b[size] + extra : 0);
|
|
57
68
|
return {
|
|
58
|
-
setSize(s) { if (SIZES.includes(s)) size = s; },
|
|
69
|
+
setSize(s, add = 0) { if (SIZES.includes(s)) { size = s; extra = Number.isFinite(add) && add > 0 ? Math.floor(add) : 0; } },
|
|
59
70
|
get size() { return size; },
|
|
60
71
|
budget,
|
|
61
72
|
on: () => budget() > 0,
|
package/bin/lean/budget.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { describe, it } from "node:test";
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { writeFileSync, chmodSync } from "node:fs";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
-
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, nextSize, recoverySize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
8
|
+
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, nextSize, recoverySize, wrapupGrant, makeTurnBudget, scaledExtra, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
9
9
|
import { orchestrate } from "./orchestrate.mjs";
|
|
10
10
|
import { OUTCOME_CODES, RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
11
11
|
import { RECOVER_HOW } from "./lean.mjs";
|
|
@@ -54,6 +54,16 @@ describe("#4392 turn budget", () => {
|
|
|
54
54
|
assert.equal(wrapupGrant(25), 5); assert.equal(wrapupGrant(100), 20); assert.equal(wrapupGrant(3), 5);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it("#4587 L budgets scale with the contract (+10/path over 5, cap +2x) and an L recovery gets more; S/M and off unchanged", () => {
|
|
58
|
+
const b = (n, size = "L", recover = null) => { const tb = makeTurnBudget(); tb.setSize(size, scaledExtra({ size, contract: contract(n), recover })); return tb.budget(); };
|
|
59
|
+
assert.equal(b(6), 110); assert.equal(b(15), 200); assert.equal(b(40), 300);
|
|
60
|
+
assert.equal(b(5, "L"), 100);
|
|
61
|
+
assert.equal(b(15, "L", { code: "budget.exceeded" }), 250); assert.ok(b(6, "L", { code: "budget.exceeded" }) > b(6));
|
|
62
|
+
assert.equal(b(15, "M"), 60); assert.equal(b(1, "S"), 25);
|
|
63
|
+
assert.equal(scaledExtra({ size: "L", contract: contract(15), base: 0 }), 0);
|
|
64
|
+
const off = makeTurnBudget({ L: 0 }); off.setSize("L", 50); assert.equal(off.on(), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
57
67
|
it("the digest is the builder's (capped), else the diff stat and the last self-check", () => {
|
|
58
68
|
const d = budgetDigest({ size: "M", budget: 60, used: 66, digest: "x".repeat(5000) });
|
|
59
69
|
assert.match(d, /size M, 60 turns; 66 used/); assert.ok(d.length < 1800);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic base-merge conflict resolution for sibling lanes (#4588). Sibling lanes of one milestone always collide
|
|
5
|
+
* on the same kinds of file once one lands; `resolveBaseConflicts` settles them while a base merge is in progress (ours =
|
|
6
|
+
* the lane, theirs = the base), with no builder session:
|
|
7
|
+
* - `unionGlobs`: append-only lists; hunks where both sides purely added lines keep both (basesync.mjs `unionResolve`).
|
|
8
|
+
* - `jsonGlobs`: JSON maps (status files); a 3-way key union. A key both sides changed differently, or a non-object
|
|
9
|
+
* document, is refused.
|
|
10
|
+
* - `generatedGlobs`: the base side is taken, then `generatedCommand` runs once and the files are re-added.
|
|
11
|
+
* Sequence/migration files are never touched here (sequence.mjs). A file that cannot be fully resolved stays conflicted
|
|
12
|
+
* and is returned in `remaining`. Resolved files are `git add`ed; the caller commits. Never throws.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { isSequenceFile, matchesGlobs, unionResolve } from "./basesync.mjs";
|
|
19
|
+
|
|
20
|
+
const pexec = promisify(execFile);
|
|
21
|
+
const defaultRun = (cmd, { cwd }) => pexec("sh", ["-c", cmd], { cwd, timeout: 600000, maxBuffer: 50 * 1024 * 1024 });
|
|
22
|
+
const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
23
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
24
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
25
|
+
const NONE = Symbol("absent");
|
|
26
|
+
|
|
27
|
+
/** 3-way merge of parsed JSON (NONE = absent): one-sided changes win, objects merge per key, anything else throws. */
|
|
28
|
+
export function jsonMerge(base, ours, theirs) {
|
|
29
|
+
if (same(ours, theirs) || same(theirs, base)) return ours;
|
|
30
|
+
if (same(ours, base)) return theirs;
|
|
31
|
+
if (!isObj(ours) || !isObj(theirs) || !(isObj(base) || base === NONE)) throw new Error("both sides changed the same value");
|
|
32
|
+
const b = isObj(base) ? base : {}, out = {};
|
|
33
|
+
for (const k of [...Object.keys(ours), ...Object.keys(theirs)]) {
|
|
34
|
+
if (k in out) continue;
|
|
35
|
+
const v = jsonMerge(k in b ? b[k] : NONE, k in ours ? ours[k] : NONE, k in theirs ? theirs[k] : NONE);
|
|
36
|
+
if (v !== NONE) out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The merged text of a JSON map file from index stages 1-3, written with the lane's indent; throws when it cannot merge. */
|
|
42
|
+
export function jsonUnionText(baseTxt, oursTxt, theirsTxt) {
|
|
43
|
+
const base = baseTxt === null ? NONE : JSON.parse(baseTxt), ours = JSON.parse(oursTxt), theirs = JSON.parse(theirsTxt);
|
|
44
|
+
if (!isObj(ours) || !isObj(theirs)) throw new Error("not a JSON object");
|
|
45
|
+
const indent = /^([ \t]+)"/m.exec(oursTxt)?.[1] ?? 2;
|
|
46
|
+
return `${JSON.stringify(jsonMerge(base, ours, theirs), null, indent)}\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{git:(args:string[])=>Promise<string>, cwd:string, files:string[], cfg?:{unionGlobs?:string[],jsonGlobs?:string[],generatedGlobs?:string[],generatedCommand?:string}, run?:Function}} p
|
|
51
|
+
* `git` runs in the worktree with the merge in progress and throws on failure.
|
|
52
|
+
* @returns {Promise<{resolved:string[], remaining:string[], note?:string}>}
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveBaseConflicts({ git, cwd, files = [], cfg = {}, run = defaultRun }) {
|
|
55
|
+
const resolved = [], remaining = [];
|
|
56
|
+
try {
|
|
57
|
+
const stages = new Map();
|
|
58
|
+
for (const l of lines(await git(["-c", "core.quotepath=off", "ls-files", "-u"]).catch(() => ""))) {
|
|
59
|
+
const m = /^\d+ \S+ (\d)\t(.+)$/.exec(l); if (m) stages.set(m[2], (stages.get(m[2]) ?? 0) + (1 << Number(m[1])));
|
|
60
|
+
}
|
|
61
|
+
const generated = [];
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
let ok = false;
|
|
64
|
+
try {
|
|
65
|
+
const st = stages.get(f);
|
|
66
|
+
if (isSequenceFile(f)) ok = false;
|
|
67
|
+
else if (matchesGlobs(f, cfg.generatedGlobs) && cfg.generatedCommand && (st & 8)) { await git(["checkout", "--theirs", "--", f]); generated.push(f); ok = true; }
|
|
68
|
+
else if (st === 14 && matchesGlobs(f, cfg.jsonGlobs)) {
|
|
69
|
+
const show = (n) => git(["show", `:${n}:${f}`]);
|
|
70
|
+
writeFileSync(join(cwd, f), jsonUnionText(await show(1), await show(2), await show(3))); ok = true;
|
|
71
|
+
} else if (st === 14 && matchesGlobs(f, cfg.unionGlobs)) {
|
|
72
|
+
await git(["checkout", "--conflict=diff3", "--", f]);
|
|
73
|
+
const text = unionResolve(readFileSync(join(cwd, f), "utf8"));
|
|
74
|
+
if (text !== null) { writeFileSync(join(cwd, f), text); ok = true; }
|
|
75
|
+
}
|
|
76
|
+
} catch { ok = false; }
|
|
77
|
+
if (ok) resolved.push(f); else remaining.push(f);
|
|
78
|
+
}
|
|
79
|
+
let note;
|
|
80
|
+
if (generated.length) {
|
|
81
|
+
try { await run(cfg.generatedCommand, { cwd }); } catch (e) {
|
|
82
|
+
note = `generated command failed: ${String(e?.stderr || e?.message || e).trim().split("\n")[0].slice(0, 160)}`;
|
|
83
|
+
for (const f of generated) { await git(["checkout", "-m", "--", f]).catch(() => {}); resolved.splice(resolved.indexOf(f), 1); remaining.push(f); }
|
|
84
|
+
generated.length = 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const f of resolved) await git(["add", "--", f]);
|
|
88
|
+
for (const f of generated) await git(["add", "-A", "--", f]);
|
|
89
|
+
return { resolved, remaining: files.filter((f) => remaining.includes(f)), ...(note ? { note } : {}) };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { resolved: [], remaining: [...files], note: String(e?.message ?? e).split("\n")[0].slice(0, 160) };
|
|
92
|
+
}
|
|
93
|
+
}
|