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.
@@ -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
- return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
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
- let inLearned = false, listIndent = null;
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)) { inLearned = /^learned:\s*(#.*)?$/.test(line); listIndent = null; continue; }
360
- if (!inLearned) continue;
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 (/^\s+test_commands:\s*(#.*)?$/.test(line)) listIndent = indent;
386
+ if (keyRe.test(line)) listIndent = indent;
364
387
  continue;
365
388
  }
366
- if (indent <= listIndent) { listIndent = /^\s+test_commands:\s*(#.*)?$/.test(line) ? indent : null; continue; }
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
- * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
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
- let counts = {};
49
- try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
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 "";
@@ -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
- return !marker || cmd.split("\0").includes(marker);
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
  /**
@@ -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
+ });
@@ -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,
@@ -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
+ }