forgedock 1.31.0 → 1.32.1

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.
@@ -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,28 @@ 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(); }
239
+ });
240
+
241
+ it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
242
+ const h = host();
243
+ try {
244
+ const statusIds = async () => (await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" })).batches.map((x) => x.id);
245
+ const kill = async (d) => { const gone = new Promise((r) => d.on("exit", r)); d.kill("SIGKILL"); await gone; };
246
+ let d = await h.daemon();
247
+ await withRoot(h, () => capture(async () => {
248
+ const a = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
249
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
250
+ const [idA] = listBatches(h.root);
251
+ assert.equal((await orchestrateFromCli(["--cancel", idA], h.client)).cancelled, true);
252
+ await a;
253
+ await kill(d);
254
+ d = await h.daemon();
255
+ await new Promise((r) => setTimeout(r, 300));
256
+ assert.deepEqual(await statusIds(), [], "the restarted daemon hosts no cancelled batch");
257
+ assert.deepEqual(h.launched(), [1]);
258
+ }));
259
+ } finally { await h.cleanup(); }
215
260
  });
216
261
 
217
262
  it("single instance: a live daemon keeps its lock; a dead owner's lock and socket are taken over", async () => {
@@ -231,7 +276,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
231
276
  assert.equal(st.pid, process.pid);
232
277
  } finally { await third.close(); }
233
278
  assert.equal(existsSync(scheddPaths(h.sched).lock), false, "lock released on close");
234
- } finally { h.cleanup(); }
279
+ } finally { await h.cleanup(); }
235
280
  });
236
281
 
237
282
  it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
@@ -288,11 +333,12 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
288
333
  const h = host();
289
334
  try {
290
335
  const d1 = await h.daemon();
291
- await withRoot(h, () => capture(async () => {
292
- 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);
293
339
  await until(() => h.launched().length === 1 && h.lanePids().length === 1);
294
340
  const [idA] = listBatches(h.root);
295
- 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);
296
342
  await until(() => h.launched().includes(5) && h.lanePids().length === 2);
297
343
  const idB = listBatches(h.root).find((x) => x !== idA);
298
344
  const lanes = h.lanePids();
@@ -306,15 +352,102 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
306
352
  assert.equal(openBatch(h.root, idA).readReport(), null, "a stopped batch writes no report");
307
353
  assert.equal(openBatch(h.root, idB).readReport(), null);
308
354
  assert.match(String((await a)?.message), /--resume/);
309
- await b;
310
355
  await h.daemon(); // resumes both batches
311
- const ra = orchestrateFromCli(["--resume", idA], h.client), rb = orchestrateFromCli(["--resume", idB], h.client);
356
+ const ra = orchestrateFromCli(["--resume", idA], h.client);
312
357
  setTimeout(() => writeFileSync(h.release, ""), 200);
313
358
  assert.deepEqual((await ra).rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
314
- 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");
315
360
  assert.deepEqual(h.launched(), [1, 2, 5], "adopted, not relaunched");
316
361
  }));
317
- } 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`);
318
451
  });
319
452
 
320
453
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
@@ -59,14 +59,18 @@ function parseLog(text) {
59
59
  return out;
60
60
  }
61
61
 
62
- /** Every RUN_START..RUN_TERMINAL slice of one run log (all of them, not only the last). */
62
+ export const LANE_SHAPES = Object.freeze(["light", "upgraded", "full"]);
63
+
64
+ /** Every RUN_START..RUN_TERMINAL slice of one run log (all of them, not only the last). `shape` (#4452): light (LIGHT_DONE), upgraded (LIGHT_UPGRADE) or full. */
63
65
  export function issueSlices(events) {
64
66
  const out = [];
65
67
  let cur = null;
66
68
  for (const e of Array.isArray(events) ? events : []) {
67
69
  if (!e || typeof e !== "object") continue;
68
- if (e.event === "RUN_START") { cur = { startTs: e.ts ?? null, repo: e.repo ?? null, terminal: null, attempts: [] }; out.push(cur); continue; }
70
+ if (e.event === "RUN_START") { cur = { startTs: e.ts ?? null, repo: e.repo ?? null, terminal: null, shape: "full", attempts: [] }; out.push(cur); continue; }
69
71
  if (!cur) continue;
72
+ if (e.event === "LIGHT_DONE") cur.shape = "light";
73
+ else if (e.event === "LIGHT_UPGRADE") cur.shape = "upgraded";
70
74
  if (e.event === "PHASE_ATTEMPT_USAGE") {
71
75
  cur.attempts.push({ phase: e.phase ?? "other", trigger: e.trigger ?? null, restart_of: e.restart_of ?? null, model: e.model ?? null,
72
76
  turns: num(e.turns), cacheRead: num(e.usage?.cache_read_input_tokens), cost: costOf(e),
@@ -145,9 +149,18 @@ const group = (xs) => {
145
149
  */
146
150
  export function efficiencyMetrics({ entries = [], logs = new Map(), issues = null, repo = null, now = 0, notes = [] } = {}) {
147
151
  const merged = [];
152
+ const inScope = (row, settleAt) => issueSlices(logs.get(row.issue) ?? []).filter((s) => (!repo || !s.repo || s.repo === repo) && (!settleAt || !s.startTs || s.startTs <= settleAt));
153
+ // #4452: light/full split and upgrade rate over every lane of every row (all outcomes); cost is the #4407 delta sum
154
+ const shapes = Object.fromEntries(LANE_SHAPES.map((k) => [k, { n: 0, cost_usd: 0 }]));
155
+ for (const { row, settleAt } of entries) {
156
+ if (!Number.isSafeInteger(row?.issue)) continue;
157
+ for (const s of inScope(row, settleAt)) { const g = shapes[s.shape] ?? shapes.full; g.n++; g.cost_usd += sum(s.attempts.map((a) => a.cost)); }
158
+ }
159
+ for (const g of Object.values(shapes)) g.cost_usd = r(g.cost_usd, 1e6);
160
+ const lanes = { ...shapes, upgrade_rate: r(per(shapes.upgraded.n, shapes.light.n + shapes.upgraded.n)) };
148
161
  for (const { row, settleAt } of entries) {
149
162
  if (row?.outcome !== "merged") continue;
150
- const slices = issueSlices(logs.get(row.issue) ?? []).filter((s) => (!repo || !s.repo || s.repo === repo) && (!settleAt || !s.startTs || s.startTs <= settleAt));
163
+ const slices = inScope(row, settleAt);
151
164
  const atts = slices.flatMap((s) => s.attempts), impl = atts.filter((a) => a.phase === "implement");
152
165
  const retries = Object.fromEntries(RETRY_CAUSES.map((c) => [c, 0]));
153
166
  for (const s of slices) for (const [c, k] of Object.entries(sliceRetries(s))) retries[c] += k;
@@ -156,7 +169,7 @@ export function efficiencyMetrics({ entries = [], logs = new Map(), issues = nul
156
169
  const term = [...slices].reverse().find((s) => s.terminal?.reason === "merged")?.terminal;
157
170
  merged.push({ issue: row.issue, slices: slices.length, cost: sum(atts.map((a) => a.cost)), implCost: sum(impl.map((a) => a.cost)),
158
171
  reviewCost: sum(atts.filter((a) => a.phase === "review").map((a) => a.cost)), implTurns: sum(impl.map((a) => a.turns)), implCache: sum(impl.map((a) => a.cacheRead)),
159
- size: term?.size ?? laneSize({ labels: info?.labels ?? [] }) ?? "unknown", impl, retries, info, tools: laneTools(impl) });
172
+ size: term?.size ?? laneSize({ labels: info?.labels ?? [] }) ?? "unknown", impl, retries, info, tools: laneTools(impl), shape: slices.at(-1)?.shape ?? null });
160
173
  }
161
174
  const all = group(merged);
162
175
  const bySize = {};
@@ -202,10 +215,10 @@ export function efficiencyMetrics({ entries = [], logs = new Map(), issues = nul
202
215
  retries: { total, per_merge: r(per(total, merged.length), 100), by_cause: retries, lane_reruns: sum(merged.map((m) => Math.max(0, m.slices - 1))) },
203
216
  no_action: { lanes: sum(Object.values(noAction).map((g) => g.n)), cost_usd: r(sum(Object.values(noAction).map((g) => g.cost_usd)), 1e6),
204
217
  by_kind: Object.fromEntries(Object.entries(noAction).map(([k, g]) => [k, { ...g, cost_usd: r(g.cost_usd, 1e6) }])) },
205
- quality, acceptance,
218
+ quality, acceptance, lanes,
206
219
  tool_calls_per_turn_p50: median(withTools.map((m) => m.tools.per_turn)), rereads_per_merge: withTools.length ? r(per(sum(withTools.map((m) => m.tools.rereads)), withTools.length), 100) : null,
207
220
  per_issue: merged.map((m) => ({ issue: m.issue, slices: m.slices, cost_usd: r(m.cost, 1e6), implement_usd: r(m.implCost, 1e6), implement_turns: m.implTurns,
208
- implement_cache_read: m.implCache, size: m.size, retries: m.retries, ...(m.info ? { quality: issueQuality(m.info.body).tag } : {}),
221
+ implement_cache_read: m.implCache, size: m.size, retries: m.retries, ...(m.shape ? { shape: m.shape } : {}), ...(m.info ? { quality: issueQuality(m.info.body).tag } : {}),
209
222
  ...(m.tools ? { implement_tool_calls_per_turn: m.tools.per_turn, implement_rereads: m.tools.rereads } : {}) })),
210
223
  notes,
211
224
  };
@@ -315,5 +328,8 @@ export function formatEfficiencyLines(m, label = "") {
315
328
  lines.push(a ? `acceptance (7d after promotion): pass ${a.pass}/${a.pass + a.fail}${a.pass_rate == null ? "" : ` (${Math.round(a.pass_rate * 100)}%)`}, pending ${a.pending}, unprobed ${a.unprobed}`
316
329
  : "acceptance (7d after promotion): n/a");
317
330
  lines.push(`tools: calls/turn p50 ${f(m.tool_calls_per_turn_p50)}, re-reads (>=3x same path)/merge ${f(m.rereads_per_merge)}`); // #4410
331
+ const ln = m.lanes; // #4452
332
+ lines.push(ln ? `lanes: ${LANE_SHAPES.map((k) => `${k} ${ln[k]?.n ?? 0} (${usd(ln[k]?.cost_usd ?? 0)})`).join(", ")}; upgrade rate ${ln.upgrade_rate == null ? "n/a"
333
+ : `${Math.round(ln.upgrade_rate * 100)}% (${ln.upgraded?.n ?? 0}/${(ln.light?.n ?? 0) + (ln.upgraded?.n ?? 0)})`}` : "lanes: n/a");
318
334
  return lines;
319
335
  }
@@ -115,11 +115,44 @@ describe("#4402 efficiencyMetrics", () => {
115
115
  });
116
116
  it("renders n/a, never NaN, for an empty cohort", () => {
117
117
  const lines = formatEfficiencyLines(efficiencyMetrics({}));
118
- assert.equal(lines.length, 8);
118
+ assert.equal(lines.length, 9);
119
119
  assert.ok(!lines.some((l) => /NaN|undefined/.test(l)), lines.join("\n"));
120
120
  assert.match(lines[0], /^efficiency: merged 0, n\/a\/merge/);
121
121
  assert.equal(lines[6], "acceptance (7d after promotion): n/a");
122
122
  assert.equal(lines[7], "tools: calls/turn p50 n/a, re-reads (>=3x same path)/merge n/a");
123
+ assert.equal(lines[8], "lanes: light 0 ($0.00), upgraded 0 ($0.00), full 0 ($0.00); upgrade rate n/a");
124
+ });
125
+ });
126
+
127
+ describe("#4452 light/full split and upgrade rate", () => {
128
+ // issue 1: light, merged; 2: light then upgraded in the same run, merged; 3: full, merged; 4: light, failed; 5: upgraded, failed
129
+ const logs = new Map([
130
+ [1, [start("2026-10-22T10:00:00Z"), { event: "LIGHT_ELIGIBILITY", light: true }, att("implement", { trigger: "build", cost_usd: 0.9, cost_usd_delta: 0.4 }), { event: "LIGHT_DONE" }, att("review", { cost_usd: 0.05 }), term("merged", "merged")]],
131
+ [2, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.3 }), { event: "LIGHT_UPGRADE", reasons: [{ code: "diff_lines" }] }, att("implement", { trigger: "plan", cost_usd_delta: 0.5 }), term("merged", "merged")]],
132
+ [3, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "plan", cost_usd_delta: 0.6 }), att("implement", { trigger: "build", cost_usd: 1.2, cost_usd_delta: 0.4 }), term("merged", "merged")]],
133
+ [4, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.2 }), { event: "LIGHT_DONE" }, term("failed", "ci.red")]],
134
+ [5, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.1 }), { event: "LIGHT_UPGRADE" }, term("failed", "conformance.unimplemented")]],
135
+ ]);
136
+ const entries = [1, 2, 3, 4, 5].map((issue) => ({ row: { issue, outcome: issue <= 3 ? "merged" : "failed" }, settleAt: null }));
137
+ const m = efficiencyMetrics({ entries, logs, repo: "o/r" });
138
+
139
+ it("tags each lane slice by shape from LIGHT_DONE / LIGHT_UPGRADE", () => {
140
+ assert.deepEqual(issueSlices(logs.get(1)).map((s) => s.shape), ["light"]);
141
+ assert.deepEqual(issueSlices(logs.get(2)).map((s) => s.shape), ["upgraded"]);
142
+ assert.deepEqual(issueSlices(logs.get(3)).map((s) => s.shape), ["full"]);
143
+ });
144
+ it("splits every lane (all outcomes) by shape with true #4407 delta costs and computes the upgrade rate", () => {
145
+ assert.deepEqual(m.lanes, {
146
+ light: { n: 2, cost_usd: 0.65 }, // 0.4 delta (not the 0.9 running total) + 0.05 review + 0.2
147
+ upgraded: { n: 2, cost_usd: 0.9 },
148
+ full: { n: 1, cost_usd: 1 }, // 0.6 + 0.4 delta, not 1.2
149
+ upgrade_rate: 0.5,
150
+ });
151
+ assert.deepEqual(m.per_issue.map((x) => [x.issue, x.shape]), [[1, "light"], [2, "upgraded"], [3, "full"]]);
152
+ });
153
+ it("the batch report prints the split and the upgrade rate", () => {
154
+ const lines = formatEfficiencyLines(m);
155
+ assert.equal(lines.at(-1), "lanes: light 2 ($0.65), upgraded 2 ($0.90), full 1 ($1.00); upgrade rate 50% (2/4)");
123
156
  });
124
157
  });
125
158
 
@@ -142,7 +175,7 @@ describe("#4410 tool calls per turn and re-reads", () => {
142
175
  const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
143
176
  assert.equal(p[1].implement_tool_calls_per_turn, 1.5); assert.equal(p[1].implement_rereads, 2);
144
177
  assert.equal("implement_tool_calls_per_turn" in p[4], false);
145
- assert.equal(formatEfficiencyLines(m).at(-1), "tools: calls/turn p50 1.50, re-reads (>=3x same path)/merge 1.00");
178
+ assert.equal(formatEfficiencyLines(m).find((l) => l.startsWith("tools:")), "tools: calls/turn p50 1.50, re-reads (>=3x same path)/merge 1.00");
146
179
  });
147
180
  it("null without tool data (older logs)", () => {
148
181
  const e = efficiencyMetrics({ entries: [entries[3]], logs, repo: "o/r", now: NOW });