forgedock 1.34.0 → 1.36.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.
Files changed (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -6,6 +6,8 @@
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
+ * `requeue` (`--requeue`, #4550: a cumulative JSON list of `{issue, reason, by, at}` the runner applies once each, moving a terminal issue back to pending),
10
+ * `hold` (issues held by `--hold`, #4592: a cumulative JSON set; the runner admits none of them until `--release` or a `--resume` names them),
9
11
  * `cancel` (`{at, reason}` written by `--cancel`, #4376: the runner stops admitting and stops its local lanes; a resume clears it),
10
12
  * `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
11
13
  * 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 +70,19 @@ const readAdded = (file) => {
68
70
  return Array.isArray(v) ? [...new Set(v.filter((n) => Number.isSafeInteger(n) && n > 0))].sort((a, b) => a - b) : [];
69
71
  };
70
72
 
73
+ /** `outcome`s `--requeue` may move back to pending (#4550). */
74
+ export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded"].includes(outcome) || /^dependency-/.test(String(outcome));
75
+ /** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
76
+ export const requeueKey = (e) => `${e.issue}@${e.at}`;
77
+
78
+ const readRequeueFile = (file) => {
79
+ const v = readJson(file);
80
+ return Array.isArray(v) ? v.filter((e) => e && Number.isSafeInteger(e.issue) && e.issue > 0 && typeof e.at === "string") : [];
81
+ };
82
+
83
+ /** The positive integers among `list` (numbers, `7`, `#7`). */
84
+ 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);
85
+
71
86
  export function newBatchId(now = Date.now(), pid = process.pid) {
72
87
  return `b${new Date(now).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${pid}`;
73
88
  }
@@ -118,8 +133,53 @@ export function openBatch(root, id) {
118
133
  if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
119
134
  return { added, skipped };
120
135
  },
121
- /** `--cancel` (#4376): `{at, reason}` in the `cancel` file; the runner re-reads it every pass. */
122
- requestCancel: ({ reason = "operator" } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason) }) + "\n"),
136
+ /**
137
+ * `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
138
+ * `order`, no terminal result yet (`not terminal`), or already requeued and not yet applied. The needs-human label and
139
+ * closed checks need GitHub and live in the CLI. @returns {{requeued:number[], refused:Array<{issue, reason}>}}
140
+ */
141
+ requeue: (list, { reason = "operator decision", by = "operator" } = {}) => {
142
+ const st = readJson(f("state.json")) ?? {};
143
+ const order = new Set(st.order ?? []), applied = new Set(st.requeueApplied ?? []);
144
+ const entries = readRequeueFile(f("requeue"));
145
+ const pend = new Set(entries.filter((e) => !applied.has(requeueKey(e))).map((e) => e.issue));
146
+ const requeued = [], refused = [];
147
+ for (const x of Array.isArray(list) ? list : []) {
148
+ const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
149
+ if (!Number.isSafeInteger(n) || n < 1) refused.push({ issue: x, reason: "not an integer" });
150
+ else if (!order.has(n)) refused.push({ issue: n, reason: "not in batch" });
151
+ else if (pend.has(n)) refused.push({ issue: n, reason: "already requeued" });
152
+ else if (!REQUEUABLE(st.results?.[n]?.outcome)) refused.push({ issue: n, reason: "not terminal" });
153
+ else { pend.add(n); requeued.push(n); }
154
+ }
155
+ if (requeued.length) {
156
+ const at = new Date().toISOString();
157
+ writeAtomic(f("requeue"), JSON.stringify([...entries, ...requeued.map((issue) => ({ issue, reason: String(reason), by: String(by), at }))]) + "\n");
158
+ }
159
+ return { requeued, refused };
160
+ },
161
+ /** Every `--requeue` entry, in order; [] when missing or invalid. */
162
+ readRequeued: () => readRequeueFile(f("requeue")),
163
+ /** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
164
+ unrunRequeues: () => { const done = new Set(readJson(f("state.json"))?.requeueApplied ?? []); return readRequeueFile(f("requeue")).filter((e) => !done.has(requeueKey(e))); },
165
+ /** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
166
+ readHeld: () => readAdded(f("hold")),
167
+ /** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
168
+ holdIssues: (list) => {
169
+ const have = new Set(readAdded(f("hold")));
170
+ const added = [...new Set(ints(list))].filter((n) => !have.has(n)).sort((a, b) => a - b);
171
+ if (added.length) writeAtomic(f("hold"), JSON.stringify([...have, ...added].sort((a, b) => a - b)) + "\n");
172
+ return added;
173
+ },
174
+ /** Remove `list` from the `hold` file. @returns {number[]} the issues that were held */
175
+ releaseIssues: (list) => {
176
+ const have = new Set(readAdded(f("hold")));
177
+ const freed = [...new Set(ints(list))].filter((n) => have.has(n)).sort((a, b) => a - b);
178
+ if (freed.length) writeAtomic(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
179
+ return freed;
180
+ },
181
+ /** `--cancel` (#4376): `{at, reason, stopRemote?}` in the `cancel` file; the runner re-reads it every pass. `stopRemote` (#4612) also asks remote lanes to stop at their checkpoint. */
182
+ requestCancel: ({ reason = "operator", stopRemote = false } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason), ...(stopRemote === true ? { stopRemote: true } : {}) }) + "\n"),
123
183
  /** The cancel record, or null when missing or invalid. */
124
184
  readCancel: () => { const v = readJson(f("cancel")); return v && typeof v === "object" && !Array.isArray(v) && typeof v.at === "string" ? v : null; },
125
185
  clearCancel: () => rmSync(f("cancel"), { force: true }),
@@ -146,7 +206,10 @@ export function isLaneAlive(pid, marker, { kill = process.kill.bind(process), pr
146
206
  if (!existsSync(procDir)) return true;
147
207
  let cmd;
148
208
  try { cmd = readFileSync(join(procDir, String(pid), "cmdline"), "utf8"); } catch { return false; }
149
- return !marker || cmd.split("\0").includes(marker);
209
+ if (marker && !cmd.split("\0").includes(marker)) return false;
210
+ // A zombie (exited, not yet reaped: PID 1 may not reap) answers `kill(pid, 0)` but is not running.
211
+ try { if (/^\d+ \(.*\) Z /s.test(readFileSync(join(procDir, String(pid), "stat"), "utf8"))) return false; } catch { /* no stat: treat as alive */ }
212
+ return true;
150
213
  }
151
214
 
152
215
  /**
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
9
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
10
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
11
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
12
12
 
@@ -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: [] } });
@@ -520,6 +528,37 @@ describe("#4376: forgedock orchestrate --cancel", () => {
520
528
  assert.deepEqual(saved.deferrals, { 1: 1 });
521
529
  });
522
530
 
531
+ for (const stopRemote of [false, true]) {
532
+ it(`a cancel ${stopRemote ? "with" : "without"} stopRemote ${stopRemote ? "stops" : "leaves"} a remote lane (#4612)`, async () => {
533
+ let cancel = null, saved = null;
534
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
535
+ const started = [], stops = [];
536
+ let release;
537
+ const gate = new Promise((r) => { release = r; });
538
+ const done = orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", store, getCancelled: () => cancel,
539
+ stopLane: (n, rec) => { stops.push([n, rec.remote]); release(); },
540
+ runOne: async (n, _lane, ctx) => {
541
+ started.push(n);
542
+ ctx.onStart?.({ remote: true, handle: { id: "sbx-1" } });
543
+ if (!stopRemote) setTimeout(release, 100);
544
+ await gate;
545
+ return { terminalReason: "deferred", code: "run.interrupted" };
546
+ } });
547
+ await until(() => started.includes(1));
548
+ cancel = { at: new Date().toISOString(), reason: "operator", ...(stopRemote ? { stopRemote: true } : {}) };
549
+ await done;
550
+ assert.deepEqual(stops, stopRemote ? [[1, true]] : []);
551
+ });
552
+ }
553
+
554
+ it("requestCancel records stopRemote only when asked (#4612)", () => {
555
+ const b = openBatch(tmpDir("batch-cancel-stop-"), "b1");
556
+ b.requestCancel({});
557
+ assert.equal(b.readCancel().stopRemote, undefined);
558
+ b.requestCancel({ stopRemote: true });
559
+ assert.equal(b.readCancel().stopRemote, true);
560
+ });
561
+
523
562
  it("through the CLI: --cancel during a lane holds the rest, a finished batch is left alone, unknown ids reject, --resume clears it", async () => {
524
563
  const root = tmpDir("batch-cancel-cli-");
525
564
  const leanIo = {
@@ -703,6 +742,75 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
703
742
  });
704
743
  });
705
744
 
745
+ describe("#4550: forgedock orchestrate --requeue", () => {
746
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
747
+ it("a needs-human issue is requeued and relaunched; its skipped dependent returns to pending and runs after it merges; one row per issue", async () => {
748
+ const bodies = { 1: "", 2: "Depends on #1", 3: "" };
749
+ let entries = [];
750
+ const started = [], g3 = gate(), log = [], decisions = [];
751
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
752
+ decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
753
+ runOne: async (n) => {
754
+ started.push(n);
755
+ if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
756
+ if (n === 1 && started.filter((x) => x === 1).length === 1) return { terminalReason: "needs-human", detail: "builder.blocked: product-decision" };
757
+ return { terminalReason: "merged" };
758
+ } });
759
+ await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
760
+ entries = [{ issue: 1, reason: "decided in scope", by: "operator", at: "2026-10-24T00:00:00Z" }];
761
+ await until(() => started.filter((x) => x === 2).length === 1, 3000);
762
+ g3.open();
763
+ const rows = await done;
764
+ assert.deepEqual(started.filter((x) => x === 1).length, 2, "relaunched once");
765
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
766
+ assert.equal(rows[0].requeued.reason, "decided in scope");
767
+ assert.equal(rows[0].requeued.prior[0].outcome, "needs-human");
768
+ assert.deepEqual(formatOutcomeLists(rows), ["requeued: #1 (decided in scope)"]);
769
+ assert.ok(decisions.some((d) => d.issue === 1 && /requeued/.test(d.what) && d.why === "decided in scope"));
770
+ assert.ok(started.indexOf(2) > started.lastIndexOf(1) - 1 && started.filter((x) => x === 2).length === 1, "#2 ran once, after #1 was relaunched");
771
+ });
772
+
773
+ it("an entry is applied once; one for an issue that is not terminal changes nothing", async () => {
774
+ const entries = [{ issue: 1, reason: "r", by: "operator", at: "t1" }];
775
+ let runs = 0;
776
+ const rows = await orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", getRequeued: () => entries,
777
+ runOne: async () => { runs++; return { terminalReason: "merged" }; } });
778
+ assert.equal(runs, 1);
779
+ assert.equal(rows[0].requeued, undefined);
780
+ });
781
+
782
+ it("batch.requeue: terminal issues are recorded; unknown, merged and double requests are refused; --add still says already in batch", () => {
783
+ const b = openBatch(tmpDir("batch-requeue-"), "b1");
784
+ b.writePlan({ argv: ["1"], repo: "o/r" });
785
+ b.store.save({ order: [1, 2, 3, 4], results: { 1: { outcome: "needs-human" }, 2: { outcome: "merged" }, 3: { outcome: "dependency-blocked" } } });
786
+ const r = b.requeue([1, 2, 3, 4, 9, "x", 1], { reason: "why", by: "me" });
787
+ assert.deepEqual(r.requeued, [1, 3]);
788
+ assert.deepEqual(r.refused.map((x) => [x.issue, x.reason]), [[2, "not terminal"], [4, "not terminal"], [9, "not in batch"], ["x", "not an integer"], [1, "already requeued"]]);
789
+ assert.deepEqual(b.readRequeued().map((e) => [e.issue, e.reason, e.by]), [[1, "why", "me"], [3, "why", "me"]]);
790
+ assert.equal(b.unrunRequeues().length, 2);
791
+ assert.deepEqual(b.addIssues([1]).skipped, [{ issue: 1, reason: "already in batch" }]);
792
+ });
793
+
794
+ it("the CLI refuses a needs-human-labelled or closed issue and writes nothing; an open, cleared one is requeued (a finished batch's report is cleared)", async () => {
795
+ const root = tmpDir("batch-requeue-cli-");
796
+ const b = openBatch(root, "b1");
797
+ b.writePlan({ argv: ["1"], repo: "o/r" });
798
+ b.store.save({ order: [5, 6, 7], results: { 5: { outcome: "needs-human" }, 6: { outcome: "needs-human" }, 7: { outcome: "needs-human" } } });
799
+ b.writeReport({ rows: [], report: "x" });
800
+ const io = { gh: async (a) => JSON.stringify({ state: a[2] === "6" ? "CLOSED" : "OPEN", labels: a[2] === "7" ? [{ name: "needs-human" }] : [] }) };
801
+ const bad = await quiet(() => orchestrateFromCli(["--requeue", "b1", "6", "7", "--reason", "r"], { batchRoot: root, io }));
802
+ assert.deepEqual(bad.requeued, []);
803
+ assert.deepEqual(bad.refused.map((x) => x.issue), [6, 7]);
804
+ assert.match(bad.refused[1].reason, /needs-human/);
805
+ assert.deepEqual(b.readRequeued(), []);
806
+ assert.ok(b.readReport(), "nothing changed");
807
+ const ok = await quiet(() => orchestrateFromCli(["--requeue", "b1", "5", "--reason", "decided"], { batchRoot: root, io }));
808
+ assert.deepEqual(ok.requeued, [5]);
809
+ assert.equal(b.readRequeued()[0].reason, "decided");
810
+ assert.equal(b.readReport(), null, "the finished batch is reopened for --resume");
811
+ });
812
+ });
813
+
706
814
  describe("#4540: remote admissions are capped to the provider's free capacity", () => {
707
815
  const run = async (capacity) => {
708
816
  const c = { t: 1_000_000 };
@@ -760,3 +868,70 @@ describe("#4547: opt-in local fallback in mode remote when no pool agent is onli
760
868
  }
761
869
  });
762
870
  });
871
+
872
+ describe("#4592: forgedock orchestrate --hold / --release", () => {
873
+ it("the hold file: holdIssues / readHeld / releaseIssues merge sorted unique positive integers; invalid content reads as []", () => {
874
+ const b = openBatch(tmpDir("batch-hold-"), "b1");
875
+ b.writePlan({ argv: ["1"], repo: "o/r" });
876
+ assert.deepEqual(b.readHeld(), []);
877
+ assert.deepEqual(b.holdIssues([5, "#3", "x", 0, 5, "7"]), [3, 5, 7]);
878
+ assert.deepEqual(b.holdIssues([3, 9]), [9], "already held is not newly held");
879
+ assert.deepEqual(b.readHeld(), [3, 5, 7, 9]);
880
+ assert.deepEqual(b.releaseIssues([5, 4, "#9"]), [5, 9]);
881
+ assert.deepEqual(b.readHeld(), [3, 7]);
882
+ writeFileSync(join(b.dir, "hold"), "not json");
883
+ assert.deepEqual(b.readHeld(), []);
884
+ });
885
+
886
+ it("a held issue is never admitted; the rest run, the batch ends with it held, and a release (resume) runs it", async () => {
887
+ let saved = null, held = [2];
888
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
889
+ const started = [], log = [];
890
+ const run = (issues) => orchestrate({ issues, parallel: 2, pollMs: 5, readBody: async () => "", store, getHeld: () => held, onUpdate: (r) => log.push(r),
891
+ runOne: async (n) => { started.push(n); return { terminalReason: "merged" }; } });
892
+ const rows = await run([1, 2, 3]);
893
+ assert.deepEqual(started.sort(), [1, 3], "#2 is not admitted");
894
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "held"], [3, "merged"]]);
895
+ assert.equal(rows[1].detail, "held by operator");
896
+ assert.equal(log.filter((r) => r.outcome === "hold").length, 1, "one row when the set changes");
897
+ held = [];
898
+ const resumed = await run([]);
899
+ assert.deepEqual(resumed.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
900
+ assert.deepEqual(started.sort(), [1, 2, 3]);
901
+ });
902
+
903
+ it("a release while another lane runs admits the held issue in the same batch", async () => {
904
+ let saved = null, held = [2], free;
905
+ const gate = new Promise((r) => { free = r; });
906
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
907
+ const started = [];
908
+ const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 5, readBody: async () => "", store, getHeld: () => held,
909
+ runOne: async (n) => { started.push(n); if (n === 1) await gate; return { terminalReason: "merged" }; } });
910
+ await until(() => started.includes(1));
911
+ await new Promise((r) => setTimeout(r, 30));
912
+ assert.deepEqual(started, [1]);
913
+ held = [];
914
+ await until(() => started.includes(2));
915
+ free();
916
+ assert.deepEqual((await done).map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
917
+ });
918
+
919
+ it("through the CLI: --hold / --release write the hold file (usage and unknown batch reject); --resume <id> N releases N", async () => {
920
+ const root = tmpDir("batch-hold-cli-");
921
+ const b = openBatch(root, "b1");
922
+ b.writePlan({ argv: [], repo: "o/r" });
923
+ b.store.save({ order: [1, 2] });
924
+ const io = { gh: async () => JSON.stringify({ state: "OPEN" }) };
925
+ let res = await quiet(() => orchestrateFromCli(["--hold", "b1", "1", "2"], { batchRoot: root, io }));
926
+ assert.deepEqual(res.changed, [1, 2]);
927
+ res = await quiet(() => orchestrateFromCli(["--release", "b1", "1"], { batchRoot: root, io }));
928
+ assert.deepEqual(res.changed, [1]);
929
+ assert.deepEqual(b.readHeld(), [2]);
930
+ await assert.rejects(orchestrateFromCli(["--hold", "b1"], { batchRoot: root, io }), /usage/);
931
+ await assert.rejects(orchestrateFromCli(["--hold", "nope", "1"], { batchRoot: root, io }), /no batch nope/);
932
+ // 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).
933
+ await assert.rejects(quiet(() => orchestrateFromCli(["--resume", "b1", "2"], { batchRoot: root, io })), /usage/);
934
+ assert.deepEqual(b.readHeld(), []);
935
+ assert.deepEqual(b.readAdded(), [], "an issue already in the batch is not added again");
936
+ });
937
+ });
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4588: deterministic base-merge conflict resolution, on real temp git repos.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { tmpDir } from "../tests/_tmp.mjs";
10
+ import { readBaseMerge } from "../engine-cli.mjs";
11
+ import { resolveBaseConflicts, jsonUnionText } from "./conflictres.mjs";
12
+
13
+ const sh = (cwd, ...a) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
14
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
15
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
16
+
17
+ /** A repo whose lane and main both edited `files`; the merge of main into the lane is left in progress. */
18
+ function conflicted(root, start, lane, main) {
19
+ sh(root, "init", "-q", "-b", "main"); put(root, start); commit(root, "base");
20
+ sh(root, "checkout", "-qb", "lane"); put(root, lane); commit(root, "lane");
21
+ sh(root, "checkout", "-q", "main"); put(root, main); commit(root, "main");
22
+ sh(root, "checkout", "-q", "lane");
23
+ try { sh(root, "merge", "--no-edit", "main"); } catch { /* conflicts */ }
24
+ return Object.keys(start).filter((f) => sh(root, "diff", "--name-only", "--diff-filter=U").split("\n").includes(f));
25
+ }
26
+ const env = (root) => ({ git: async (a) => sh(root, ...a), cwd: root });
27
+
28
+ describe("resolveBaseConflicts (#4588)", () => {
29
+ it("union: two lanes appending to one barrel keep both lines", async () => {
30
+ const root = tmpDir("cr-union"); const files = conflicted(root, { "index.ts": "a\n" }, { "index.ts": "a\nlane\n" }, { "index.ts": "a\nmain\n" });
31
+ const r = await resolveBaseConflicts({ ...env(root), files, cfg: { unionGlobs: ["index.ts"] } });
32
+ assert.deepEqual(r, { resolved: ["index.ts"], remaining: [] });
33
+ assert.equal(readFileSync(join(root, "index.ts"), "utf8"), "a\nlane\nmain\n");
34
+ sh(root, "commit", "--no-edit"); // all staged: the merge commits
35
+ });
36
+
37
+ it("union: a hunk that edits a base line is refused and stays conflicted", async () => {
38
+ const root = tmpDir("cr-union-edit"); const files = conflicted(root, { "index.ts": "a\n" }, { "index.ts": "x\n" }, { "index.ts": "y\n" });
39
+ assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: { unionGlobs: ["index.ts"] } }), { resolved: [], remaining: ["index.ts"] });
40
+ });
41
+
42
+ it("json: key union of a status map; the lane's indent is kept", async () => {
43
+ const root = tmpDir("cr-json");
44
+ const files = conflicted(root, { "status.json": '{\n "a": 1\n}\n' }, { "status.json": '{\n "a": 1,\n "lane": 2\n}\n' }, { "status.json": '{\n "a": 1,\n "main": 3\n}\n' });
45
+ const r = await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["status.json"] } });
46
+ assert.deepEqual(r.resolved, ["status.json"]);
47
+ assert.equal(readFileSync(join(root, "status.json"), "utf8"), '{\n "a": 1,\n "lane": 2,\n "main": 3\n}\n');
48
+ });
49
+
50
+ it("json: the same key changed differently on both sides is refused", async () => {
51
+ const root = tmpDir("cr-json-clash");
52
+ const files = conflicted(root, { "status.json": '{"a":1}\n' }, { "status.json": '{"a":2}\n' }, { "status.json": '{"a":3}\n' });
53
+ assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["status.json"] } }), { resolved: [], remaining: ["status.json"] });
54
+ assert.throws(() => jsonUnionText('[1]', '[2]', '[3]'));
55
+ });
56
+
57
+ it("generated: the base side is taken and the command regenerates the file", async () => {
58
+ const root = tmpDir("cr-gen");
59
+ const files = conflicted(root, { "gen/out.txt": "v0\n", "src.txt": "s\n" }, { "gen/out.txt": "lane\n" }, { "gen/out.txt": "main\n" });
60
+ const r = await resolveBaseConflicts({ ...env(root), files, cfg: { generatedGlobs: ["gen/*"], generatedCommand: "echo regenerated > gen/out.txt" } });
61
+ assert.deepEqual(r, { resolved: ["gen/out.txt"], remaining: [] });
62
+ assert.equal(readFileSync(join(root, "gen/out.txt"), "utf8"), "regenerated\n");
63
+ assert.equal(sh(root, "diff", "--name-only", "--diff-filter=U"), "");
64
+ });
65
+
66
+ it("generated: a failing command leaves the file conflicted", async () => {
67
+ const root = tmpDir("cr-gen-fail");
68
+ const files = conflicted(root, { "gen/out.txt": "v0\n" }, { "gen/out.txt": "lane\n" }, { "gen/out.txt": "main\n" });
69
+ const r = await resolveBaseConflicts({ ...env(root), files, cfg: { generatedGlobs: ["gen/*"], generatedCommand: "exit 3" } });
70
+ assert.deepEqual(r.resolved, []);
71
+ assert.deepEqual(r.remaining, ["gen/out.txt"]);
72
+ assert.match(r.note, /generated command failed/);
73
+ assert.match(readFileSync(join(root, "gen/out.txt"), "utf8"), /<<<<<<</);
74
+ });
75
+
76
+ it("sequence files and unconfigured files are left alone", async () => {
77
+ const root = tmpDir("cr-skip");
78
+ const files = conflicted(root, { "db/meta/_journal.json": '{"a":1}\n', "x.ts": "a\n" }, { "db/meta/_journal.json": '{"a":1,"l":1}\n', "x.ts": "a\nl\n" }, { "db/meta/_journal.json": '{"a":1,"m":1}\n', "x.ts": "a\nm\n" });
79
+ const r = await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["*.json"] } });
80
+ assert.deepEqual(r.resolved, []);
81
+ assert.deepEqual(r.remaining.sort(), ["db/meta/_journal.json", "x.ts"]);
82
+ assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: {} }), { resolved: [], remaining: files });
83
+ });
84
+ });
85
+
86
+ describe("readBaseMerge (#4588)", () => {
87
+ it("reads lean.base_merge_* and is undefined when no glob is set", () => {
88
+ const root = tmpDir("cr-cfg");
89
+ assert.equal(readBaseMerge(root), undefined);
90
+ writeFileSync(join(root, "forge.yaml"), 'lean:\n base_merge_union_globs: "index.ts, **/messages.ts"\n base_merge_json_globs: "status.json"\n base_merge_generated_globs: "gen/**"\n base_merge_generated_command: "pnpm gen"\n');
91
+ assert.deepEqual(readBaseMerge(root), { unionGlobs: ["index.ts", "**/messages.ts"], jsonGlobs: ["status.json"], generatedGlobs: ["gen/**"], generatedCommand: "pnpm gen" });
92
+ });
93
+ });