forgedock 1.42.0 → 1.43.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.
Files changed (51) hide show
  1. package/bin/engine/outcome-codes.mjs +1 -0
  2. package/bin/engine-cli.mjs +91 -19
  3. package/bin/journey.mjs +2 -2
  4. package/bin/lean/autochildren.mjs +103 -0
  5. package/bin/lean/autochildren.test.mjs +126 -0
  6. package/bin/lean/batch.mjs +11 -3
  7. package/bin/lean/batch.test.mjs +66 -2
  8. package/bin/lean/conformance.mjs +140 -9
  9. package/bin/lean/conformance.test.mjs +164 -2
  10. package/bin/lean/effects.mjs +1 -1
  11. package/bin/lean/execution.test.mjs +9 -6
  12. package/bin/lean/fixround.test.mjs +9 -1
  13. package/bin/lean/guardgate.mjs +66 -7
  14. package/bin/lean/guardgate.test.mjs +109 -3
  15. package/bin/lean/heavy.test.mjs +11 -1
  16. package/bin/lean/issuewatch.mjs +2 -2
  17. package/bin/lean/lean.mjs +80 -24
  18. package/bin/lean/lean.test.mjs +119 -7
  19. package/bin/lean/orchestrate-blocked.test.mjs +16 -0
  20. package/bin/lean/orchestrate-children.test.mjs +83 -0
  21. package/bin/lean/orchestrate.mjs +51 -12
  22. package/bin/lean/postmerge.mjs +5 -2
  23. package/bin/lean/postmerge.test.mjs +7 -0
  24. package/bin/lean/preflight.mjs +21 -0
  25. package/bin/lean/preflight.test.mjs +15 -1
  26. package/bin/lean/probe.mjs +1 -1
  27. package/bin/lean/prompts.mjs +19 -4
  28. package/bin/lean/schedd.mjs +116 -19
  29. package/bin/lean/schedd.test.mjs +201 -2
  30. package/bin/lean/selftest-live.mjs +1 -1
  31. package/bin/lean/selftest-nightly.mjs +1 -1
  32. package/bin/lean/signoff.mjs +11 -1
  33. package/bin/lean/signoff.test.mjs +24 -1
  34. package/bin/lean/toolstate.mjs +19 -3
  35. package/bin/lean/toolstate.test.mjs +22 -0
  36. package/bin/lean/verify.mjs +9 -1
  37. package/bin/lean/verify.test.mjs +4 -0
  38. package/bin/pool/agent.mjs +1 -1
  39. package/bin/runner.mjs +1 -1
  40. package/commands/orchestrate.md +2 -2
  41. package/docs/CONFIG.md +8 -2
  42. package/docs/OUTCOME-CODES.md +1 -0
  43. package/docs/RELEASE-NOTES.md +3 -0
  44. package/package.json +1 -1
  45. package/scripts/ci-local/run.mjs +1 -1
  46. package/scripts/file-deferred-followups.sh +1 -0
  47. package/scripts/file-deferred-followups.test.sh +7 -0
  48. package/scripts/review-pr-staging-scope.test.sh +3 -3
  49. package/scripts/review-prepare.sh +3 -0
  50. package/scripts/review-staging-pre.sh +88 -43
  51. package/scripts/review-staging-pre.test.sh +42 -1
@@ -20,7 +20,7 @@
20
20
  * {type:"ok", cancelled:false, finished:true}, nothing written
21
21
  * {op:"hold"|"release", id, issues} → {type:"ok", changed, hosted} (#4592): merges into / removes from the batch's `hold` file; a hosted
22
22
  * runner admits no held issue from its next pass (running lanes are not stopped)
23
- * {op:"status"} → {type:"status", pid, lastTick, batches:[{id, running}]} (#4780: `lastTick` = epoch ms of the last heartbeat; see schedd-health.mjs)
23
+ * {op:"status"} → {type:"status", pid, lastTick, batches:[{id, running, lastProgressAt, lastProgressAgeSec, pendingAdds}]} (#4775: also an unhosted batch with pending adds)
24
24
  * Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
25
25
  * never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
26
26
  * report, so live lanes are re-adopted through the batch runner's `adopt`. It exits after `idleMs` with no batch and no client.
@@ -34,8 +34,8 @@
34
34
  */
35
35
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
36
36
  import { createConnection, createServer } from "node:net";
37
- import { spawn } from "node:child_process";
38
- import { tmpdir } from "node:os";
37
+ import { execFileSync, spawn } from "node:child_process";
38
+ import { homedir, tmpdir } from "node:os";
39
39
  import { createHash } from "node:crypto";
40
40
  import { join, resolve } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
@@ -46,6 +46,10 @@ import { batchRoot, listBatches, openBatch, SECRET_ENV } from "./batch.mjs";
46
46
  export const IDLE_MS = 10 * 60 * 1000;
47
47
  export const STOP_MS = 10 * 1000;
48
48
  export const LOCK_CHECK_MS = 5 * 1000;
49
+ export const ADMIT_MS = 120 * 1000; // #4775: how long one add/requeue/attach/resume waits for its batch to start before answering an error
50
+ export const LAG_MS = 5 * 1000; // #4775: an event-loop stall longer than this is logged
51
+ export const REQUEST_MS = 15 * 1000; // #4775: default bound of a control/status request
52
+ export const UNIT = "forgedock-schedd";
49
53
  export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
50
54
 
51
55
  /** #4762: win32 `net` servers only accept named pipes, so the socket is a per-dir pipe there (it vanishes with its server). */
@@ -130,7 +134,7 @@ function takeLock(lock, pid, alive) {
130
134
  */
131
135
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
132
136
  root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
133
- lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
137
+ lockCheckMs = LOCK_CHECK_MS, admitMs = Number(process.env.FORGEDOCK_SCHEDD_ADMIT_MS) || ADMIT_MS, lagMs = LAG_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS, preflight = null } = {}) {
134
138
  const p = scheddPaths(dir);
135
139
  mkdirSync(dir, { recursive: true });
136
140
  const lock = takeLock(p.lock, pid, alive);
@@ -168,6 +172,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
168
172
  const run = Promise.resolve().then(() => runBatch({ argv, cwd, env, sink, keepBreaker, signal: ac.signal })).then((result) => {
169
173
  const report = ent.dir ? openBatch(root, ent.id).readReport()?.report ?? null : null;
170
174
  emit({ type: "done", result, report });
175
+ if (ent.id) rehostLater(ent.id); // #4917
171
176
  }, (e) => {
172
177
  const message = e?.message ?? String(e);
173
178
  if (e?.code === "batch.stopped") { log(`schedd: batch ${ent.id ?? argv.join(" ")} stopped (lanes keep running; resumed by the next daemon)`); if (!started) reject(new Error(message)); return; }
@@ -176,8 +181,61 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
176
181
  runs.set(run, ac);
177
182
  });
178
183
 
184
+ // #4917: a batch that ended with a deferred issue still waiting (no report) is re-hosted once its retry-at is near, without a restart
185
+ const rehostTimers = new Set();
186
+ const rehostLater = (id) => {
187
+ if (closing) return;
188
+ let b, st;
189
+ try { b = openBatch(root, id); if (b.readReport() || b.readCancel() || !b.pendingIssues().length) return; st = b.store.load(); } catch { return; }
190
+ if (st?.breaker && !st.breaker.transport) return; // a tripped breaker holds the batch for an operator --resume (a re-host would stop again at once)
191
+ const at = Math.min(...b.pendingIssues().map((n) => Number(st?.notBefore?.[n]) || Infinity));
192
+ if (!Number.isFinite(at)) return; // nothing waits on the clock: a deliberate hold stays for --resume
193
+ if (at <= Date.now()) return; // the run that just ended already saw this retry-at pass and did not admit: re-hosting would loop every second
194
+ const t = setTimeout(() => {
195
+ rehostTimers.delete(t);
196
+ if (closing || hosted.has(id) || starting.has(id)) return;
197
+ log(`schedd: re-hosting batch ${id} (pending issues waiting on a retry)`);
198
+ const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
199
+ starting.set(id, started);
200
+ started.catch((e) => log(`schedd: re-host ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
201
+ }, Math.min(60_000, Math.max(1000, at - Date.now())));
202
+ t.unref?.();
203
+ rehostTimers.add(t);
204
+ };
205
+
206
+ /** #4775: wait for a batch start at most `admitMs`; the start itself goes on (a second host() would duplicate the batch). */
207
+ const bound = (p, what) => {
208
+ let t;
209
+ return Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`${what} did not start within ${admitMs} ms (schedd is busy; retry)`)), admitMs); t.unref?.(); })]).finally(() => clearTimeout(t));
210
+ };
211
+ const hostBounded = (args, first = null, what = "batch") => bound(host(args, first), what);
212
+
213
+ /** #4775: per batch id, last progress and pending adds: every hosted batch, plus any daemon batch holding adds it never took. */
214
+ const statusBatches = () => {
215
+ const now = Date.now(), out = [];
216
+ const ids = new Set(hosted.keys());
217
+ for (const id of starting.keys()) ids.add(id);
218
+ try { for (const id of listBatches(root)) if (!ids.has(id) && openBatch(root, id).readPlan()?.daemon) ids.add(id); } catch { /* unreadable root */ }
219
+ for (const id of [...ids].sort()) {
220
+ const isHosted = hosted.has(id) || starting.has(id);
221
+ let last = null, pending = [];
222
+ try { const b = openBatch(root, id); last = b.lastProgressMs(); pending = b.unrunAdds(); } catch { /* a half-written batch */ }
223
+ if (!isHosted && !pending.length) continue;
224
+ out.push({ id, running: isHosted, lastProgressAt: last === null ? null : new Date(last).toISOString(), lastProgressAgeSec: last === null ? null : Math.max(0, Math.round((now - last) / 1000)), pendingAdds: pending });
225
+ }
226
+ return out;
227
+ };
228
+
179
229
  const handle = async (req, send) => {
180
- if (req.op === "status") return send({ type: "status", pid, lastTick, batches: [...hosted.keys()].map((id) => ({ id, running: true })) }, true);
230
+ if (req.op === "status") {
231
+ // #4917: a batch dir that still holds pending issues but is not hosted (finalised early, or never resumed)
232
+ const unhosted = [];
233
+ for (const id of listBatches(root)) {
234
+ if (hosted.has(id) || starting.has(id)) continue;
235
+ try { const pending = openBatch(root, id).pendingIssues(); if (pending.length) unhosted.push({ id, pending }); } catch { /* advisory */ }
236
+ }
237
+ return send({ type: "status", pid, lastTick, batches: statusBatches(), unhosted }, true);
238
+ }
181
239
  if (req.op === "set-parallel") {
182
240
  const b = openBatch(root, req.id);
183
241
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
@@ -188,9 +246,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
188
246
  const b = openBatch(root, req.id);
189
247
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
190
248
  const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
191
- // #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
192
- const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
193
- if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
249
+ // #4687/#5000/#4917: a re-queued skipped issue or a newly added one reopens a finished, unhosted batch like --requeue does
250
+ const reopen = (added.length > 0 || requeued.length > 0 || b.unrunAdds().length > 0 || b.unrunRequeues().length > 0) && !hosted.has(req.id) && !starting.has(req.id);
251
+ if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
194
252
  return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
195
253
  }
196
254
  if (req.op === "requeue") {
@@ -198,8 +256,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
198
256
  const b = openBatch(root, req.id);
199
257
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
200
258
  const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
201
- const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
202
- if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
259
+ // #4917: a refused repeat requeue still reopens when an earlier entry was never applied
260
+ const reopen = (requeued.length > 0 || b.unrunRequeues().length > 0) && !hosted.has(req.id) && !starting.has(req.id);
261
+ if (reopen) await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, null, `batch ${req.id}`);
203
262
  return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
204
263
  }
205
264
  if (req.op === "hold" || req.op === "release") {
@@ -222,7 +281,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
222
281
  }
223
282
  if (req.op === "attach") {
224
283
  if (req.resume) { const rb = openBatch(root, req.id); if (rb.exists()) rb.clearCancel(); } // #4592: the operator's resume continues a cancelled batch the daemon still hosts
225
- if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
284
+ if (!hosted.has(req.id) && starting.has(req.id)) await bound(starting.get(req.id), `batch ${req.id}`).catch((e) => { if (/did not start within/.test(e?.message)) throw e; });
226
285
  const live = hosted.get(req.id);
227
286
  if (live) {
228
287
  // Synchronous read + subscribe: no row is lost or doubled between the replay and the live stream.
@@ -236,7 +295,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
236
295
  if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
237
296
  return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
238
297
  }
239
- const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
298
+ const ent = await hostBounded({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub, `batch ${req.id}`);
240
299
  return send({ type: "batch", id: ent.id, dir: ent.dir });
241
300
  }
242
301
  throw new Error(`unknown op ${JSON.stringify(req.op)}`);
@@ -277,7 +336,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
277
336
  if (closing) return closing;
278
337
  clearInterval(idleTimer);
279
338
  clearInterval(lockTimer);
339
+ clearInterval(lagTimer);
280
340
  clearInterval(beatTimer);
341
+ for (const t of rehostTimers) clearTimeout(t);
281
342
  if (dueTimer) clearInterval(dueTimer);
282
343
  for (const c of conns) c.destroy();
283
344
  const stopping = [...runs.keys()];
@@ -309,6 +370,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
309
370
  else if (process.platform !== "win32" && !existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
310
371
  }, lockCheckMs);
311
372
  lockTimer.unref?.();
373
+ // #4775: an event loop starved by host load leaves sockets unanswered and children unreaped; leave a trace of it.
374
+ let lagAt = Date.now(), lagLogged = 0;
375
+ const lagTimer = setInterval(() => {
376
+ const now = Date.now(), lag = now - lagAt - 1000;
377
+ lagAt = now;
378
+ if (lag >= lagMs && now - lagLogged >= 60_000) { lagLogged = now; log(`schedd: event loop stalled ${Math.round(lag / 1000)}s (host overloaded?)`); }
379
+ }, 1000);
380
+ lagTimer.unref?.();
312
381
  // #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
313
382
  let dueRunning = false;
314
383
  const dueTimer = due ? setInterval(async () => {
@@ -333,6 +402,9 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
333
402
  log(`schedd: resuming batch ${id}`);
334
403
  const started = host({ argv: ["--resume", id], cwd: process.cwd(), env: {}, keepBreaker: true });
335
404
  starting.set(id, started);
405
+ const stall = setTimeout(() => log(`schedd: batch ${id} has not started after ${admitMs} ms (still resuming; other batches are served)`), admitMs);
406
+ stall.unref?.();
407
+ started.finally(() => clearTimeout(stall)).catch(() => {});
336
408
  started.catch((e) => log(`schedd: resume ${id} failed: ${e?.message ?? e}`)).finally(() => starting.delete(id));
337
409
  }
338
410
  }
@@ -362,8 +434,29 @@ export function exitOnSignals(d, { on = (s, f) => (s.startsWith("SIG") || s ===
362
434
  on("exit", (code) => log(`schedd: exited (code ${code})`));
363
435
  }
364
436
 
365
- /** Connect to the daemon, spawning it (`spawnDaemon()`) when nobody listens. @returns {Promise<import("node:net").Socket>} */
366
- export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
437
+ /**
438
+ * #4775: state of the systemd-user unit (`active`, `activating`, `inactive`, ...), or null when no unit is installed or
439
+ * systemd is not usable here (then a client may spawn the daemon itself).
440
+ */
441
+ export function unitState({ unitFile = join(homedir(), ".config", "systemd", "user", `${UNIT}.service`), exists = existsSync, run = execFileSync } = {}) {
442
+ if (!exists(unitFile)) return null;
443
+ try { return String(run("systemctl", ["--user", "is-active", UNIT], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })).trim() || null; }
444
+ catch (e) { return e?.code === "ENOENT" || e?.killed ? null : String(e?.stdout ?? "").trim() || null; } // is-active exits 3 and prints the state when not active
445
+ }
446
+
447
+ /** Start the installed unit (best effort: the connect retry reports a failure). */
448
+ export function startUnit({ run = execFileSync } = {}) {
449
+ try { run("systemctl", ["--user", "start", "--no-block", UNIT], { timeout: 5000, stdio: "ignore" }); } catch { /* reported by the connect retry */ }
450
+ }
451
+
452
+ /**
453
+ * Connect to the daemon. When nobody listens it is started: a systemd-managed instance (`unitState()` active, activating
454
+ * or reloading: restarting) is waited for and never raced by a second daemon, an installed but stopped unit is started
455
+ * (`startUnit()`), and only without a unit does `spawnDaemon()` run (#4775). `spawnDaemon: null` never starts anything.
456
+ * @returns {Promise<import("node:net").Socket>}
457
+ */
458
+ export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpawn, waitMs = 10_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
459
+ unit = unitState, start = startUnit } = {}) {
367
460
  const { sock } = scheddPaths(dir);
368
461
  const tryConnect = () => new Promise((res, rej) => {
369
462
  const c = createConnection(sock);
@@ -371,7 +464,9 @@ export async function connectDaemon({ dir = schedDir(), spawnDaemon = defaultSpa
371
464
  c.once("error", rej);
372
465
  });
373
466
  try { return await tryConnect(); } catch (e) { if (!["ENOENT", "ECONNREFUSED"].includes(e?.code) || !spawnDaemon) throw e; }
374
- spawnDaemon({ dir });
467
+ const state = unit();
468
+ if (state === null) spawnDaemon({ dir });
469
+ else if (!["active", "activating", "reloading"].includes(state)) start();
375
470
  const end = Date.now() + waitMs;
376
471
  for (;;) {
377
472
  await sleep(100);
@@ -386,7 +481,7 @@ export function defaultSpawn({ dir = schedDir(), script = SCHEDD_SCRIPT, env = p
386
481
  mkdirSync(dir, { recursive: true });
387
482
  const out = openSync(scheddPaths(dir).log, "a");
388
483
  try {
389
- const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, stdio: ["ignore", out, out] });
484
+ const child = spawn(process.execPath, [script, "schedd"], { env, detached: true, windowsHide: true, stdio: ["ignore", out, out] });
390
485
  child.on?.("error", () => { /* the connect retry reports it */ });
391
486
  child.unref?.();
392
487
  } finally { closeSync(out); }
@@ -396,10 +491,12 @@ export function defaultSpawn({ dir = schedDir(), script = SCHEDD_SCRIPT, env = p
396
491
  * Send one request and feed every reply to `onMsg`. Resolves with the last message (`done`, `ok`, `status`), rejects on
397
492
  * `error` or when the daemon closes the stream first.
398
493
  */
399
- export function request(sock, msg, onMsg = () => {}) {
494
+ export function request(sock, msg, onMsg = () => {}, { timeoutMs = 0 } = {}) {
400
495
  return new Promise((resolve, reject) => {
401
- let buf = "", last = null, settled = false;
402
- const finish = (fn, v) => { if (!settled) { settled = true; fn(v); } };
496
+ let buf = "", last = null, settled = false, timer = null;
497
+ const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v); } };
498
+ // #4775: a bound for one-shot requests; a stalled daemon answers an error instead of hanging the caller (streams pass none)
499
+ if (timeoutMs > 0) timer = setTimeout(() => { sock.destroy(); finish(reject, Object.assign(new Error(`schedd did not answer within ${Math.round(timeoutMs / 1000)}s (not responding)`), { code: "schedd.timeout" })); }, timeoutMs);
403
500
  sock.on("data", (d) => {
404
501
  buf += d;
405
502
  let i;
@@ -8,11 +8,11 @@ import { homedir, tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
10
  import { openBatch, isLaneAlive, listBatches, SECRET_ENV } from "./batch.mjs";
11
- import { startDaemon, scheddPaths, connectDaemon, request, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
11
+ import { startDaemon, scheddPaths, connectDaemon, request, unitState, SCHEDD_SCRIPT, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
12
12
  import { schedDir } from "./sched.mjs";
13
13
  import { readHeartbeat } from "./schedd-health.mjs";
14
14
  import { formatTable, orchestrate } from "./orchestrate.mjs";
15
- import { orchestrateFromCli } from "../engine-cli.mjs";
15
+ import { orchestrateFromCli, scheddFromCli } from "../engine-cli.mjs";
16
16
 
17
17
  // #4503: this process, and the env its clients submit for their lanes, never resolve the host's `~/.forge/sched`.
18
18
  process.env.FORGEDOCK_SCHED_DIR = tmpDir("schedd-home-");
@@ -249,6 +249,55 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
249
249
  } finally { await h.cleanup(); }
250
250
  });
251
251
 
252
+ it("#5000: add to a finished unhosted batch reopens it through the daemon and runs the new issue", async () => {
253
+ const h = host();
254
+ try {
255
+ await h.daemon();
256
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
257
+ await withRoot(h, () => capture(async () => {
258
+ writeFileSync(h.release, "");
259
+ await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
260
+ const [id] = listBatches(h.root);
261
+ const b = openBatch(h.root, id);
262
+ const res = await orchestrateFromCli(["--add", id, "6"], client);
263
+ assert.deepEqual(res.added, [6]);
264
+ assert.equal(res.reopened, true, "the finished batch is hosted again");
265
+ await until(() => h.launched().includes(6));
266
+ await until(() => b.store.load()?.results?.[6]?.outcome === "merged");
267
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
268
+ }));
269
+ } finally { await h.cleanup(); }
270
+ });
271
+
272
+ it("#4917: a held (pending) issue is flagged unhosted by status once its report is gone; --requeue re-hosts it and says so", async () => {
273
+ const h = host();
274
+ try {
275
+ await h.daemon();
276
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
277
+ await withRoot(h, () => capture(async () => {
278
+ const run = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
279
+ await until(() => h.launched().length === 1 && listBatches(h.root).length === 1);
280
+ const [id] = listBatches(h.root);
281
+ const b = openBatch(h.root, id);
282
+ b.holdIssues([2]);
283
+ writeFileSync(h.release, "");
284
+ const res = await run;
285
+ assert.equal(res.rows.find((r) => r.issue === 2).outcome, "held");
286
+ assert.deepEqual(b.pendingIssues(), [2]);
287
+ b.clearReport(); // a batch finalised before the fix, or one never resumed
288
+ const st = await request(await connectDaemon({ dir: h.sched, spawnDaemon: null }), { op: "status" });
289
+ assert.deepEqual(st.unhosted, [{ id, pending: [2] }]);
290
+ // finalised by an older version: a report plus unapplied requeue entries still reopens, and the CLI says so
291
+ b.releaseIssues([2]);
292
+ b.store.save({ ...b.store.load(), results: { ...b.store.load().results, 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
293
+ b.writeReport({ rows: [{ issue: 1, outcome: "needs-human" }] });
294
+ const rq = await capture(() => orchestrateFromCli(["--requeue", id, "1"], client));
295
+ assert.deepEqual(rq.res.requeued, [1]);
296
+ assert.ok(rq.lines.some((l) => l.includes("reopened and hosted by the schedd")), rq.lines.join("\n"));
297
+ }));
298
+ } finally { await h.cleanup(); }
299
+ });
300
+
252
301
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
253
302
  const h = host();
254
303
  try {
@@ -350,6 +399,66 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
350
399
  } finally { await h.cleanup(); }
351
400
  });
352
401
 
402
+ it("#4917: a batch that ends unreported with a deferred issue waiting is re-hosted by the running daemon once its retry-at is near", async () => {
403
+ const dir = tmpDir("schedd-rehost-"), root = join(dir, "batches"), id = "b20261031-000000-4917";
404
+ const calls = [];
405
+ const runBatch = async ({ argv, sink }) => {
406
+ calls.push(argv);
407
+ const b = openBatch(root, id);
408
+ if (argv[0] !== "--resume") {
409
+ b.writePlan({ daemon: true, repo: "o/r" });
410
+ b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 1200 } });
411
+ } else b.store.save({ ...b.store.load(), pending: [], results: { 1: { issue: 1, outcome: "merged" } } });
412
+ sink.batch({ id, dir: b.dir });
413
+ return {};
414
+ };
415
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
416
+ try {
417
+ await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
418
+ await until(() => calls.length === 2, 8000);
419
+ assert.deepEqual(calls[1], ["--resume", id]);
420
+ } finally { await d.close(); }
421
+ });
422
+
423
+ it("#4917: a resume that leaves the waiting issue unadmitted (breaker tripped, retry-at passed) is not re-hosted again", async () => {
424
+ const dir = tmpDir("schedd-rehost-loop-"), root = join(dir, "batches"), id = "b20261031-000000-4918";
425
+ const calls = [];
426
+ const runBatch = async ({ argv, sink }) => {
427
+ calls.push(argv);
428
+ const b = openBatch(root, id);
429
+ // first run: deferred issue 1 waits 300 ms; every resume ends at once (a tripped breaker), leaving it pending with a past retry-at
430
+ if (argv[0] !== "--resume") { b.writePlan({ daemon: true, repo: "o/r" }); b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 300 } }); }
431
+ sink.batch({ id, dir: b.dir });
432
+ return {};
433
+ };
434
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
435
+ try {
436
+ await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
437
+ await until(() => calls.length >= 2, 8000);
438
+ await new Promise((r) => setTimeout(r, 3500));
439
+ assert.equal(calls.length, 2, `re-hosted ${calls.length - 1} times; a past retry-at the resume did not act on must not loop`);
440
+ } finally { await d.close(); }
441
+ });
442
+
443
+ it("#4917: a batch whose breaker tripped (not transport) is not re-hosted by the timer", async () => {
444
+ const dir = tmpDir("schedd-rehost-brk-"), root = join(dir, "batches"), id = "b20261031-000000-4919";
445
+ const calls = [];
446
+ const runBatch = async ({ argv, sink }) => {
447
+ calls.push(argv);
448
+ const b = openBatch(root, id);
449
+ b.writePlan({ daemon: true, repo: "o/r" });
450
+ b.store.save({ v: 1, order: [1], results: {}, running: {}, pending: [1], notBefore: { 1: Date.now() + 300 }, breaker: { at: Date.now(), errors: 3 } });
451
+ sink.batch({ id, dir: b.dir });
452
+ return {};
453
+ };
454
+ const d = await startDaemon({ dir, root, runBatch, log: () => {}, resumeOnStart: false });
455
+ try {
456
+ await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "submit", argv: ["1"], cwd: dir, env: {} });
457
+ await new Promise((r) => setTimeout(r, 2000));
458
+ assert.equal(calls.length, 1, "a tripped breaker holds the batch for an operator --resume");
459
+ } finally { await d.close(); }
460
+ });
461
+
353
462
  it("#4454: close() stops every hosted batch loop before releasing the lock; no batch admits a lane after close()", async () => {
354
463
  const dir = tmpDir("schedd-stop-"), root = join(dir, "batches"), lock = scheddPaths(dir).lock;
355
464
  const started = { a: [], b: [] }, stoppedWithLock = {};
@@ -637,6 +746,96 @@ describe("schedd due timer (#4627)", () => {
637
746
  });
638
747
  });
639
748
 
749
+ // #4775: a client never races a systemd-managed daemon; a stalled daemon is visible and bounded.
750
+ describe("schedd under load and restart (#4775)", () => {
751
+ const noSleep = () => Promise.resolve();
752
+ it("connectDaemon never spawns while the unit is active or activating, and starts an installed stopped unit instead of spawning", async () => {
753
+ for (const state of ["active", "activating", "reloading"]) {
754
+ let spawned = 0;
755
+ await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => { spawned++; }, unit: () => state, start: () => { spawned++; } }), /did not start/);
756
+ assert.equal(spawned, 0, `${state}: nothing is started`);
757
+ }
758
+ const calls = [];
759
+ await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => "inactive", start: () => calls.push("unit") }), /did not start/);
760
+ assert.deepEqual(calls, ["unit"]);
761
+ });
762
+
763
+ it("connectDaemon spawns the daemon itself only when no unit is installed; spawnDaemon:null starts nothing", async () => {
764
+ const calls = [];
765
+ await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), waitMs: 0, sleep: noSleep, spawnDaemon: () => calls.push("spawn"), unit: () => null, start: () => calls.push("unit") }), /did not start/);
766
+ assert.deepEqual(calls, ["spawn"]);
767
+ await assert.rejects(connectDaemon({ dir: tmpDir("schedd-unit-"), spawnDaemon: null, unit: () => { throw new Error("not consulted"); } }), { code: "ENOENT" });
768
+ });
769
+
770
+ it("unitState reads the systemctl state (also from a non-zero exit) and is null without a unit file", () => {
771
+ assert.equal(unitState({ exists: () => false }), null);
772
+ assert.equal(unitState({ exists: () => true, run: () => "activating\n" }), "activating");
773
+ assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("exit 3"), { stdout: "inactive\n" }); } }), "inactive");
774
+ assert.equal(unitState({ exists: () => true, run: () => { throw Object.assign(new Error("none"), { code: "ENOENT" }); } }), null);
775
+ });
776
+
777
+ it("request(timeoutMs) rejects a daemon that never answers instead of hanging", async () => {
778
+ const dir = tmpDir("schedd-mute-"), sock = scheddPaths(dir).sock;
779
+ const server = (await import("node:net")).createServer(() => { /* accepts, never answers */ });
780
+ await new Promise((r) => server.listen(sock, r));
781
+ try {
782
+ const c = await connectDaemon({ dir, spawnDaemon: null });
783
+ await assert.rejects(request(c, { op: "status" }, undefined, { timeoutMs: 80 }), { code: "schedd.timeout" });
784
+ } finally { server.close(); }
785
+ });
786
+
787
+ it("status reports per-batch last-progress age and pending adds, including an unhosted batch with un-admitted adds", async () => {
788
+ const root = tmpDir("schedd-st-b-"), dir = tmpDir("schedd-st-");
789
+ const mk = (id, files) => { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); for (const [n, t] of Object.entries(files)) writeFileSync(join(root, id, n), t); };
790
+ mk("b-stuck", { "state.json": JSON.stringify({ order: [1] }), add: "[7,8]" });
791
+ mk("b-quiet", { "state.json": JSON.stringify({ order: [1] }) });
792
+ const d = await startDaemon({ dir, root, runBatch: async () => ({}), resumeOnStart: false, log: () => {} });
793
+ try {
794
+ const st = await request(await connectDaemon({ dir, spawnDaemon: null }), { op: "status" });
795
+ assert.equal(st.batches.length, 1, "a quiet unhosted batch is not listed");
796
+ const [b] = st.batches;
797
+ assert.equal(b.id, "b-stuck");
798
+ assert.equal(b.running, false);
799
+ assert.deepEqual(b.pendingAdds, [7, 8]);
800
+ assert.ok(b.lastProgressAgeSec >= 0 && b.lastProgressAgeSec < 60);
801
+ } finally { await d.close(); }
802
+ });
803
+
804
+ it("a batch whose start stalls does not block another batch's request, and the stalled request answers an error", async () => {
805
+ const root = tmpDir("schedd-stall-b-"), dir = tmpDir("schedd-stall-");
806
+ for (const id of ["b-hung", "b-ok"]) { mkdirSync(join(root, id), { recursive: true }); writeFileSync(join(root, id, "plan.json"), JSON.stringify({ daemon: true })); writeFileSync(join(root, id, "state.json"), JSON.stringify({ order: [1] })); }
807
+ const d = await startDaemon({ dir, root, resumeOnStart: false, admitMs: 150, log: () => {},
808
+ runBatch: ({ argv, sink }) => (argv[1] === "b-hung" ? new Promise(() => {}) : Promise.resolve().then(() => { sink.batch({ id: argv[1], dir: join(root, argv[1]) }); return {}; })) });
809
+ try {
810
+ const ask = async (id) => request(await connectDaemon({ dir, spawnDaemon: null }), { op: "attach", id }).catch((e) => e);
811
+ const hung = ask("b-hung");
812
+ const ok = await ask("b-ok");
813
+ assert.notEqual(ok instanceof Error && /did not start/.test(ok.message), true, "the healthy batch is served");
814
+ const e = await hung;
815
+ assert.match(e.message, /did not start within 150 ms/);
816
+ } finally { await d.close(); }
817
+ });
818
+
819
+ it("the unit stands by while another daemon holds the lock instead of exiting 0 (no Restart=always loop)", async () => {
820
+ const dir = tmpDir("schedd-sb-"), root = tmpDir("schedd-sb-b-");
821
+ const owner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { stdio: "ignore" });
822
+ const gone = new Promise((r) => owner.on("exit", r));
823
+ mkdirSync(dir, { recursive: true });
824
+ writeFileSync(scheddPaths(dir).lock, String(owner.pid)); // a live daemon of another process
825
+ const slept = [];
826
+ process.env.FORGEDOCK_SCHEDD_IDLE_MS = "100"; // the successor daemon exits when idle, so the call returns
827
+ process.env.FORGEDOCK_BATCH_DIR = root;
828
+ try {
829
+ const r = await Promise.race([
830
+ scheddFromCli([], { scheddDir: dir, standby: true, standbyMs: 1, sleep: async () => { slept.push(1); if (slept.length === 3) { owner.kill("SIGKILL"); await gone; } }, processOn: () => {}, exit: () => {}, batchDeps: {}, due: null }),
831
+ new Promise((res) => setTimeout(() => res("timeout"), 5000)),
832
+ ]);
833
+ assert.ok(slept.length >= 3, "waited for the owner before retrying");
834
+ assert.notEqual(r, "timeout");
835
+ } finally { delete process.env.FORGEDOCK_SCHEDD_IDLE_MS; delete process.env.FORGEDOCK_BATCH_DIR; owner.kill("SIGKILL"); }
836
+ });
837
+ });
838
+
640
839
  describe("#4762 win32 socket path", () => {
641
840
  it("win32 derives a stable per-dir named pipe; other platforms keep the unix socket file", () => {
642
841
  const a = scheddPaths("/tmp/a", "win32"), b = scheddPaths("/tmp/b", "win32");
@@ -158,7 +158,7 @@ const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs")
158
158
  export function realIo(env = process.env, { pool = false } = {}) {
159
159
  let self;
160
160
  const root = batchRoot(env);
161
- const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
161
+ const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, windowsHide: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
162
162
  const waitBatch = async (before) => { for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); } throw new Error("orchestrate did not create a batch within 60s"); };
163
163
  return (self = {
164
164
  now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
@@ -37,7 +37,7 @@ function writeLast(dir, repo, ts) {
37
37
  */
38
38
  export function launchNightly({ cli, repo, cwd = process.cwd(), spawnFn = spawn }) {
39
39
  try {
40
- const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, stdio: "ignore" });
40
+ const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, windowsHide: true, stdio: "ignore" });
41
41
  c.on("error", () => {});
42
42
  c.unref();
43
43
  return c;
@@ -8,6 +8,7 @@
8
8
  * never in the range, so they are never rewritten. Deterministic, 0 LLM calls.
9
9
  */
10
10
  const SEP = "\x1f", END = "\x1e";
11
+ const STASH_REF = "refs/forgedock/dco-stash";
11
12
  const PLACEHOLDER_RE = /@localhost$/i;
12
13
 
13
14
  /** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
@@ -59,8 +60,17 @@ export async function ensureSignedOff({ git, lane, branch, env = process.env })
59
60
  throw Object.assign(new Error(`${bad.length} unpushed commit(s) fail DCO (placeholder author or no sign-off: ${bad.slice(0, 5).map((c) => c.sha.slice(0, 8)).join(", ")}) and the worktree has no usable git identity to rewrite them`), { noIdentity: true });
60
61
  }
61
62
  const fenv = { FD_BAD: bad.map((c) => c.sha).join(" "), FD_NAME: name, FD_EMAIL: email, FILTER_BRANCH_SQUELCH_WARNING: "1" };
63
+ // filter-branch refuses a dirty tree (lockfile churn, build output): park tracked changes on a private ref, never the shared stash stack
64
+ const saved = await ok(["status", "--porcelain", "--untracked-files=no"]) ? await ok(["stash", "create"]) : "";
65
+ if (saved) { await git(["update-ref", STASH_REF, saved]); await git(["reset", "-q", "--hard", "HEAD"]); }
66
+ let failure = null;
62
67
  try { await git(["filter-branch", "-f", "--env-filter", ENV_FILTER, "--msg-filter", MSG_FILTER, "--", "HEAD", ...exclude], { env: fenv }); }
63
- catch (e) { throw Object.assign(new Error(`could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`), { noIdentity: false }); }
68
+ catch (e) { failure = `could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
69
+ if (saved) {
70
+ try { await git(["stash", "apply", saved]); await ok(["update-ref", "-d", STASH_REF]); }
71
+ catch { failure = `${failure ? `${failure}; ` : ""}could not restore the lane's uncommitted changes after the DCO rewrite; they are kept as ${saved} (${STASH_REF})`; }
72
+ }
73
+ if (failure) throw Object.assign(new Error(failure), { noIdentity: false, engineCode: "engine.dco_rewrite" });
64
74
  await ok(["for-each-ref", "--format=delete %(refname)", "refs/original/"]).then(async (l) => { for (const r of l.split("\n").map((x) => x.replace(/^delete /, "")).filter(Boolean)) await ok(["update-ref", "-d", r]); });
65
75
  return { rewritten: bad.length };
66
76
  }
@@ -4,7 +4,7 @@
4
4
  import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { execFileSync } from "node:child_process";
7
- import { writeFileSync } from "node:fs";
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
10
  import { ensureSignedOff, needsSignoff, offendersOf } from "./signoff.mjs";
@@ -99,4 +99,27 @@ describe("ensureSignedOff", () => {
99
99
  await assert.rejects(ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.noIdentity === true);
100
100
  assert.equal(f.sh("rev-parse", "HEAD"), bad);
101
101
  });
102
+
103
+ it("rewrites on a dirty tree (#4997): unstaged and staged tracked changes survive, no stash entry is left", async () => {
104
+ const f = fixture();
105
+ f.commit("a.txt", "unpushed placeholder", "placeholder");
106
+ writeFileSync(join(f.wt, "base.txt"), "churn\n");
107
+ writeFileSync(join(f.wt, "a.txt"), "staged\n"); f.sh("add", "a.txt");
108
+ const r = await ensureSignedOff({ git: f.git, lane: "staging", branch: "feat/x", env: {} });
109
+ assert.equal(r.rewritten, 1);
110
+ assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
111
+ assert.equal(readFileSync(join(f.wt, "a.txt"), "utf8"), "staged\n");
112
+ assert.equal(f.sh("stash", "list"), "");
113
+ assert.equal(f.sh("for-each-ref", "refs/forgedock/"), "");
114
+ assert.match(f.sh("log", "-1", "--format=%B"), /Signed-off-by: Lane Bot/);
115
+ });
116
+
117
+ it("a failed rewrite carries engineCode engine.dco_rewrite, restores the tree, and is not noIdentity (#4997)", async () => {
118
+ const f = fixture();
119
+ f.commit("a.txt", "unpushed placeholder", "placeholder");
120
+ writeFileSync(join(f.wt, "base.txt"), "churn\n");
121
+ const git = async (a, o) => { if (a[0] === "filter-branch") throw new Error("boom"); return f.git(a, o); };
122
+ await assert.rejects(ensureSignedOff({ git, lane: "staging", branch: "feat/x", env: {} }), (e) => e.engineCode === "engine.dco_rewrite" && e.noIdentity === false && /boom/.test(e.message));
123
+ assert.equal(readFileSync(join(f.wt, "base.txt"), "utf8"), "churn\n");
124
+ });
102
125
  });
@@ -51,14 +51,30 @@ export async function unstageToolState(git) {
51
51
  return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
52
52
  }
53
53
 
54
+ const REQUIREMENTS_EXPORT = /^requirements([-_.][\w.-]+)?\.txt$/;
55
+
56
+ /**
57
+ * #4959: `requirements*.txt` files the branch added to a Poetry/uv project (pyproject [tool.poetry], or poetry.lock / uv.lock at
58
+ * the base) that the base does not track and the contract does not list (`keep`): an export generated to run local tests.
59
+ */
60
+ export async function strayGenerated(git, ref, added, keep = []) {
61
+ const cands = added.filter((p) => REQUIREMENTS_EXPORT.test(base(p)) && !keep.includes(p));
62
+ if (!cands.length) return [];
63
+ const tracked = zlist(await git(["ls-tree", "-r", "--name-only", "-z", ref]));
64
+ const managed = tracked.some((f) => /(^|\/)(poetry|uv)\.lock$/.test(f))
65
+ || (await Promise.all(tracked.filter((f) => /(^|\/)pyproject\.toml$/.test(f)).map((f) => git(["show", `${ref}:${f}`]).catch(() => "")))).some((t) => /^\[tool\.poetry[\].]/m.test(String(t)));
66
+ return managed ? cands : [];
67
+ }
68
+
54
69
  /**
55
70
  * Remove from the index (and commit) the deny-listed files this branch added against `base`. Returns the removed paths ([]
56
- * when the branch is clean). Never throws: a failure leaves the branch as it was and the push proceeds.
71
+ * when the branch is clean). `keep`: contract deliverable paths, never removed as generated files. Never throws: a failure
72
+ * leaves the branch as it was and the push proceeds.
57
73
  */
58
- export async function untrackToolState({ git, base: ref, ev = () => {} }) {
74
+ export async function untrackToolState({ git, base: ref, ev = () => {}, keep = [] }) {
59
75
  try {
60
76
  const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
61
- const stray = await strayPaths(git, added);
77
+ const stray = [...await strayPaths(git, added), ...await strayGenerated(git, ref, added, keep)];
62
78
  if (!stray.length) return [];
63
79
  await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
64
80
  await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);