forgedock 1.32.0 → 1.33.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 (69) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +29 -8
  3. package/bin/engine-cli.mjs +84 -24
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.mjs +9 -3
  8. package/bin/lean/batch.test.mjs +134 -3
  9. package/bin/lean/budget.mjs +7 -1
  10. package/bin/lean/budget.test.mjs +9 -1
  11. package/bin/lean/conformance.mjs +20 -7
  12. package/bin/lean/conformance.test.mjs +29 -2
  13. package/bin/lean/depcache.mjs +23 -3
  14. package/bin/lean/depcache.test.mjs +42 -1
  15. package/bin/lean/effects.mjs +29 -3
  16. package/bin/lean/execution.mjs +106 -14
  17. package/bin/lean/execution.test.mjs +101 -1
  18. package/bin/lean/hostkill.mjs +8 -0
  19. package/bin/lean/lean.mjs +65 -24
  20. package/bin/lean/lean.test.mjs +221 -10
  21. package/bin/lean/lightlane.mjs +38 -13
  22. package/bin/lean/lightlane.test.mjs +35 -1
  23. package/bin/lean/orchestrate.mjs +93 -26
  24. package/bin/lean/prompts.mjs +17 -7
  25. package/bin/lean/recovery.test.mjs +26 -0
  26. package/bin/lean/retry.mjs +15 -1
  27. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  28. package/bin/lean/sandbox-remote.mjs +40 -7
  29. package/bin/lean/schedd.mjs +50 -14
  30. package/bin/lean/schedd.test.mjs +133 -21
  31. package/bin/lean/seqlease.mjs +49 -2
  32. package/bin/lean/seqlease.test.mjs +14 -0
  33. package/bin/lean/sequence.mjs +54 -6
  34. package/bin/lean/sequence.test.mjs +83 -1
  35. package/bin/pool/agent.mjs +435 -0
  36. package/bin/pool/cli.mjs +110 -0
  37. package/bin/pool/docker.mjs +56 -0
  38. package/bin/pool/http.mjs +55 -0
  39. package/bin/pool/router.mjs +299 -0
  40. package/bin/pool/store.mjs +25 -0
  41. package/bin/pool/version.mjs +44 -0
  42. package/bin/runner.mjs +1 -0
  43. package/bin/stats/aggregate.mjs +6 -0
  44. package/bin/stats/index.mjs +14 -2
  45. package/bin/stats/local-source.mjs +1 -1
  46. package/bin/stats/pool.mjs +51 -0
  47. package/bin/stats/pool.test.mjs +39 -0
  48. package/bin/stats/query.mjs +2 -2
  49. package/bin/stats/stats.test.mjs +15 -1
  50. package/commands/orchestrate.md +3 -3
  51. package/docs/CONFIG.md +23 -9
  52. package/docs/OUTCOME-CODES.md +5 -3
  53. package/docs/STATS.md +7 -0
  54. package/package.json +2 -1
  55. package/pool/README.md +13 -0
  56. package/pool/image/Dockerfile +46 -0
  57. package/pool/image/claude-shim +8 -0
  58. package/pool/image/cs-cred +12 -0
  59. package/pool/image/entrypoint.sh +61 -0
  60. package/pool/image/gh-shim +6 -0
  61. package/pool/install/install-agent-linux.sh +53 -0
  62. package/pool/install/install-agent.ps1 +225 -0
  63. package/pool/install/install-agent.sh +101 -0
  64. package/pool/install/install-router.sh +67 -0
  65. package/pool/install/keepalive.ps1 +36 -0
  66. package/pool/install/uninstall-agent.ps1 +20 -0
  67. package/scripts/hotspots.sh +33 -21
  68. package/scripts/hotspots.test.sh +22 -3
  69. package/scripts/refresh-bot-token.test.sh +56 -0
@@ -24,6 +24,9 @@
24
24
  * Shutdown (#4454): `close()` aborts every hosted batch's `signal` (its loop admits nothing more and leaves, lanes keep
25
25
  * running, no report is written), waits for them up to `stopMs`, and only then releases `schedd.lock` and the socket, so a
26
26
  * daemon started next never resumes a batch this one still dispatches. `exitOnSignals` exits 128+signo after that.
27
+ * Exit reasons (#4503): every exit logs a line: `idle, exiting`, `exiting (<signal>|uncaught: …)`, `lost lock|socket …,
28
+ * exiting` (checked every `lockCheckMs`: the lock no longer holds this pid, or the socket is gone; nothing it does not own
29
+ * is removed), then `exited (code N)`. Taking over a dead owner's lock is logged, so a SIGKILLed daemon leaves a trace.
27
30
  * `runBatch({argv, cwd, env, sink, keepBreaker, signal})` is injected (engine-cli `runBatch`); `sink` gets `batch(b)`, `row(r)`, `line(text)`.
28
31
  */
29
32
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
@@ -35,6 +38,7 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
35
38
 
36
39
  export const IDLE_MS = 10 * 60 * 1000;
37
40
  export const STOP_MS = 10 * 1000;
41
+ export const LOCK_CHECK_MS = 5 * 1000;
38
42
 
39
43
  export function scheddPaths(dir = schedDir()) {
40
44
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
@@ -54,17 +58,19 @@ export function mergeEnv(base, kept) {
54
58
  return { ...out, ...kept };
55
59
  }
56
60
 
57
- /** Take the daemon lock; null when another live daemon holds it. */
61
+ /** Take the daemon lock: `{took}` (the dead owner's pid it replaced, or null); null when another live daemon holds it. */
58
62
  function takeLock(lock, pid, alive) {
63
+ let took = null;
59
64
  for (let i = 0; i < 5; i++) {
60
65
  try {
61
66
  const fd = openSync(lock, "wx");
62
67
  try { writeSync(fd, String(pid)); } finally { closeSync(fd); }
63
- return true;
68
+ return { took };
64
69
  } catch (e) { if (e?.code !== "EEXIST") throw e; }
65
70
  let owner = NaN;
66
71
  try { owner = parseInt(readFileSync(lock, "utf8"), 10); } catch { continue; }
67
72
  if (Number.isInteger(owner) && owner !== pid && alive(owner)) return null;
73
+ if (Number.isInteger(owner) && owner !== pid) took = owner;
68
74
  rmSync(lock, { force: true });
69
75
  }
70
76
  return null;
@@ -74,10 +80,13 @@ function takeLock(lock, pid, alive) {
74
80
  * Start the daemon. @returns {Promise<{running:true, pid, close, closed:Promise} | {running:false, owner:number}>}
75
81
  */
76
82
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
77
- root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS } = {}) {
83
+ root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
84
+ lockCheckMs = LOCK_CHECK_MS } = {}) {
78
85
  const p = scheddPaths(dir);
79
86
  mkdirSync(dir, { recursive: true });
80
- if (!takeLock(p.lock, pid, alive)) {
87
+ const lock = takeLock(p.lock, pid, alive);
88
+ if (lock?.took) log(`schedd: took over the lock of dead pid ${lock.took} (it exited without a clean shutdown)`);
89
+ if (!lock) {
81
90
  let owner = NaN;
82
91
  try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
83
92
  log(`schedd: already running (pid ${owner})`);
@@ -88,7 +97,8 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
88
97
  const hosted = new Map(); // batch id -> {id, dir, subs:Set<fn>}
89
98
  const conns = new Set();
90
99
  const runs = new Map(); // every runBatch in flight (hosted or not yet started) -> its AbortController (#4454)
91
- let closing = null, resolveClosed;
100
+ const starting = new Map(); // batch id -> host() promise of a start-up resume not yet hosted (#4503: an attach waits for it)
101
+ let closing = null, resolveClosed, keepSock = false;
92
102
  let lastActive = Date.now();
93
103
  const touch = () => { lastActive = Date.now(); };
94
104
 
@@ -143,6 +153,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
143
153
  return send({ type: "batch", id: ent.id, dir: ent.dir });
144
154
  }
145
155
  if (req.op === "attach") {
156
+ if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
146
157
  const live = hosted.get(req.id);
147
158
  if (live) {
148
159
  // Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
@@ -191,6 +202,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
191
202
  const close = () => {
192
203
  if (closing) return closing;
193
204
  clearInterval(idleTimer);
205
+ clearInterval(lockTimer);
194
206
  for (const c of conns) c.destroy();
195
207
  const stopping = [...runs.keys()];
196
208
  for (const ac of runs.values()) ac.abort();
@@ -198,9 +210,11 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
198
210
  // #4454: the lock goes only once every hosted batch loop has stopped (or `stopMs` passed: the caller exits regardless)
199
211
  const stopped = stopping.length ? Promise.race([Promise.allSettled(stopping).then(() => true), new Promise((r) => { timer = setTimeout(() => r(false), stopMs); })])
200
212
  .then((ok) => { clearTimeout(timer); if (!ok) log(`schedd: ${runs.size} batch(es) did not stop within ${stopMs} ms`); }) : Promise.resolve();
201
- closing = Promise.all([new Promise((r) => server.close(() => r())), stopped]).then(() => {
213
+ // A lost lock: the socket path may be the successor's, and closing the server unlinks it (libuv), so leave it unref'd.
214
+ const serverClosed = keepSock ? Promise.resolve(server.unref()) : new Promise((r) => server.close(() => r()));
215
+ closing = Promise.all([serverClosed, stopped]).then(() => {
202
216
  try { if (parseInt(readFileSync(p.lock, "utf8"), 10) === pid) rmSync(p.lock, { force: true }); } catch { /* gone */ }
203
- rmSync(p.sock, { force: true });
217
+ if (!keepSock) rmSync(p.sock, { force: true });
204
218
  resolveClosed();
205
219
  });
206
220
  return closing;
@@ -209,6 +223,15 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
209
223
  if (!hosted.size && !conns.size && Date.now() - lastActive >= idleMs) { log("schedd: idle, exiting"); close(); }
210
224
  }, Math.min(idleMs, 30_000));
211
225
  idleTimer.unref?.();
226
+ // #4503: a daemon whose lock or socket was taken stops (its batches stay resumable) instead of running unreachable.
227
+ const lockTimer = setInterval(() => {
228
+ if (closing) return;
229
+ let owner = NaN;
230
+ try { owner = parseInt(readFileSync(p.lock, "utf8"), 10); } catch { /* gone */ }
231
+ if (owner !== pid) { keepSock = true; log(`schedd: lost lock (owner ${Number.isInteger(owner) ? `pid ${owner}` : "none"}), exiting`); close(); }
232
+ else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
233
+ }, lockCheckMs);
234
+ lockTimer.unref?.();
212
235
 
213
236
  // Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
214
237
  if (resumeOnStart) {
@@ -216,22 +239,35 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
216
239
  const b = openBatch(root, id);
217
240
  if (!b.readPlan()?.daemon || b.readReport()) continue;
218
241
  log(`schedd: resuming batch ${id}`);
219
- host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true }).catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`));
242
+ const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
243
+ starting.set(id, started);
244
+ started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
220
245
  }
221
246
  }
222
247
  return { running: true, pid, sock: p.sock, close, closed };
223
248
  }
224
249
 
225
- /** #4454: on SIGTERM/SIGINT stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo, once. */
226
- export function exitOnSignals(d, { on = (s, f) => process.once(s, f), exit = (c) => process.exit(c), signals = ["SIGTERM", "SIGINT"] } = {}) {
227
- const SIGNO = { SIGINT: 2, SIGTERM: 15 };
250
+ /**
251
+ * #4454: on SIGTERM/SIGINT/SIGHUP stop the daemon (`close()`: batch loops first, then lock and socket), then exit 128+signo,
252
+ * once; an uncaught exception or unhandled rejection does the same and exits 1. #4503: each logs its reason first, and the
253
+ * process `exit` logs `schedd: exited (code N)`.
254
+ */
255
+ export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s === "exit" ? process.once(s, f) : process.on(s, f)),
256
+ exit = (c) => process.exit(c), log = (m) => console.log(m), signals = ["SIGTERM", "SIGINT", "SIGHUP"] } = {}) {
257
+ const SIGNO = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
228
258
  let fired = false;
229
- for (const sig of signals) on(sig, async () => {
259
+ const stop = async (reason, code, detail = null) => {
230
260
  if (fired) return;
231
261
  fired = true;
262
+ log(`schedd: exiting (${reason})`);
263
+ if (detail) log(detail);
232
264
  try { await d.close(); } catch { /* exit anyway */ }
233
- exit(128 + (SIGNO[sig] ?? 0));
234
- });
265
+ exit(code);
266
+ };
267
+ for (const sig of signals) on(sig, () => stop(sig, 128 + (SIGNO[sig] ?? 0)));
268
+ on("uncaughtException", (e) => stop(`uncaught: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
269
+ on("unhandledRejection", (e) => stop(`unhandled rejection: ${String(e?.message ?? e).split("\n")[0]}`, 1, e?.stack ?? null));
270
+ on("exit", (code) => log(`schedd: exited (code ${code})`));
235
271
  }
236
272
 
237
273
  /** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
@@ -3,14 +3,23 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { tmpDir } from "../tests/_tmp.mjs";
9
10
  import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
10
- import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv } from "./schedd.mjs";
11
+ import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
12
+ import { schedDir } from "./sched.mjs";
11
13
  import { formatTable, orchestrate } from "./orchestrate.mjs";
12
14
  import { orchestrateFromCli } from "../engine-cli.mjs";
13
15
 
16
+ // #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
17
+ process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
18
+ // Stub lanes run detached: a host whose test never reached its `finally` still has them reaped when the file ends.
19
+ const hosts = [];
20
+ process.on("exit", () => { for (const h of hosts) h.reap(); });
21
+ const LANE_MAX_MS = 120_000;
22
+
14
23
  const until = async (cond, ms = 15000) => {
15
24
  const end = Date.now() + ms;
16
25
  while (!cond()) { if (Date.now() > end) throw new Error("timed out waiting"); await new Promise((r) => setTimeout(r, 25)); }
@@ -24,13 +33,20 @@ const capture = async (fn) => {
24
33
  /** A host: sched + batch dirs, a stub lane that waits for `release`, and a daemon script with injected lanes. */
25
34
  function host() {
26
35
  const work = tmpDir("schedd-");
27
- const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [] };
36
+ const h = { work, sched: join(work, "s"), root: join(work, "batches"), launches: join(work, "launches"), release: join(work, "release"), procs: [],
37
+ lanePidFile: join(work, "lane-pids"), log: join(work, "schedd.log") };
28
38
  writeFileSync(h.launches, "");
39
+ writeFileSync(h.lanePidFile, "");
40
+ hosts.push(h);
29
41
  const stub = join(work, "stub-lane.mjs");
30
42
  writeFileSync(stub, `import { appendFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
31
43
  const a = process.argv.slice(2); const n = Number(a[1]); const rf = a[a.indexOf("--result-file") + 1];
32
44
  appendFileSync(${JSON.stringify(h.launches)}, n + "\\n");
33
- const t = setInterval(() => { if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
45
+ appendFileSync(${JSON.stringify(h.lanePidFile)}, process.pid + "\\n");
46
+ // #4503: a stub whose test run is gone (work dir removed) or that outlived its bound exits instead of polling forever.
47
+ const born = Date.now();
48
+ const t = setInterval(() => { if (!existsSync(${JSON.stringify(work)}) || Date.now() - born > ${LANE_MAX_MS}) process.exit(0);
49
+ if (!existsSync(${JSON.stringify(h.release)})) return; clearInterval(t);
34
50
  writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "merged", pr: 100 + n, cost_usd: 0 })); renameSync(rf + ".tmp", rf); }, 20);
35
51
  // Like run-issue's #4179 teardown: SIGTERM reports deferred run.interrupted.
36
52
  process.once("SIGTERM", () => { writeFileSync(rf + ".tmp", JSON.stringify({ terminalReason: "deferred", code: "run.interrupted", detail: "interrupted by SIGTERM" })); renameSync(rf + ".tmp", rf); process.exit(143); });
@@ -49,7 +65,9 @@ await d.closed;
49
65
  h.env = { ...process.env, FORGEDOCK_SCHED_DIR: h.sched, FORGEDOCK_BATCH_DIR: h.root, FORGEDOCK_LIMITS_FILE: join(work, "limits.json") };
50
66
  h.daemon = async () => {
51
67
  const before = existsSync(scheddPaths(h.sched).lock) ? readFileSync(scheddPaths(h.sched).lock, "utf8") : null;
52
- const child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: "ignore" });
68
+ const out = openSync(h.log, "a");
69
+ let child;
70
+ try { child = spawn(process.execPath, [h.daemonScript], { env: h.env, stdio: ["ignore", out, out] }); } finally { closeSync(out); }
53
71
  h.procs.push(child);
54
72
  await until(() => existsSync(scheddPaths(h.sched).sock) && existsSync(scheddPaths(h.sched).lock) && readFileSync(scheddPaths(h.sched).lock, "utf8") === String(child.pid) && readFileSync(scheddPaths(h.sched).lock, "utf8") !== before);
55
73
  // Wait for the socket to accept.
@@ -59,9 +77,15 @@ await d.closed;
59
77
  h.client = { scheddDir: h.sched, spawnDaemon: null };
60
78
  h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
61
79
  h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
62
- h.cleanup = () => {
63
- writeFileSync(h.release, "");
80
+ h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
81
+ h.reap = () => {
64
82
  for (const p of h.procs) { try { p.kill("SIGKILL"); } catch { /* gone */ } }
83
+ for (const pid of h.stubPids()) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
84
+ };
85
+ h.cleanup = async () => {
86
+ if (existsSync(h.work)) writeFileSync(h.release, "");
87
+ h.reap();
88
+ await until(() => h.stubPids().every((pid) => !isLaneAlive(pid, null)), 5000).catch(() => {});
65
89
  };
66
90
  return h;
67
91
  }
@@ -98,7 +122,7 @@ describe("schedd host scheduler (#4192)", () => {
98
122
  const id = done[0].batch;
99
123
  await withRoot(h, () => capture(() => orchestrateFromCli(["--set-parallel", id, "5"], h.client)));
100
124
  assert.equal(openBatch(h.root, id).readParallel(), 5);
101
- } finally { h.cleanup(); }
125
+ } finally { await h.cleanup(); }
102
126
  });
103
127
 
104
128
  it("a client that disconnects does not stop the batch; --resume reattaches, and replays the report once done", async () => {
@@ -127,7 +151,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
127
151
  assert.deepEqual(again.res.rows, res.rows);
128
152
  assert.ok(again.lines.join("\n").includes(formatTable(res.rows)));
129
153
  assert.deepEqual(h.launched(), [1, 2]);
130
- } finally { h.cleanup(); }
154
+ } finally { await h.cleanup(); }
131
155
  });
132
156
 
133
157
  it("a daemon restart resumes the batch from state and re-adopts its live lanes without duplicate launches", async () => {
@@ -150,7 +174,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
150
174
  assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103]]);
151
175
  assert.deepEqual(h.launched(), [1, 2, 3], "adopted, not relaunched");
152
176
  assert.deepEqual(openBatch(h.root, id).store.load().running, {});
153
- } finally { h.cleanup(); }
177
+ } finally { await h.cleanup(); }
154
178
  });
155
179
 
156
180
  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 () => {
@@ -181,7 +205,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
181
205
  }));
182
206
  assert.equal(res.rows.length, 3);
183
207
  assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
184
- } finally { h.cleanup(); }
208
+ } finally { await h.cleanup(); }
185
209
  });
186
210
 
187
211
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
@@ -211,7 +235,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
211
235
  assert.equal(again.cancelled, false, "a finished batch is left alone");
212
236
  await assert.rejects(orchestrateFromCli(["--cancel", "nope"], h.client), /no batch nope/);
213
237
  }));
214
- } finally { h.cleanup(); }
238
+ } finally { await h.cleanup(); }
215
239
  });
216
240
 
217
241
  it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
@@ -232,7 +256,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
232
256
  assert.deepEqual(await statusIds(), [], "the restarted daemon hosts no cancelled batch");
233
257
  assert.deepEqual(h.launched(), [1]);
234
258
  }));
235
- } finally { h.cleanup(); }
259
+ } finally { await h.cleanup(); }
236
260
  });
237
261
 
238
262
  it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
@@ -252,7 +276,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
252
276
  assert.equal(st.pid, process.pid);
253
277
  } finally { await third.close(); }
254
278
  assert.equal(existsSync(scheddPaths(h.sched).lock), false, "lock released on close");
255
- } finally { h.cleanup(); }
279
+ } finally { await h.cleanup(); }
256
280
  });
257
281
 
258
282
  it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
@@ -309,11 +333,12 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
309
333
  const h = host();
310
334
  try {
311
335
  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);
336
+ const { lines } = await withRoot(h, () => capture(async () => {
337
+ // #4503: a gives up at once (reconnectMs 0) with the resume hint; b re-attaches to the next daemon on its own.
338
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 }).then(() => null, (e) => e);
314
339
  await until(() => h.launched().length === 1 && h.lanePids().length === 1);
315
340
  const [idA] = listBatches(h.root);
316
- const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client).then(() => null, (e) => e);
341
+ const b = orchestrateFromCli(["5", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
317
342
  await until(() => h.launched().includes(5) && h.lanePids().length === 2);
318
343
  const idB = listBatches(h.root).find((x) => x !== idA);
319
344
  const lanes = h.lanePids();
@@ -327,15 +352,102 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
327
352
  assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
328
353
  assert.equal(openBatch(h.root, idB).readReport(), null);
329
354
  assert.match(String((await a)?.message), /--resume/);
330
- await b;
331
355
  await h.daemon(); // resumes both batches
332
- const ra = orchestrateFromCli(["--resume", idA], h.client), rb = orchestrateFromCli(["--resume", idB], h.client);
356
+ const ra = orchestrateFromCli(["--resume", idA], h.client);
333
357
  setTimeout(() => writeFileSync(h.release, ""), 200);
334
358
  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"]]);
359
+ assert.deepEqual((await b).rows.map((r) => [r.issue, r.outcome]), [[5, "merged"]], "b re-attached and finished");
336
360
  assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
337
361
  }));
338
- } finally { h.cleanup(); }
362
+ assert.ok(lines.some((l) => l.startsWith("schedd connection lost; reconnecting to batch ")), "the client said it reconnects");
363
+ const log = readFileSync(h.log, "utf8");
364
+ assert.match(log, /^schedd: exiting \(SIGTERM\)$/m);
365
+ assert.match(log, /^schedd: exited \(code 143\)$/m);
366
+ } finally { await h.cleanup(); }
367
+ });
368
+
369
+ it("#4503: every schedd exit writes a reason line", async () => {
370
+ for (const [ev, arg, code, re] of [["SIGTERM", undefined, 143, /^schedd: exiting \(SIGTERM\)$/], ["SIGINT", undefined, 130, /^schedd: exiting \(SIGINT\)$/],
371
+ ["SIGHUP", undefined, 129, /^schedd: exiting \(SIGHUP\)$/], ["uncaughtException", new Error("boom\nmore"), 1, /^schedd: exiting \(uncaught: boom\)$/],
372
+ ["unhandledRejection", new Error("nope"), 1, /^schedd: exiting \(unhandled rejection: nope\)$/]]) {
373
+ const handlers = {}, lines = [], exits = [];
374
+ exitOnSignals({ close: async () => { lines.push("closed"); } }, { on: (s, f) => { handlers[s] = f; }, exit: (c) => exits.push(c), log: (m) => lines.push(m) });
375
+ await handlers[ev](arg);
376
+ assert.match(lines[0], re, `${ev}: the reason is logged before close()`);
377
+ assert.equal(lines.at(-1), "closed");
378
+ assert.deepEqual(exits, [code]);
379
+ handlers.exit(code);
380
+ assert.equal(lines.at(-1), `schedd: exited (code ${code})`);
381
+ }
382
+ const dir = tmpDir("schedd-idle-"), lines = [];
383
+ const d = await startDaemon({ dir, root: join(dir, "batches"), runBatch: async () => ({}), log: (m) => lines.push(m), resumeOnStart: false, idleMs: 50 });
384
+ await d.closed;
385
+ assert.ok(lines.includes("schedd: idle, exiting"));
386
+ });
387
+
388
+ it("#4503: a daemon that lost its lock or socket logs it and stops its batches resumable, never removing the successor's lock or socket; a dead owner's takeover is logged", async () => {
389
+ const dir = tmpDir("schedd-lost-"), p = scheddPaths(dir), root = join(dir, "batches"), lines = [];
390
+ let stopped = false;
391
+ const d = await startDaemon({ dir, root, log: (m) => lines.push(m), resumeOnStart: false, lockCheckMs: 30,
392
+ runBatch: ({ sink, signal }) => { sink.batch({ id: "x", dir }); return new Promise((_, rej) => signal.addEventListener("abort", () => { stopped = true; rej(Object.assign(new Error("stopped"), { code: "batch.stopped" })); })); } });
393
+ request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["x"], cwd: dir, env: {} }).catch(() => {});
394
+ await until(() => lines.some((l) => l.includes("listening")));
395
+ await new Promise((r) => setTimeout(r, 50));
396
+ writeFileSync(p.lock, "999999999"); // a successor (now dead) took the lock
397
+ await d.closed;
398
+ assert.ok(lines.includes("schedd: lost lock (owner pid 999999999), exiting"), lines.join("\n"));
399
+ assert.equal(stopped, true, "the hosted batch loop was stopped (no report: the next daemon resumes it)");
400
+ assert.equal(readFileSync(p.lock, "utf8"), "999999999", "the successor's lock is left alone");
401
+ assert.equal(existsSync(p.sock), true, "the successor's socket is left alone");
402
+
403
+ const lines2 = [];
404
+ const d2 = await startDaemon({ dir, root, runBatch: async () => ({}), log: (m) => lines2.push(m), resumeOnStart: false, lockCheckMs: 30 });
405
+ assert.ok(lines2.includes("schedd: took over the lock of dead pid 999999999 (it exited without a clean shutdown)"), lines2.join("\n"));
406
+ rmSync(p.sock);
407
+ await d2.closed;
408
+ assert.ok(lines2.includes(`schedd: lost socket ${p.sock}, exiting`), lines2.join("\n"));
409
+ assert.equal(existsSync(p.lock), false, "its own lock is released");
410
+ });
411
+
412
+ it("#4503: a client whose daemon is SIGKILLed re-attaches to the next daemon and finishes, without relaunching a lane", async () => {
413
+ const h = host();
414
+ try {
415
+ const d1 = await h.daemon();
416
+ await withRoot(h, () => capture(async () => {
417
+ const a = orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
418
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
419
+ const gone = new Promise((r) => d1.on("exit", r));
420
+ d1.kill("SIGKILL");
421
+ await gone;
422
+ await new Promise((r) => setTimeout(r, 300)); // the client is retrying against the dead socket
423
+ await h.daemon();
424
+ setTimeout(() => writeFileSync(h.release, ""), 200);
425
+ assert.deepEqual((await a).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
426
+ assert.deepEqual(h.launched(), [1], "adopted, not relaunched");
427
+ }));
428
+ assert.match(readFileSync(h.log, "utf8"), /^schedd: took over the lock of dead pid \d+ \(it exited without a clean shutdown\)$/m);
429
+ } finally { await h.cleanup(); }
430
+ });
431
+
432
+ it("#4503: test runs never resolve ~/.forge/sched, and teardown leaves no live stub lane", async () => {
433
+ assert.notEqual(schedDir(), join(homedir(), ".forge", "sched"));
434
+ assert.ok(schedDir().startsWith(tmpdir()), schedDir());
435
+ const h = host();
436
+ let pids = [], a;
437
+ try {
438
+ await h.daemon();
439
+ await withRoot(h, async () => {
440
+ a = capture(() => orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
441
+ await until(() => h.stubPids().length === 2 && h.lanePids().length === 2);
442
+ });
443
+ pids = h.stubPids();
444
+ for (const pid of pids) assert.equal(isLaneAlive(pid, null), true);
445
+ const [id] = listBatches(h.root);
446
+ assert.equal(openBatch(h.root, id).readPlan().env.FORGEDOCK_SCHED_DIR, schedDir(), "the lanes' env pins the test sched dir");
447
+ } finally { await h.cleanup(); }
448
+ await a;
449
+ assert.equal(pids.length, 2);
450
+ for (const pid of pids) assert.equal(isLaneAlive(pid, null), false, `stub lane ${pid} reaped`);
339
451
  });
340
452
 
341
453
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
@@ -6,23 +6,70 @@
6
6
  * Each key is a one-slot `heavy.mjs` semaphore in its own registry dir (`schedDir()/seq-lease/<key>/heavy.json`):
7
7
  * file-locked, FIFO, dead holders dropped by pid. The heavy-step registry and its env caps are never touched.
8
8
  * Fail-open: a wait over `maxWaitS` (or an unreadable registry) proceeds without the lease; CI and base-sync stay the gate.
9
+ * #4526: number reservations. `reserve` picks the numbers of a renumber under a short host lock per (repo, base, dir) in
10
+ * its own registry (`schedDir()/seq-pick/<key>/`, never the lease above: its holder renumbers while it holds it) and
11
+ * records them in `reserved.json` until `unreserve` (run end), the holder's death or 24h, so concurrent lanes renumbering
12
+ * against the same base get distinct numbers without holding a lock across regenerate and push. Fail-open.
9
13
  */
10
14
  import { createHash } from "node:crypto";
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
16
  import { join } from "node:path";
12
17
  import { acquireHeavy, openHeavy } from "./heavy.mjs";
13
- import { schedDir } from "./sched.mjs";
18
+ import { pidAlive, schedDir } from "./sched.mjs";
14
19
 
15
20
  /** The lease key of one sequence dir of one lane branch of one repo. */
16
21
  export const sequenceLeaseKey = (repo, lane, dir) => createHash("sha1").update(`${repo}\0${lane}\0${dir}`).digest("hex").slice(0, 16);
17
22
 
18
23
  const NO_MEM = Object.freeze({ cores: () => 2, memAvailableMb: () => null });
24
+ const RESERVE_TTL_MS = 24 * 3600 * 1000;
19
25
 
20
26
  /**
21
27
  * `acquire({repo, lane, dirs, issue, maxWaitS})` takes every dir's lease in sorted order (no lock-order deadlock).
22
28
  * @returns {{acquire: (e:object) => Promise<{waitMs:number, timedOut:boolean, release:Function}>}}
23
29
  */
24
- export function makeSequenceLeaseIo({ dir = join(schedDir(), "seq-lease"), open = openHeavy, acquire = acquireHeavy, pollMs } = {}) {
30
+ export function makeSequenceLeaseIo({ dir = join(schedDir(), "seq-lease"), pickDir = join(schedDir(), "seq-pick"), open = openHeavy, acquire = acquireHeavy, pollMs, alive = pidAlive, now = () => Date.now() } = {}) {
31
+ const reservedDirs = new Map(); // `${repo}\0${lane}\0${issue}` -> dirs reserved in, for unreserve
32
+ /** Run `fn(entries) => entries'` on one key's reservations under its pick lock; returns what `fn` set as `out`. */
33
+ const locked = async ({ repo, lane, dir: d }, fn) => {
34
+ const kd = join(pickDir, sequenceLeaseKey(repo, lane, d)), file = join(kd, "reserved.json");
35
+ const h = open(kd, { cfg: { max: 1, max_wait_s: 60 }, env: {}, host: NO_MEM });
36
+ const g = await acquire(h, { phase: "sequence-pick" }, ...(pollMs ? [{ pollMs }] : []));
37
+ try {
38
+ let entries = [];
39
+ try { const o = JSON.parse(readFileSync(file, "utf8")); if (Array.isArray(o?.entries)) entries = o.entries; } catch { /* none yet */ }
40
+ const t = now();
41
+ entries = entries.filter((e) => Array.isArray(e?.numbers) && alive(e.pid) && t - Number(e.at ?? 0) < RESERVE_TTL_MS);
42
+ const r = fn(entries);
43
+ mkdirSync(kd, { recursive: true });
44
+ writeFileSync(`${file}.tmp`, `${JSON.stringify({ entries: r.entries }, null, 2)}\n`);
45
+ renameSync(`${file}.tmp`, file);
46
+ return r.out;
47
+ } finally { try { g.release(); } catch { /* fail-open */ } }
48
+ };
25
49
  return {
50
+ /**
51
+ * #4526: `count` consecutive numbers above max(floor, every other issue's live reservation) in (repo, lane, dir),
52
+ * recorded for `issue` (its earlier entries there replaced). @returns {Promise<{numbers:number[]}|null>} null on error.
53
+ */
54
+ async reserve({ repo, lane, dir: d, issue = null, floor = 0, count = 1, pid = process.pid } = {}) {
55
+ try {
56
+ const k = `${repo}\0${lane}\0${issue}`;
57
+ reservedDirs.set(k, new Set([...(reservedDirs.get(k) ?? []), d]));
58
+ return await locked({ repo, lane, dir: d }, (entries) => {
59
+ const others = entries.filter((e) => String(e.issue) !== String(issue));
60
+ let n = Math.max(Number(floor) || 0, ...others.flatMap((e) => e.numbers.map(Number)).filter(Number.isFinite));
61
+ const numbers = Array.from({ length: Math.max(0, count) }, () => ++n);
62
+ return { entries: [...others, { issue, pid, numbers, at: now() }], out: { numbers } };
63
+ });
64
+ } catch { return null; }
65
+ },
66
+ /** #4526: drop `issue`'s reservations in every dir of (repo, lane) it reserved in this process. Never throws. */
67
+ async unreserve({ repo, lane, issue = null } = {}) {
68
+ for (const d of [...(reservedDirs.get(`${repo}\0${lane}\0${issue}`) ?? [])]) {
69
+ try { await locked({ repo, lane, dir: d }, (entries) => ({ entries: entries.filter((e) => String(e.issue) !== String(issue)) })); } catch { /* fail-open */ }
70
+ }
71
+ reservedDirs.delete(`${repo}\0${lane}\0${issue}`);
72
+ },
26
73
  async acquire({ repo, lane, dirs = [], issue = null, maxWaitS = 1800 } = {}) {
27
74
  const held = [];
28
75
  let waitMs = 0, timedOut = false;
@@ -39,6 +39,20 @@ describe("sequence lease (#4386)", () => {
39
39
  assert.equal(b.timedOut, true);
40
40
  b.release(); a.release();
41
41
  });
42
+
43
+ it("#4526: reserve gives each issue distinct numbers above the floor and other issues' live reservations; dead or unreserved entries free them", async () => {
44
+ const dead = new Set();
45
+ const io = makeSequenceLeaseIo({ dir: tmpDir("seqlease-"), pickDir: tmpDir("seqpick-"), pollMs: 20, alive: (pid) => !dead.has(pid) });
46
+ const k = { repo: "o/r", lane: "staging", dir: "db" };
47
+ const [a, b] = await Promise.all([io.reserve({ ...k, issue: 1, floor: 51, count: 1, pid: 101 }), io.reserve({ ...k, issue: 2, floor: 51, count: 2, pid: 102 })]);
48
+ assert.deepEqual([...a.numbers, ...b.numbers].sort((x, y) => x - y), [52, 53, 54]);
49
+ assert.deepEqual((await io.reserve({ ...k, issue: 1, floor: 51, count: 1, pid: 101 })).numbers, [Math.max(...b.numbers) + 1], "own entry replaced, others kept");
50
+ assert.deepEqual((await io.reserve({ ...k, dir: "docs/adr", issue: 3, floor: 51, pid: 103 })).numbers, [52], "other dir: own registry");
51
+ assert.deepEqual((await io.reserve({ ...k, lane: "main", issue: 3, floor: 51, pid: 103 })).numbers, [52], "other base: own registry");
52
+ dead.add(102);
53
+ await io.unreserve({ repo: "o/r", lane: "staging", issue: 1 });
54
+ assert.deepEqual((await io.reserve({ ...k, issue: 4, floor: 51, pid: 104 })).numbers, [52], "dead holder and unreserved issue dropped");
55
+ });
42
56
  });
43
57
 
44
58
  const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
@@ -11,6 +11,9 @@
11
11
  * #4343: a leading calendar date (YYYY-MM-DD, YYYYMMDD) is not a number, and a directory only counts when it holds a
12
12
  * series on the base: a migration journal (`<dir>/meta/_journal.json`, `<dir>/_journal.json`), or >=2 distinct numbers
13
13
  * of the lane file's width that fill at least half of their min..max range (sparse issue-numbered docs are no series).
14
+ * #4526: renumberLane takes its numbers from a host reservation (`reserve`, seqlease.mjs) so concurrent lanes get distinct
15
+ * ones; verifyRenumber accepts a first-line header that only swaps the file's own tag (`headerOnlyChange`); a hand-written
16
+ * migration the generator writes nothing for gets the generator's `--custom` snapshot and keeps the lane's SQL.
14
17
  */
15
18
  import { execFile } from "node:child_process";
16
19
  import { randomUUID } from "node:crypto";
@@ -123,6 +126,21 @@ export function numberOnlyChange(was, now, from, to) {
123
126
  return new RegExp(`^${parts.map((p, i) => (i % 2 ? alt(p) : esc(p))).join("")}$`).test(String(now));
124
127
  }
125
128
 
129
+ /**
130
+ * #4526: everything after the first line is byte-identical, and the first line of `now` is that of `was` with the old
131
+ * stem (`fromPath` without extension) mapped to the new one, then the old number (padded or bare, not next to another
132
+ * digit) to the new number: a migration's `-- Migration 0067_x` or an ADR's `# 0029. Title` header following a renumber.
133
+ */
134
+ export function headerOnlyChange(was, now, fromPath, toPath) {
135
+ const from = numOf(fromPath), to = numOf(toPath);
136
+ if (!from || !to) return false;
137
+ const split = (t) => { const s = String(t), i = s.indexOf("\n"); return i < 0 ? [s, ""] : [s.slice(0, i), s.slice(i)]; };
138
+ const [h1, r1] = split(was), [h2, r2] = split(now);
139
+ if (r1 !== r2) return false;
140
+ const mapped = h1.replaceAll(stemOf(fromPath), stemOf(toPath)).replace(new RegExp(`(?<!\\d)0*${Number(from)}(?!\\d)`, "g"), (tok) => (tok.length >= to.length ? to : String(Number(to))));
141
+ return mapped === h2;
142
+ }
143
+
126
144
  /** Lane-added numbered files with their blobs, recorded before a renumber so verifyRenumber can prove the SQL kept. */
127
145
  export const snapshotLane = ({ git, base }) => laneNumbered(git, base).catch(() => []);
128
146
 
@@ -173,7 +191,7 @@ export async function verifyRenumber({ git, base, before = [], moves = null }) {
173
191
  for (const f of peers) {
174
192
  if (kept || f.path === b.path) continue;
175
193
  const text = await g(["cat-file", "-p", f.blob]);
176
- kept = numberOnlyChange(was, text, numOf(b.path), numOf(f.path)) || (rest(f.path) === rest(b.path) && text === mapped);
194
+ kept = numberOnlyChange(was, text, numOf(b.path), numOf(f.path)) || (rest(f.path) === rest(b.path) && text === mapped) || headerOnlyChange(was, text, b.path, f.path);
177
195
  }
178
196
  if (!kept) problems.push(`${b.path}: content changed or file gone after renumbering (keep it byte-identical, rename only)`);
179
197
  }
@@ -269,7 +287,8 @@ const writeJson = (p, o) => writeFileSync(p, `${JSON.stringify(o, null, 2)}\n`);
269
287
  * where it started. Never throws.
270
288
  * @returns {Promise<{ok:true, moves:Array<{from,to}>, commit:string|null}|{unsupported:string}>}
271
289
  */
272
- export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerate = null, run = null, uuid = randomUUID }) {
290
+ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerate = null, regenerateCustom = null, run = null, uuid = randomUUID, reserve = null }) {
291
+ const custom = regenerateCustom ?? (regenerate ? `${regenerate} --custom` : null);
273
292
  const g = out(git);
274
293
  const start = (await g(["rev-parse", "HEAD"])).trim();
275
294
  const written = [];
@@ -286,6 +305,16 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
286
305
  if (!collisions.length) return { ok: true, moves: [], commit: null };
287
306
  const odd = collisions.find((c) => !SUPPORTED_RE.test(c.lane));
288
307
  if (odd) return { unsupported: `${odd.lane}: no deterministic renumber for .${basename(odd.lane).split(".").pop()} migrations` };
308
+ // #4526: numbers above every other live lane's host reservation, not only above the base (fail-open: base-derived).
309
+ if (reserve) {
310
+ for (const dir of [...new Set(collisions.map((c) => c.dir))].sort()) {
311
+ const cs = collisions.filter((c) => c.dir === dir), width = cs[0].next.length;
312
+ let got = null;
313
+ try { got = await reserve({ dir, floor: Number(cs[0].next) - 1, count: cs.length }); } catch { /* fail-open */ }
314
+ const nums = Array.isArray(got?.numbers) ? got.numbers.map(Number) : [];
315
+ if (nums.length === cs.length && nums.every(Number.isInteger)) cs.forEach((c, i) => { c.next = String(nums[i]).padStart(width, "0"); });
316
+ }
317
+ }
289
318
  const moves = collisions.map((c) => ({ from: c.lane, to: posix.join(c.dir, c.next + basename(c.lane).slice(c.number.length)), dir: c.dir, oldNum: c.number, newNum: c.next }));
290
319
  // Plan the journals and snapshots before touching anything, so an unsupported migration leaves the branch as it was.
291
320
  const baseList = lines(await g(["-c", "core.quotepath=off", "ls-tree", "-r", "--name-only", base]));
@@ -296,12 +325,14 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
296
325
  if (!jp) continue;
297
326
  const meta = dirname(jp), mine = moves.filter((m) => m.dir === dir && /\.sql$/i.test(m.from));
298
327
  const snaps = [];
328
+ let chained = false; // #4526: after a schema-changing move, a data-only one's predecessor is not the base tip
299
329
  for (const m of mine) {
300
330
  const laneSnap = await json(g, `${lane}:${posix.join(meta, `${m.oldNum}_snapshot.json`)}`);
301
331
  if (!laneSnap) continue;
302
332
  const laneMeta = lines(await g(["-c", "core.quotepath=off", "ls-tree", "--name-only", lane, `${meta}/`])).filter((p) => SNAPSHOT_RE.test(p) && Number(numOf(p)) < Number(m.oldNum));
303
333
  const pred = laneMeta.sort((a, b) => Number(numOf(a)) - Number(numOf(b))).at(-1);
304
- const dataOnly = Boolean(pred) && strip(await json(g, `${lane}:${pred}`)) === strip(laneSnap);
334
+ const dataOnly = !chained && Boolean(pred) && strip(await json(g, `${lane}:${pred}`)) === strip(laneSnap);
335
+ if (!dataOnly) chained = true;
305
336
  if (!dataOnly && !regenerate) return { unsupported: `schema-changing migration ${m.from} needs lean.sequence.regenerate (its snapshot cannot be cloned)` };
306
337
  if (!dataOnly) needRegen = true;
307
338
  snaps.push({ m, dataOnly });
@@ -339,7 +370,8 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
339
370
  const tags = new Set(baseEntries.map((e) => e?.tag));
340
371
  let idx = Math.max(-1, ...baseEntries.map((e) => Number(e?.idx)).filter(Number.isFinite));
341
372
  const add = (Array.isArray(lj.entries) ? lj.entries : []).filter((e) => !tags.has(e?.tag)).map((e) => ({ ...e, idx: ++idx, tag: stem.get(e.tag) ?? e.tag }));
342
- writeJson(join(cwd, p.jp), { ...bj, entries: [...baseEntries, ...add] });
373
+ p.journal = { ...bj, entries: [...baseEntries, ...add] };
374
+ writeJson(join(cwd, p.jp), p.journal);
343
375
  touched.add(p.jp);
344
376
  const baseSnaps = baseList.filter((x) => dirname(x) === p.meta && SNAPSHOT_RE.test(x)).sort((a, b) => Number(numOf(a)) - Number(numOf(b)));
345
377
  let tip = baseSnaps.length ? await json(g, `${base}:${baseSnaps.at(-1)}`) : null;
@@ -360,11 +392,27 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
360
392
  }
361
393
  if (needRegen) {
362
394
  try { await (run ?? defaultRun)(regenerate, { cwd }); } catch (e) { return await fail(`lean.sequence.regenerate failed: ${String(e?.stderr || e?.message || e).trim().split("\n")[0]}`); }
395
+ const exists = (p) => { try { readFileSync(join(cwd, p)); return true; } catch { return false; } };
363
396
  for (const p of plans) {
364
397
  touched.add(p.meta);
365
- for (const { m, dataOnly } of p.snaps) {
398
+ // #4526: a hand-written migration (no schema diff) gets nothing from the regular run: the generator's --custom
399
+ // writes an empty migration and its snapshot at that number (its SQL and later ones withheld), then the lane's
400
+ // SQL and the planned journal come back.
401
+ for (const { m, dataOnly } of [...p.snaps].sort((a, b) => Number(a.m.newNum) - Number(b.m.newNum))) {
366
402
  const want = posix.join(p.meta, `${m.newNum}_snapshot.json`);
367
- if (!dataOnly) { try { readFileSync(join(cwd, want)); } catch { return await fail(`lean.sequence.regenerate did not write ${want}`); } }
403
+ if (dataOnly || exists(want)) continue;
404
+ if (!custom) return await fail(`lean.sequence.regenerate did not write ${want}`);
405
+ const held = moves.filter((w) => w.dir === p.dir && /\.sql$/i.test(w.to) && Number(w.newNum) >= Number(m.newNum));
406
+ const heldTags = new Set(held.map((w) => stemOf(w.to)));
407
+ for (const w of held) rmSync(join(cwd, w.to), { force: true });
408
+ writeJson(join(cwd, p.jp), { ...p.journal, entries: p.journal.entries.filter((e) => !heldTags.has(e?.tag)) });
409
+ try { await (run ?? defaultRun)(custom, { cwd }); } catch (e) { return await fail(`lean.sequence.regenerate --custom failed: ${String(e?.stderr || e?.message || e).trim().split("\n")[0]}`); }
410
+ for (const f of lines(await g(["-c", "core.quotepath=off", "ls-files", "--others", "--exclude-standard", "--", `${p.dir}/`]))) {
411
+ if (dirname(f) === p.dir && /\.sql$/i.test(f) && numOf(f) === m.newNum && f !== m.to) rmSync(join(cwd, f), { force: true });
412
+ }
413
+ for (const w of held) writeFileSync(join(cwd, w.to), await g(["cat-file", "blob", `${lane}:${w.from}`]));
414
+ writeJson(join(cwd, p.jp), p.journal);
415
+ if (!exists(want)) return await fail(`lean.sequence.regenerate did not write ${want}`);
368
416
  }
369
417
  }
370
418
  }