forgedock 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
10
10
  import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
11
- import { formatTable } from "./orchestrate.mjs";
11
+ import { formatTable, orchestrate } from "./orchestrate.mjs";
12
12
  import { orchestrateFromCli } from "../engine-cli.mjs";
13
13
 
14
14
  const until = async (cond, ms = 15000) => {
@@ -32,16 +32,18 @@ const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf(
32
32
  appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
33
33
  const t = setInterval(() => { if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
34
34
  writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
35
+ // Like run-issue's #4179 teardown: SIGTERM reports deferred run.interrupted.
36
+ process.once("SIGTERM", () => { writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" })); renameSync(rf + ".tmp", rf); process.exit(143); });
35
37
  `);
36
38
  h.daemonScript = join(work, "daemon.mjs");
37
- writeFileSync(h.daemonScript, `import { startDaemon } from ${JSON.stringify(new URL("./schedd.mjs", import.meta.url).href)};
39
+ writeFileSync(h.daemonScript, `import { startDaemon, exitOnSignals } from ${JSON.stringify(new URL("./schedd.mjs", import.meta.url).href)};
38
40
  import { runBatch } from ${JSON.stringify(new URL("../engine-cli.mjs", import.meta.url).href)};
39
41
  const deps = { allowTmpfs: true, laneScript: ${JSON.stringify(stub)}, pollMs: 30, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\\n" },
40
42
  leanIo: { gh: async (a) => a.includes("milestone") ? JSON.stringify({ milestone: null }) : a[0] === "repo" ? "main\\n" : JSON.stringify({ body: a[2] === "3" ? "Depends on #1" : "", labels: [] }),
41
43
  git: async (a) => (a[0] === "ls-remote" ? "abc\\trefs/heads/staging\\n" : "") } };
42
44
  const d = await startDaemon({ runBatch: (a) => runBatch({ ...a, daemon: true, deps }), idleMs: 600000 });
43
45
  if (!d.running) process.exit(3);
44
- process.once("SIGTERM", () => d.close());
46
+ exitOnSignals(d); // the forgedock schedd handler (#4454)
45
47
  await d.closed;
46
48
  `);
47
49
  h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
@@ -151,6 +153,88 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
151
153
  } finally { h.cleanup(); }
152
154
  });
153
155
 
156
+ it("#4408: --add and --resume <id> N add issues to the live batch (no second batch); an unhosted finished batch runs its adds on --resume", async () => {
157
+ const h = host();
158
+ try {
159
+ await h.daemon();
160
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: a[2] === "9" ? "CLOSED" : "OPEN" }) : "o/r\n") } };
161
+ const { res } = await withRoot(h, () => capture(async () => {
162
+ const first = orchestrateFromCli(["1", "--parallel", "3", "--repo", "o/r", "--no-prefilter"], h.client);
163
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
164
+ const [id] = listBatches(h.root);
165
+ const add = await orchestrateFromCli(["--add", id, "2", "1", "x", "9"], client);
166
+ assert.deepEqual(add.added, [2]);
167
+ assert.deepEqual(add.skipped.map((x) => [x.issue, x.reason]).sort(), [[1, "already in batch"], [9, "closed"], ["x", "not an integer"]]);
168
+ await until(() => h.launched().includes(2));
169
+ const resumed = orchestrateFromCli(["--resume", id, "4"], client);
170
+ await until(() => h.launched().includes(4));
171
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
172
+ writeFileSync(h.release, "");
173
+ const [done] = await Promise.all([first, resumed]);
174
+ assert.deepEqual(done.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"]]);
175
+ // Finished and no longer hosted: the add is persisted and the next --resume runs it instead of replaying the report.
176
+ const later = await orchestrateFromCli(["--add", id, "6"], client);
177
+ assert.deepEqual(later.added, [6]);
178
+ const again = await orchestrateFromCli(["--resume", id], client);
179
+ assert.deepEqual(again.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"], [6, "merged"]]);
180
+ return done;
181
+ }));
182
+ assert.equal(res.rows.length, 3);
183
+ assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
184
+ } finally { h.cleanup(); }
185
+ });
186
+
187
+ it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
188
+ const h = host();
189
+ try {
190
+ const d = await h.daemon();
191
+ await withRoot(h, () => capture(async () => {
192
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
193
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
194
+ const [idA] = listBatches(h.root);
195
+ const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
196
+ await until(() => h.launched().includes(5) && h.lanePids().length === 2);
197
+ const idB = listBatches(h.root).find((x) => x !== idA);
198
+ const res = await orchestrateFromCli(["--cancel", idA], h.client);
199
+ assert.deepEqual(res, { ok: true, batch: idA, cancelled: true });
200
+ const doneA = await a;
201
+ assert.deepEqual(doneA.rows.map((r) => [r.issue, r.outcome, r.detail]), [[1, "held", "cancelled by operator"], [2, "held", "cancelled by operator"]]);
202
+ assert.ok(openBatch(h.root, idA).readReport(), "the cancelled batch wrote its report");
203
+ assert.deepEqual(h.launched(), [1, 5], "#2 never launched");
204
+ const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
205
+ assert.equal(st.pid, d.pid, "the daemon kept running");
206
+ assert.deepEqual(st.batches.map((x) => x.id), [idB]);
207
+ writeFileSync(h.release, "");
208
+ const doneB = await b;
209
+ assert.deepEqual(doneB.rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]]);
210
+ const again = await orchestrateFromCli(["--cancel", idA], h.client);
211
+ assert.equal(again.cancelled, false, "a finished batch is left alone");
212
+ await assert.rejects(orchestrateFromCli(["--cancel", "nope"], h.client), /no batch nope/);
213
+ }));
214
+ } finally { h.cleanup(); }
215
+ });
216
+
217
+ it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
218
+ const h = host();
219
+ try {
220
+ const statusIds = async () => (await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" })).batches.map((x) => x.id);
221
+ const kill = async (d) => { const gone = new Promise((r) => d.on("exit", r)); d.kill("SIGKILL"); await gone; };
222
+ let d = await h.daemon();
223
+ await withRoot(h, () => capture(async () => {
224
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
225
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
226
+ const [idA] = listBatches(h.root);
227
+ assert.equal((await orchestrateFromCli(["--cancel", idA], h.client)).cancelled, true);
228
+ await a;
229
+ await kill(d);
230
+ d = await h.daemon();
231
+ await new Promise((r) => setTimeout(r, 300));
232
+ assert.deepEqual(await statusIds(), [], "the restarted daemon hosts no cancelled batch");
233
+ assert.deepEqual(h.launched(), [1]);
234
+ }));
235
+ } finally { h.cleanup(); }
236
+ });
237
+
154
238
  it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
155
239
  const h = host();
156
240
  try {
@@ -171,6 +255,89 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
171
255
  } finally { h.cleanup(); }
172
256
  });
173
257
 
258
+ it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
259
+ const dir = tmpDir("schedd-stop-"), root = join(dir, "batches"), lock = scheddPaths(dir).lock;
260
+ const started = { a: [], b: [] }, stoppedWithLock = {};
261
+ let releaseLane;
262
+ const lane = new Promise((r) => { releaseLane = r; });
263
+ // An unref'd cancellable sleep: a base without the stop cannot hang the test process on the 30-min requeue delay.
264
+ const sleep = (ms, sig) => new Promise((r) => { const t = setTimeout(r, ms); t.unref(); sig?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); });
265
+ const runBatch = async ({ argv, sink, signal }) => {
266
+ const id = argv[0];
267
+ sink.batch({ id, dir: join(root, id) });
268
+ try {
269
+ return await orchestrate({ issues: id === "a" ? [1, 2] : [9], parallel: 1, readBody: async () => "", signal, sleep,
270
+ runOne: async (n) => {
271
+ started[id].push(n);
272
+ if (n === 1) { await lane; return { terminalReason: "merged" }; } // a detached lane, still running at shutdown
273
+ if (n === 9) return { terminalReason: "deferred", detail: `leased by x until ${new Date(Date.now() + 3600e3).toISOString()}` };
274
+ return { terminalReason: "merged" };
275
+ } });
276
+ } finally { stoppedWithLock[id] = existsSync(lock); }
277
+ };
278
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
279
+ const submit = async (id) => request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: [id], cwd: dir, env: {} }).catch((e) => e);
280
+ const clients = [submit("a"), submit("b")]; // each resolves (or rejects) once its connection ends
281
+ await until(() => started.a.length === 1 && started.b.length === 1);
282
+ await new Promise((r) => setTimeout(r, 100)); // b is asleep on its requeue delay
283
+ const t0 = Date.now();
284
+ await d.close();
285
+ assert.ok(Date.now() - t0 < 3000, `close() took ${Date.now() - t0} ms`);
286
+ assert.deepEqual(stoppedWithLock, { a: true, b: true }, "both batch loops stopped while the lock was still held");
287
+ assert.equal(existsSync(lock), false, "lock released after the loops stopped");
288
+ assert.equal(existsSync(scheddPaths(dir).sock), false);
289
+ releaseLane();
290
+ await new Promise((r) => setTimeout(r, 200));
291
+ assert.deepEqual(started, { a: [1], b: [9] }, "no hosted batch admits a lane after close()");
292
+ await Promise.all(clients);
293
+ });
294
+
295
+ it("#4454: close() waits at most stopMs for a batch that ignores the stop, then releases the lock", async () => {
296
+ const dir = tmpDir("schedd-bound-");
297
+ const d = await startDaemon({ dir, root: join(dir, "batches"), log: () => {}, resumeOnStart: false, stopMs: 200,
298
+ runBatch: ({ sink }) => { sink.batch({ id: "x", dir }); return new Promise(() => {}); } });
299
+ request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["x"], cwd: dir, env: {} }).catch(() => {});
300
+ await new Promise((r) => setTimeout(r, 50));
301
+ const t0 = Date.now();
302
+ await d.close();
303
+ const took = Date.now() - t0;
304
+ assert.ok(took >= 150 && took < 2000, `close() took ${took} ms`);
305
+ assert.equal(existsSync(scheddPaths(dir).lock), false);
306
+ });
307
+
308
+ it("#4454: kill -TERM stops both batches and exits 143 within the bound; lanes keep running; the next daemon resumes and adopts them", async () => {
309
+ const h = host();
310
+ try {
311
+ const d1 = await h.daemon();
312
+ await withRoot(h, () => capture(async () => {
313
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
314
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
315
+ const [idA] = listBatches(h.root);
316
+ const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
317
+ await until(() => h.launched().includes(5) && h.lanePids().length === 2);
318
+ const idB = listBatches(h.root).find((x) => x !== idA);
319
+ const lanes = h.lanePids();
320
+ const exit = new Promise((r) => d1.on("exit", (code) => r(code)));
321
+ const t0 = Date.now();
322
+ d1.kill("SIGTERM");
323
+ assert.equal(await exit, 143);
324
+ assert.ok(Date.now() - t0 < 12000, "exits within the bound");
325
+ for (const pid of lanes) assert.equal(isLaneAlive(pid, null), true, "lanes keep running");
326
+ assert.equal(existsSync(scheddPaths(h.sched).lock), false);
327
+ assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
328
+ assert.equal(openBatch(h.root, idB).readReport(), null);
329
+ assert.match(String((await a)?.message), /--resume/);
330
+ await b;
331
+ await h.daemon(); // resumes both batches
332
+ const ra = orchestrateFromCli(["--resume", idA], h.client), rb = orchestrateFromCli(["--resume", idB], h.client);
333
+ setTimeout(() => writeFileSync(h.release, ""), 200);
334
+ assert.deepEqual((await ra).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
335
+ assert.deepEqual((await rb).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]]);
336
+ assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
337
+ }));
338
+ } finally { h.cleanup(); }
339
+ });
340
+
174
341
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
175
342
  const kept = batchEnv({ PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s" });
176
343
  assert.deepEqual(kept, { PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Builder self-review (#4374): the builder answers the shared `REVIEW_RUBRIC` (prompts.mjs) in result.json
5
+ * `self_review: [{question, answer, evidence}]` inside its build session. The engine only reads and logs it: a missing
6
+ * self-review is SELF_REVIEW_MISSING, and the first reviewer judgement is compared with it (SELF_REVIEW_AGREED /
7
+ * SELF_REVIEW_DISAGREED). Never a failure, never a runner call. Pure: no I/O.
8
+ */
9
+
10
+ const text = (v) => (v === null || v === undefined ? "" : String(v)).trim();
11
+
12
+ /**
13
+ * The self-review of a result: `present` with its items and the questions not answered yes, `missing` when a fixed result
14
+ * has no non-empty `self_review` array, `invalid` when an entry lacks a question or an answer, or null for a result
15
+ * that is not `fixed` (nothing to self-review).
16
+ */
17
+ export function readSelfReview(result) {
18
+ if (!result || typeof result !== "object" || result.status !== "fixed") return null;
19
+ const raw = result.self_review;
20
+ if (!Array.isArray(raw) || !raw.length) return { state: "missing", items: [], no: [] };
21
+ const items = raw.map((x) => ({ question: text(x?.question), answer: text(x?.answer), evidence: text(x?.evidence) }));
22
+ if (items.some((x) => !x.question || !x.answer)) return { state: "invalid", items, no: [] };
23
+ return { state: "present", items, no: items.filter((x) => x.answer.toLowerCase() !== "yes").map((x) => x.question) };
24
+ }
25
+
26
+ /**
27
+ * Compare a present self-review with a reviewer decision (`decide()` state). Null when either side has no judgement
28
+ * (self-review not present, reviewer reply incomplete, unparseable or skipped). Agreement: all yes and the reviewer
29
+ * conforms, or any no and the reviewer finds it nonconforming.
30
+ */
31
+ export function compareSelfReview(self, decision) {
32
+ if (self?.state !== "present") return null;
33
+ const reviewer = decision?.state;
34
+ if (reviewer !== "conforms" && reviewer !== "nonconforming") return null;
35
+ const allYes = self.no.length === 0;
36
+ return { agreed: allYes === (reviewer === "conforms"), reviewer, self_no: self.no };
37
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { readSelfReview, compareSelfReview } from "./selfreview.mjs";
6
+
7
+ const item = (question, answer = "yes", evidence = "src/a.py:3") => ({ question, answer, evidence });
8
+
9
+ describe("readSelfReview (#4374)", () => {
10
+ it("only a fixed result is self-reviewed", () => {
11
+ for (const r of [null, undefined, "x", { status: "blocked" }, { status: "invalid", self_review: [item("1")] }]) assert.equal(readSelfReview(r), null);
12
+ });
13
+
14
+ it("a fixed result without a non-empty self_review array is missing", () => {
15
+ for (const self_review of [undefined, null, [], "yes", {}]) assert.deepEqual(readSelfReview({ status: "fixed", self_review }), { state: "missing", items: [], no: [] });
16
+ });
17
+
18
+ it("an entry without a question or an answer is invalid", () => {
19
+ assert.equal(readSelfReview({ status: "fixed", self_review: [item("1"), { question: "2" }] }).state, "invalid");
20
+ assert.equal(readSelfReview({ status: "fixed", self_review: [null] }).state, "invalid");
21
+ });
22
+
23
+ it("present: items normalized, the questions not answered yes listed", () => {
24
+ const sr = readSelfReview({ status: "fixed", self_review: [item(" 1 ", "YES"), item("2", "no", 7), item(3, "partly", null)] });
25
+ assert.equal(sr.state, "present");
26
+ assert.deepEqual(sr.items, [item("1", "YES"), item("2", "no", "7"), { question: "3", answer: "partly", evidence: "" }]);
27
+ assert.deepEqual(sr.no, ["2", "3"]);
28
+ });
29
+ });
30
+
31
+ describe("compareSelfReview (#4374)", () => {
32
+ const yes = readSelfReview({ status: "fixed", self_review: [item("1"), item("2")] });
33
+ const no = readSelfReview({ status: "fixed", self_review: [item("1"), item("2", "no")] });
34
+
35
+ it("all yes agrees with conforms and disagrees with nonconforming", () => {
36
+ assert.deepEqual(compareSelfReview(yes, { state: "conforms" }), { agreed: true, reviewer: "conforms", self_no: [] });
37
+ assert.deepEqual(compareSelfReview(yes, { state: "nonconforming" }), { agreed: false, reviewer: "nonconforming", self_no: [] });
38
+ });
39
+
40
+ it("a no agrees with nonconforming and disagrees with conforms", () => {
41
+ assert.deepEqual(compareSelfReview(no, { state: "nonconforming" }), { agreed: true, reviewer: "nonconforming", self_no: ["2"] });
42
+ assert.deepEqual(compareSelfReview(no, { state: "conforms" }), { agreed: false, reviewer: "conforms", self_no: ["2"] });
43
+ });
44
+
45
+ it("no comparison without a judgement on either side", () => {
46
+ for (const s of ["review.incomplete", "review.unparseable", "review.inconsistent", "environment", undefined]) assert.equal(compareSelfReview(yes, { state: s }), null, String(s));
47
+ assert.equal(compareSelfReview(null, { state: "conforms" }), null);
48
+ assert.equal(compareSelfReview({ state: "missing", items: [], no: [] }, { state: "conforms" }), null);
49
+ assert.equal(compareSelfReview(yes, null), null);
50
+ });
51
+ });
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Host sequence lease (#4386): one lane at a time per (repo, lane branch, sequence dir) in the window from the
5
+ * merge-gate renumber of its numbered files to its merge, so parallel lanes stop overtaking each other's numbers.
6
+ * Each key is a one-slot `heavy.mjs` semaphore in its own registry dir (`schedDir()/seq-lease/<key>/heavy.json`):
7
+ * file-locked, FIFO, dead holders dropped by pid. The heavy-step registry and its env caps are never touched.
8
+ * Fail-open: a wait over `maxWaitS` (or an unreadable registry) proceeds without the lease; CI and base-sync stay the gate.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { join } from "node:path";
12
+ import { acquireHeavy, openHeavy } from "./heavy.mjs";
13
+ import { schedDir } from "./sched.mjs";
14
+
15
+ /** The lease key of one sequence dir of one lane branch of one repo. */
16
+ export const sequenceLeaseKey = (repo, lane, dir) => createHash("sha1").update(`${repo}\0${lane}\0${dir}`).digest("hex").slice(0, 16);
17
+
18
+ const NO_MEM = Object.freeze({ cores: () => 2, memAvailableMb: () => null });
19
+
20
+ /**
21
+ * `acquire({repo, lane, dirs, issue, maxWaitS})` takes every dir's lease in sorted order (no lock-order deadlock).
22
+ * @returns {{acquire: (e:object) => Promise<{waitMs:number, timedOut:boolean, release:Function}>}}
23
+ */
24
+ export function makeSequenceLeaseIo({ dir = join(schedDir(), "seq-lease"), open = openHeavy, acquire = acquireHeavy, pollMs } = {}) {
25
+ return {
26
+ async acquire({ repo, lane, dirs = [], issue = null, maxWaitS = 1800 } = {}) {
27
+ const held = [];
28
+ let waitMs = 0, timedOut = false;
29
+ for (const d of [...new Set(dirs)].sort()) {
30
+ const h = open(join(dir, sequenceLeaseKey(repo, lane, d)), { cfg: { max: 1, max_wait_s: maxWaitS }, env: {}, host: NO_MEM });
31
+ const g = await acquire(h, { issue, phase: "sequence" }, ...(pollMs ? [{ pollMs }] : []));
32
+ waitMs += g.waitMs ?? 0;
33
+ if (g.timedOut) timedOut = true;
34
+ held.push(g);
35
+ }
36
+ let released = false;
37
+ return { waitMs, timedOut, release: () => { if (released) return; released = true; for (const g of held.reverse()) { try { g.release(); } catch { /* fail-open */ } } } };
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Host sequence lease (#4386): one numbered-file lane per (repo, lane, dir) in the renumber→CI→merge window.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { tmpDir } from "../tests/_tmp.mjs";
10
+ import { makeSequenceLeaseIo, sequenceLeaseKey } from "./seqlease.mjs";
11
+ import { sequenceCollisions, journalProblems, renumberLane } from "./sequence.mjs";
12
+
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ const lease = () => makeSequenceLeaseIo({ dir: tmpDir("seqlease-"), pollMs: 20 });
15
+
16
+ describe("sequence lease (#4386)", () => {
17
+ it("a second acquire on the same (repo, lane, dir) waits for the release; other dirs, lanes and repos do not; release is idempotent", async () => {
18
+ const io = lease(), order = [];
19
+ const a = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 30 });
20
+ const other = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["docs/adr"], maxWaitS: 30 });
21
+ const otherLane = await io.acquire({ repo: "o/r", lane: "milestone/x", dirs: ["db"], maxWaitS: 30 });
22
+ assert.equal(other.timedOut, false);
23
+ assert.equal(otherLane.timedOut, false);
24
+ const b = io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 30 }).then((g) => { order.push("b"); return g; });
25
+ await sleep(150);
26
+ order.push("a-release");
27
+ a.release(); a.release();
28
+ const gb = await b;
29
+ assert.deepEqual(order, ["a-release", "b"]);
30
+ assert.ok(gb.waitMs >= 100);
31
+ gb.release(); other.release(); otherLane.release();
32
+ assert.notEqual(sequenceLeaseKey("o/r", "staging", "db"), sequenceLeaseKey("o/r", "staging", "db2"));
33
+ });
34
+
35
+ it("a wait over maxWaitS proceeds without the lease (fail-open)", async () => {
36
+ const io = lease();
37
+ const a = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 30 });
38
+ const b = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 0.1 });
39
+ assert.equal(b.timedOut, true);
40
+ b.release(); a.release();
41
+ });
42
+ });
43
+
44
+ const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
45
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
46
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
47
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
48
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
49
+ const journal = (...tags) => `${JSON.stringify({ version: "7", entries: tags.map((tag, idx) => ({ idx, tag, when: 1000 + idx })) }, null, 2)}\n`;
50
+ const snap = (id, prevId) => `${JSON.stringify({ version: "7", id, prevId, tables: { t: 1 } }, null, 2)}\n`;
51
+
52
+ describe("two lanes adding migrations to the same lane branch (#4386) [integration]", () => {
53
+ it("the second renumbers at the merge gate under the lease and merges; neither ends base.conflict", async () => {
54
+ const root = tmpDir("seqlanes-"), origin = join(root, "origin.git"), seed = join(root, "seed");
55
+ sh(root, "init", "-q", "--bare", "-b", "staging", origin);
56
+ sh(root, "clone", "-q", origin, seed);
57
+ sh(seed, "checkout", "-qb", "staging");
58
+ put(seed, { "db/0048_init.sql": "CREATE TABLE t (id int);\n", "db/meta/_journal.json": journal("0048_init"), "db/meta/0048_snapshot.json": snap("s48", "s47") });
59
+ commit(seed, "init");
60
+ sh(seed, "push", "-q", "origin", "staging");
61
+ const lanes = ["a", "b"].map((n) => {
62
+ const d = join(root, `lane-${n}`);
63
+ sh(root, "clone", "-q", "-b", "staging", origin, d);
64
+ sh(d, "checkout", "-qb", `feat/${n}`);
65
+ put(d, { [`db/0049_${n}.sql`]: `INSERT INTO t VALUES ('${n}');\n`, "db/meta/_journal.json": journal("0048_init", `0049_${n}`), "db/meta/0049_snapshot.json": snap(`lane-${n}`, "s48") });
66
+ commit(d, `lane ${n}`);
67
+ return d;
68
+ });
69
+ const io = lease(), outcomes = [];
70
+ let inWindow = 0, maxInWindow = 0;
71
+ /** The merge gate of one lane: lease, re-fetch, late renumber when the number is taken, "CI", merge, release. */
72
+ const gate = async (d) => {
73
+ const l = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 60 });
74
+ inWindow++; maxInWindow = Math.max(maxInWindow, inWindow);
75
+ try {
76
+ sh(d, "fetch", "-q", "origin", "staging");
77
+ const base = "origin/staging";
78
+ const c = await sequenceCollisions({ git: gitIn(d), base });
79
+ let how = "no collision";
80
+ if (c.length) {
81
+ const r = await renumberLane({ git: gitIn(d), base, cwd: d });
82
+ assert.ok(r.ok, r.unsupported);
83
+ how = `renumbered ${r.moves.map((m) => m.to).join(", ")}`;
84
+ }
85
+ await sleep(100); // required checks
86
+ sh(d, "push", "-q", "origin", "HEAD:staging"); // the merge: rejected if the base moved meanwhile
87
+ outcomes.push({ d, how, outcome: "merged" });
88
+ } catch (e) { outcomes.push({ d, outcome: "base.conflict", error: String(e?.message ?? e) }); }
89
+ finally { inWindow--; l.release(); }
90
+ };
91
+ await Promise.all(lanes.map(gate));
92
+ assert.deepEqual(outcomes.map((o) => o.outcome), ["merged", "merged"], JSON.stringify(outcomes));
93
+ assert.equal(maxInWindow, 1, "one numbered-file PR in the window at a time");
94
+ assert.equal(outcomes[0].how, "no collision");
95
+ assert.match(outcomes[1].how, /renumbered db\/0050_[ab]\.sql/);
96
+ sh(seed, "pull", "-q", "origin", "staging");
97
+ const tags = JSON.parse(sh(seed, "show", "HEAD:db/meta/_journal.json")).entries.map((e) => e.tag);
98
+ assert.deepEqual(tags.map((t) => t.slice(0, 4)), ["0048", "0049", "0050"]);
99
+ assert.deepEqual(await journalProblems({ git: gitIn(seed), dirs: ["db"] }), []);
100
+ });
101
+ });