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
@@ -45,6 +45,7 @@ export const OUTCOME_CODES = Object.freeze({
45
45
  RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
46
46
  PATH_SWITCH_CONFLICT: "path.switch_conflict",
47
47
  ENGINE_ERROR: "engine.error",
48
+ ENGINE_DCO_REWRITE: "engine.dco_rewrite", // forge#4997: the DCO sign-off rewrite of unpushed commits failed (git filter-branch); mechanical engine-error (not needs-human), relaunched by the batch runner
48
49
  ENGINE_VALIDATE_RESULT_LOST: "engine.validate_result_lost", // forge#4708: a validate result recorded before a CI fix round is missing after it and could not be carried forward
49
50
  INFRA_DISK_QUOTA: "infra.disk_quota",
50
51
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
@@ -32,6 +32,7 @@ import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lea
32
32
  import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
33
33
  import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
34
34
  import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
35
+ import * as _autoChildren from "./lean/autochildren.mjs";
35
36
 
36
37
  /**
37
38
  * Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
@@ -408,8 +409,8 @@ export function readVerify(cwd = process.cwd()) {
408
409
  try {
409
410
  const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
410
411
  if (!sec || typeof sec !== "object") return undefined;
411
- const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky");
412
- return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}) });
412
+ const invariants = readInvariants(cwd), flaky = readYamlList(cwd, "verify", "flaky"), guard_command = readYamlMap(cwd, "verify", "guard_command");
413
+ return parseVerify({ ...sec, ...(invariants ? { invariants } : {}), ...(flaky ? { flaky } : {}), ...(guard_command ? { guard_command } : {}) });
413
414
  } catch { return undefined; }
414
415
  }
415
416
  /** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
@@ -447,6 +448,30 @@ export function readPrecheckCommands(cwd = process.cwd()) {
447
448
  export function readInvariants(cwd = process.cwd()) {
448
449
  return readYamlList(cwd, "verify", "invariants");
449
450
  }
451
+ /** #4792: a block map under `section.key` (` ".py": "cmd"` or ` py: cmd`); the one-level parser drops these lines. undefined when absent or empty. */
452
+ export function readYamlMap(cwd, section, key) {
453
+ let raw;
454
+ try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
455
+ const out = {};
456
+ const head = new RegExp(`^${section}:\\s*(#.*)?$`), keyRe = new RegExp(`^\\s+${key}:\\s*(#.*)?$`);
457
+ let inSection = false, mapIndent = null;
458
+ for (const line of raw.split(/\r?\n/)) {
459
+ if (/^\s*(#.*)?$/.test(line)) continue;
460
+ if (/^\S/.test(line)) { inSection = head.test(line); mapIndent = null; continue; }
461
+ if (!inSection) continue;
462
+ const indent = line.length - line.trimStart().length;
463
+ if (mapIndent === null) { if (keyRe.test(line)) mapIndent = indent; continue; }
464
+ if (indent <= mapIndent) { mapIndent = keyRe.test(line) ? indent : null; continue; }
465
+ const m = /^\s+(?:"([^"]+)"|'([^']+)'|([^\s:#"']+))\s*:\s+(.*)$/.exec(line);
466
+ if (!m) continue;
467
+ let v = m[4].trim();
468
+ const q = /^"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(v) ?? /^'((?:[^']|'')*)'\s*(#.*)?$/.exec(v);
469
+ if (q) v = v.startsWith('"') ? q[1].replace(/\\(["\\])/g, "$1") : q[1].replace(/''/g, "'");
470
+ else v = v.replace(/\s+#.*$/, "").trim();
471
+ if (v) out[m[1] ?? m[2] ?? m[3]] = v;
472
+ }
473
+ return Object.keys(out).length ? out : undefined;
474
+ }
450
475
  function readYamlList(cwd, section, key) {
451
476
  let raw;
452
477
  try { raw = readFileSync(join(cwd, "forge.yaml"), "utf8"); } catch { return undefined; }
@@ -496,11 +521,14 @@ function readPrecheckOptions(cwd = process.cwd()) {
496
521
  */
497
522
  export function readEnvConfig(cwd = process.cwd()) {
498
523
  try {
499
- const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.env ?? {};
524
+ const y = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"));
525
+ const e = y?.env ?? {};
526
+ const tr = y?.verification?.test_runners; // #4857: {path prefix: runner command prefix} for validate commands
527
+ const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
500
528
  const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
501
529
  const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
502
530
  return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
503
- ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}) };
531
+ ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}) };
504
532
  } catch { return {}; }
505
533
  }
506
534
  export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
@@ -1204,8 +1232,8 @@ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
1204
1232
  return { ok: true, batch: id };
1205
1233
  }
1206
1234
 
1207
- const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
1208
- const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force"];
1235
+ const ORCH_USAGE = "usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--force <reason>] [--repo owner/repo] [--auto-children off|decomposed|epic|all] [--epic N] | --resume <id> [issue...] | --set-parallel <id> <K> | --clear-breaker <id> | --add <id> <issue...> | --requeue <id> <issue...> [--reason <text>] | --cancel <id> [--stop-remote] | --hold <id> <issue...> | --release <id> <issue...> | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY";
1236
+ const ORCH_VALUED = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model", "--reason", "--force", "--auto-children", "--epic"];
1209
1237
  const issuesOf = (argv) => {
1210
1238
  const issues = [];
1211
1239
  for (let i = 0; i < argv.length; i++) {
@@ -1255,6 +1283,11 @@ async function addedEdges({ added, records, io, repo }) {
1255
1283
  * state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
1256
1284
  * when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
1257
1285
  */
1286
+ /** #5000: what happened to the batch after an add/requeue; never implies a run when no daemon hosts it. */
1287
+ const hostedLine = (id, res) => res.reopened ? `batch ${id}: finished batch reopened and hosted by the schedd`
1288
+ : res.hosted ? `batch ${id}: hosted by the schedd; the runner picks it up on its next pass`
1289
+ : `batch ${id}: queued but NOT hosted (no schedd hosts it); run: forgedock orchestrate --resume ${id}`;
1290
+
1258
1291
  async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
1259
1292
  const batchMod = await import("./lean/batch.mjs");
1260
1293
  const b = batchMod.openBatch(root, id);
@@ -1280,11 +1313,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1280
1313
  let res = { added: [], skipped: [], requeued: [] };
1281
1314
  if (open.length) {
1282
1315
  res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1283
- if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
1316
+ if (res.reopened === undefined && (res.added?.length || res.requeued?.length) && b.readReport()) b.clearReport(); // #4687/#5000: no daemon: the next --resume reruns it
1284
1317
  }
1285
1318
  const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
1319
+ if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
1286
1320
  out.edges = await addedEdges({ added: out.added, records, io, repo }); // #4773
1287
1321
  print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || (out.requeued.length ? "" : "(none)")}${out.requeued.length ? `${out.added.length ? " " : ""}${out.requeued.map((n) => `requeued #${n}`).join(" ")}` : ""}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1322
+ if (out.added.length || out.requeued.length) print(hostedLine(id, res));
1288
1323
  if (out.edges.length) print(`batch ${id}: edges ${out.edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ")}`);
1289
1324
  return out;
1290
1325
  }
@@ -1343,7 +1378,9 @@ async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, pr
1343
1378
  if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
1344
1379
  }
1345
1380
  const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
1381
+ if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
1346
1382
  print(`batch ${id}: ${out.requeued.map((n) => `requeued #${n}`).join(" ") || "requeued (none)"}${out.refused.length ? `; refused ${out.refused.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
1383
+ if (out.requeued.length) print(hostedLine(id, res));
1347
1384
  return out;
1348
1385
  }
1349
1386
 
@@ -1379,7 +1416,7 @@ async function orchestrateClient(argv, deps = {}) {
1379
1416
  if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
1380
1417
  let sock = null;
1381
1418
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1382
- if (sock) await schedd.request(sock, { op: "set-parallel", id, k });
1419
+ if (sock) await schedd.request(sock, { op: "set-parallel", id, k }, undefined, { timeoutMs: schedd.REQUEST_MS });
1383
1420
  else {
1384
1421
  // No daemon: the batch is not running anywhere; the parallel file is all there is to change.
1385
1422
  const batchMod = await import("./lean/batch.mjs");
@@ -1394,17 +1431,17 @@ async function orchestrateClient(argv, deps = {}) {
1394
1431
  const addVia = (id) => async (issues) => {
1395
1432
  let sock = null;
1396
1433
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1397
- return sock ? schedd.request(sock, { op: "add", id, issues }) : null;
1434
+ return sock ? schedd.request(sock, { op: "add", id, issues }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
1398
1435
  };
1399
1436
  const requeueVia = (id) => async (issues, reason, by) => {
1400
1437
  let sock = null;
1401
1438
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1402
- return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }) : null;
1439
+ return sock ? schedd.request(sock, { op: "requeue", id, issues, reason, by }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
1403
1440
  };
1404
1441
  const holdVia = (id) => async (op, issues) => {
1405
1442
  let sock = null;
1406
1443
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1407
- return sock ? schedd.request(sock, { op, id, issues }) : null;
1444
+ return sock ? schedd.request(sock, { op, id, issues }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
1408
1445
  };
1409
1446
  const batchRootOf = async () => (await import("./lean/batch.mjs")).batchRoot();
1410
1447
  if (["--show-env", "--set-env", "--unset-env"].some((f) => argv.includes(f))) return envBatch({ argv, root: await batchRootOf() });
@@ -1414,7 +1451,7 @@ async function orchestrateClient(argv, deps = {}) {
1414
1451
  return cancelBatch({ id, root: await batchRootOf(), stopRemote: argv.includes("--stop-remote"), viaDaemon: async () => {
1415
1452
  let sock = null;
1416
1453
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { sock = null; }
1417
- return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }) : null;
1454
+ return sock ? schedd.request(sock, { op: "cancel", id, stopRemote: argv.includes("--stop-remote") }, undefined, { timeoutMs: schedd.REQUEST_MS }) : null;
1418
1455
  } });
1419
1456
  }
1420
1457
  if (argv.includes("--requeue")) {
@@ -1718,6 +1755,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1718
1755
  lanes = { ...lanes, stopLane };
1719
1756
  }
1720
1757
  const orch = deps.orchestration ?? readOrchestration(cwd);
1758
+ // #4965: --auto-children overrides forge.yaml; it is part of plan.argv, so --resume keeps it
1759
+ const autoMod = _autoChildren, yamlAuto = readAutoChildren(cwd);
1760
+ const autoChildren = { mode: argv.includes("--auto-children") ? autoMod.parseAutoModeFlag(flag(argv, "--auto-children")) : yamlAuto.mode, pollS: yamlAuto.pollS,
1761
+ epics: argv.flatMap((a, i) => (a === "--epic" && /^#?\d+$/.test(argv[i + 1] ?? "") ? [parseInt(argv[i + 1].replace("#", ""), 10)] : [])) };
1721
1762
  // #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
1722
1763
  const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
1723
1764
  if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
@@ -1835,7 +1876,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1835
1876
  const hostIo = await laneCheck.hostLaneIo({ env });
1836
1877
  preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
1837
1878
  }
1838
- let rows;
1879
+ let rows, childFields = null; // #4965: the host gh's accepted `issue list --json` fields, read once from its help
1839
1880
  try {
1840
1881
  rows = await orchestrate({
1841
1882
  ...(estimate ? { estimate } : {}),
@@ -1843,9 +1884,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1843
1884
  ...(preflight ? { preflight } : {}),
1844
1885
  ...(orch.blockedLabel ? { getBlocked: async () => JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--label", orch.blockedLabel, "--state", "open", "--limit", "200", "--json", "number"])).map((x) => x.number) } : {}), // #4773
1845
1886
  trusted: (n) => trustedOf.get(n) === true,
1887
+ autoChildren,
1888
+ ...(autoMod.addsEpicChildren(autoChildren.mode) ? { getChildren: async () => ({ list: await autoMod.readChildList({ gh: (a) => leanIo.gh(a), repo, fields: childFields ??= await autoMod.hostListFields((a) => leanIo.gh(a)) }), elsewhere: autoMod.liveElsewhere({ batchMod, root, self: batch.id, repo }) }) } : {}),
1846
1889
  issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
1847
1890
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1848
- ...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1891
+ ...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null, { cfg: readAdmission(cwd) }), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1849
1892
  ...(remote ? { remote } : {}),
1850
1893
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1851
1894
  ...(recoveryOn ? { recovery: orch.recovery } : {}),
@@ -1928,7 +1971,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1928
1971
  } catch { /* alerts never fail a batch */ }
1929
1972
  for (const l of report) sink.line(l);
1930
1973
  const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
1931
- try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
1974
+ // #4917: a batch with any pending issue is not finished: no report, so a resume or the daemon picks it up
1975
+ let unfinished = false;
1976
+ try { unfinished = batch.pendingIssues().length > 0 && !batch.readCancel(); } catch { unfinished = false; } // a cancel ends the batch for good
1977
+ if (!unfinished) try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
1932
1978
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
1933
1979
  }
1934
1980
 
@@ -2058,8 +2104,21 @@ export async function scheddFromCli(argv, deps = {}) {
2058
2104
  if (argv[0] === "status") {
2059
2105
  let sock;
2060
2106
  try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
2061
- const st = await schedd.request(sock, { op: "status" });
2062
- console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}${Number.isFinite(st.lastTick) ? `, last tick ${Math.round((Date.now() - st.lastTick) / 1000)} s ago` : ""}`);
2107
+ let st;
2108
+ try { st = await schedd.request(sock, { op: "status" }, undefined, { timeoutMs: deps.statusMs ?? schedd.REQUEST_MS }); }
2109
+ catch (e) {
2110
+ if (e?.code !== "schedd.timeout") throw e;
2111
+ console.log(`schedd: not responding (${e.message}); see ${schedd.scheddPaths(dir).log}`);
2112
+ process.exitCode = 1;
2113
+ return { running: true, responding: false };
2114
+ }
2115
+ console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${Number.isFinite(st.lastTick) ? `, last tick ${Math.round((Date.now() - st.lastTick) / 1000)} s ago` : ""}`);
2116
+ for (const b of st.batches) {
2117
+ const age = b.lastProgressAgeSec == null ? "no progress yet" : `last progress ${b.lastProgressAgeSec}s ago`;
2118
+ const pend = b.pendingAdds?.length ? `, pending adds: ${b.pendingAdds.map((n) => `#${n}`).join(" ")}` : "";
2119
+ console.log(` ${b.id}${b.running ? "" : " (not hosted)"}: ${age}${pend}`);
2120
+ }
2121
+ for (const u of st.unhosted ?? []) console.log(`schedd: batch ${u.id} is not hosted but has ${u.pending.length} pending: ${u.pending.map((n) => `#${n}`).join(" ")} (forgedock orchestrate --resume ${u.id})`);
2063
2122
  return { running: true, ...st };
2064
2123
  }
2065
2124
  if (argv[0] === "watchdog") { // #4780: run by the schedd watchdog timer; signals a live daemon whose heartbeat went stale
@@ -2073,7 +2132,13 @@ export async function scheddFromCli(argv, deps = {}) {
2073
2132
  process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
2074
2133
  const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }),
2075
2134
  ...(deps.daemonPreflight !== undefined ? { preflight: deps.daemonPreflight } : deps.batchDeps ? {} : { preflight: async () => { const lc = await import("./lean/lanecheck.mjs"); return (await lc.runLaneChecks({ exec: readExecution(process.cwd()), io: await lc.hostLaneIo() })).filter((c) => !c.ok); } }) });
2076
- if (!d.running) return d;
2135
+ if (!d.running) {
2136
+ // #4775: under systemd, exiting here (code 0) makes `Restart=always` loop while another daemon holds the lock: stand by, take over when it dies
2137
+ if (!(deps.standby ?? Boolean(process.env.INVOCATION_ID))) return d;
2138
+ const wait = deps.standbyMs ?? 5000, alive = deps.alive ?? ((await import("./lean/sched.mjs")).pidAlive);
2139
+ do await (deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(wait); while (alive(d.owner));
2140
+ return scheddFromCli(argv, deps);
2141
+ }
2077
2142
  // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
2078
2143
  schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
2079
2144
  await d.closed;
@@ -2129,7 +2194,7 @@ export async function decisionsFromCli(argv, deps = {}) {
2129
2194
  const [sub, ...rest] = argv;
2130
2195
  if (sub !== "list") throw new Error(usage);
2131
2196
  const flag = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : null; };
2132
- const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024 })).stdout));
2197
+ const run = deps.exec ?? (async (cmd, args) => String((await pexec(cmd, args, { maxBuffer: 64 * 1024 * 1024, timeout: 60000 })).stdout));
2133
2198
  const repo = flag("--repo") ?? String(await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])).trim();
2134
2199
  if (rest.includes("--refresh")) {
2135
2200
  const jq = '.[] | select(.body | startswith("<!-- FORGE:DECISION {")) | {body, user, author_association, issue_url} | tojson';
@@ -2266,6 +2331,13 @@ export async function schedFromCli(argv, deps = {}) {
2266
2331
  /** #4773: is the author of a `viewIssue` record (author plus the REST association, #4859) trusted (the engine admission's `isTrustedActor`)? */
2267
2332
  const bodyTrusted = (v) => isTrustedActor({ association: v?.authorAssociation, type: v?.author?.is_bot ? "Bot" : "User", login: v?.author?.login });
2268
2333
 
2334
+ /** forge.yaml `orchestration.auto_add_children` (#4965; default `decomposed`) and `auto_add_children_poll_s` (default 120). */
2335
+ export function readAutoChildren(cwd = process.cwd()) {
2336
+ let o = {};
2337
+ try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
2338
+ return { mode: _autoChildren.parseAutoMode(o.auto_add_children), pollS: _autoChildren.parsePollS(o.auto_add_children_poll_s) };
2339
+ }
2340
+
2269
2341
  /**
2270
2342
  * forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
2271
2343
  * `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
package/bin/journey.mjs CHANGED
@@ -642,9 +642,9 @@ export function openUrl(url) {
642
642
  if (process.platform === "win32") {
643
643
  spawnSync("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
644
644
  } else if (process.platform === "darwin") {
645
- spawnSync("open", [url], { detached: true, stdio: "ignore" });
645
+ spawnSync("open", [url], { detached: true, windowsHide: true, stdio: "ignore" });
646
646
  } else {
647
- spawnSync("xdg-open", [url], { detached: true, stdio: "ignore" });
647
+ spawnSync("xdg-open", [url], { detached: true, windowsHide: true, stdio: "ignore" });
648
648
  }
649
649
  } catch {
650
650
  // Best-effort — if the browser can't be opened, the journey continues.
@@ -0,0 +1,103 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Auto-add of epic and decomposition children to a running batch (#4965): `orchestration.auto_add_children`.
4
+ // Pure and deterministic: the child scan reads one open-issue list per pass; no LLM call.
5
+ import { parentOf } from "./context.mjs";
6
+ import { parseHelpFields } from "./selftest-static.mjs";
7
+
8
+ export const AUTO_MODES = ["off", "decomposed", "epic", "all"];
9
+ export const DEFAULT_AUTO_MODE = "decomposed";
10
+ export const DEFAULT_POLL_S = 120;
11
+
12
+ /** @returns {string} a valid mode (case-insensitive), else `fallback` (forge.yaml typos never stop a batch). */
13
+ export function parseAutoMode(v, fallback = DEFAULT_AUTO_MODE) {
14
+ const m = String(v ?? "").trim().toLowerCase();
15
+ return AUTO_MODES.includes(m) ? m : fallback;
16
+ }
17
+
18
+ /** The CLI form: an unknown mode is an operator error. */
19
+ export function parseAutoModeFlag(v) {
20
+ const m = String(v ?? "").trim().toLowerCase();
21
+ if (!AUTO_MODES.includes(m)) throw new Error(`--auto-children must be one of ${AUTO_MODES.join(" | ")}`);
22
+ return m;
23
+ }
24
+
25
+ /** Seconds between scans: a positive integer, else the default. */
26
+ export function parsePollS(v, fallback = DEFAULT_POLL_S) {
27
+ const n = Number(v);
28
+ return v !== undefined && v !== null && v !== "" && Number.isInteger(n) && n >= 1 ? n : fallback;
29
+ }
30
+
31
+ /** Does `mode` add the epic/tracker children (`epic`, `all`)? */
32
+ export const addsEpicChildren = (mode) => mode === "epic" || mode === "all";
33
+ /** Does `mode` join the sub-issues of a decomposed lane (`decomposed`, `all`; `epic` keeps today's join too)? */
34
+ export const joinsDecomposed = (mode) => mode !== "off";
35
+
36
+ const nodesOf = (v) => (Array.isArray(v) ? v : Array.isArray(v?.nodes) ? v.nodes : []);
37
+ const labelsOf = (it) => nodesOf(it?.labels).map((l) => String(l?.name ?? l));
38
+
39
+ /**
40
+ * @param {{number:number, body?:string, labels?:any, parent?:any, subIssues?:any}[]} list one open-issue list
41
+ * @param {Iterable<number>} roots the batch issues plus the `--epic N` epics
42
+ * @param {Iterable<number>} skip issues the batch already holds
43
+ * @returns {number[]} open children of a root, ascending: `**Parent**: #P` line, `parent` field, or the root's `subIssues`;
44
+ * an issue labelled `needs-human` is left out.
45
+ */
46
+ export function selectChildren({ list, roots, skip = [] }) {
47
+ const rootSet = new Set(roots), skipSet = new Set(skip), open = new Set(), out = new Set();
48
+ for (const it of list ?? []) if (Number.isSafeInteger(it?.number)) open.add(it.number);
49
+ for (const it of list ?? []) {
50
+ const n = it?.number;
51
+ if (!open.has(n)) continue;
52
+ if (rootSet.has(n)) for (const s of nodesOf(it.subIssues)) if (open.has(s?.number)) out.add(s.number);
53
+ const p = it.parent?.number ?? parentOf(it.body ?? "");
54
+ if (p != null && rootSet.has(p)) out.add(n);
55
+ }
56
+ const byNum = new Map((list ?? []).map((it) => [it.number, it]));
57
+ return [...out].filter((n) => !skipSet.has(n) && !labelsOf(byNum.get(n)).includes("needs-human")).sort((a, b) => a - b);
58
+ }
59
+
60
+ const REQUIRED_FIELDS = ["number", "body", "labels"], OPTIONAL_FIELDS = ["parent", "subIssues"];
61
+
62
+ /** The `--json` list this host's `gh issue list` accepts (read from its `--help`, K4): the required fields plus whichever sub-issue fields it advertises. */
63
+ export async function hostListFields(gh) {
64
+ let have = [];
65
+ try { have = parseHelpFields(await gh(["issue", "list", "--help"])); } catch { have = []; }
66
+ return [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS.filter((f) => have.includes(f))];
67
+ }
68
+
69
+ /**
70
+ * One open-issue list with what `selectChildren` reads. A host `gh` without `parent`/`subIssues` still lists with the
71
+ * required fields, so children are found by their `**Parent**: #P` line.
72
+ */
73
+ export async function readChildList({ gh, repo, limit = 1000, fields = null }) {
74
+ const f = fields ?? (await hostListFields(gh));
75
+ const raw = await gh(["issue", "list", "-R", repo, "--state", "open", "--limit", String(limit), "--json", f.join(",")]);
76
+ const v = JSON.parse(raw);
77
+ return Array.isArray(v) ? v : [];
78
+ }
79
+
80
+ /**
81
+ * Issues another live batch of this repo still holds (in its order, no final row): children there are not added here.
82
+ * @param {{batchMod:object, root:string, self:string, repo:string}} p
83
+ */
84
+ export function liveElsewhere({ batchMod, root, self, repo }) {
85
+ const held = new Set();
86
+ let ids = [];
87
+ try { ids = batchMod.listBatches(root); } catch { return held; }
88
+ for (const raw of ids) {
89
+ const id = typeof raw === "string" ? raw : raw?.id;
90
+ if (!id || id === self) continue;
91
+ try {
92
+ const b = batchMod.openBatch(root, id);
93
+ if (b.readReport() || (b.readPlan()?.repo ?? repo) !== repo) continue;
94
+ const st = b.store.load();
95
+ if (!st) continue;
96
+ for (const n of st.order ?? []) if (!(st.results ?? {})[n]) held.add(n);
97
+ } catch { /* an unreadable batch holds nothing */ }
98
+ }
99
+ return held;
100
+ }
101
+
102
+ /** `#a blocked by #b, ...` for the declared edges of the added children that stay inside the batch. */
103
+ export const edgeText = (edges) => edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ");
@@ -0,0 +1,126 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4965: auto-add of epic and decomposition children: mode parse, child selection, filters.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { parseAutoMode, parseAutoModeFlag, parsePollS, selectChildren, readChildList, hostListFields, liveElsewhere, joinsDecomposed, addsEpicChildren, DEFAULT_POLL_S } from "./autochildren.mjs";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+
9
+ describe("#4965 modes", () => {
10
+ it("default is decomposed; unknown yaml values fall back; the flag rejects them", () => {
11
+ assert.equal(parseAutoMode(undefined), "decomposed");
12
+ assert.equal(parseAutoMode("EPIC"), "epic");
13
+ assert.equal(parseAutoMode("bogus"), "decomposed");
14
+ assert.equal(parseAutoModeFlag("all"), "all");
15
+ assert.throws(() => parseAutoModeFlag("bogus"), /--auto-children/);
16
+ assert.equal(parsePollS(undefined), DEFAULT_POLL_S);
17
+ assert.equal(parsePollS("30"), 30);
18
+ assert.equal(parsePollS("0"), DEFAULT_POLL_S);
19
+ });
20
+ it("which modes add epic children and which join decomposed", () => {
21
+ assert.deepEqual(["off", "decomposed", "epic", "all"].map(addsEpicChildren), [false, false, true, true]);
22
+ assert.deepEqual(["off", "decomposed", "epic", "all"].map(joinsDecomposed), [false, true, true, true]);
23
+ });
24
+ });
25
+
26
+ describe("#4965 selectChildren", () => {
27
+ const list = [
28
+ { number: 1, body: "epic", labels: [], subIssues: { nodes: [{ number: 5 }, { number: 9 }] } },
29
+ { number: 2, body: "x\n\n**Parent**: #1", labels: [] },
30
+ { number: 3, body: "", labels: [], parent: { number: 1 } },
31
+ { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] },
32
+ { number: 5, body: "", labels: [] },
33
+ { number: 6, body: "**Parent**: #77", labels: [] },
34
+ { number: 7, body: "**Parent**: #1", labels: [] },
35
+ ];
36
+ it("finds children by Parent line, parent field and sub-issues; skips needs-human, closed sub-issues and unrelated", () => {
37
+ assert.deepEqual(selectChildren({ list, roots: [1], skip: [1] }), [2, 3, 5, 7]);
38
+ });
39
+ it("skips issues already in the batch or held elsewhere", () => {
40
+ assert.deepEqual(selectChildren({ list, roots: [1], skip: [1, 2, 7] }), [3, 5]);
41
+ });
42
+ it("an --epic root outside the batch yields its children", () => {
43
+ assert.deepEqual(selectChildren({ list, roots: [77] }), [6]);
44
+ });
45
+ it("children filed later appear on a later list", () => {
46
+ assert.deepEqual(selectChildren({ list: [...list, { number: 8, body: "**Parent**: #1", labels: [] }], roots: [1], skip: [1, 2, 3, 5, 7] }), [8]);
47
+ });
48
+ });
49
+
50
+ describe("#4965 readers", () => {
51
+ const help = (fields) => `Usage\n\nJSON FIELDS\n ${fields.join(", ")}\n\nEXAMPLES\n $ gh issue list\n`;
52
+ const fakeGh = (fields, calls) => async (a) => {
53
+ calls.push(a);
54
+ if (a.includes("--help")) return help(fields);
55
+ const j = a[a.indexOf("--json") + 1].split(",");
56
+ const bad = j.find((f) => !fields.includes(f));
57
+ if (bad) throw new Error(`Unknown JSON field: "${bad}"`);
58
+ return "[{\"number\":1}]";
59
+ };
60
+ it("readChildList asks the host gh for its fields, then makes one list call", async () => {
61
+ const calls = [];
62
+ const r = await readChildList({ gh: fakeGh(["number", "body", "labels", "parent", "subIssues", "title"], calls), repo: "o/r" });
63
+ assert.equal(calls.filter((a) => !a.includes("--help")).length, 1);
64
+ assert.equal(calls.at(-1)[calls.at(-1).indexOf("--json") + 1], "number,body,labels,parent,subIssues");
65
+ assert.deepEqual(r, [{ number: 1 }]);
66
+ });
67
+ it("a host gh without parent/subIssues still lists, with the fields it has (children found by Parent line)", async () => {
68
+ const calls = [];
69
+ const gh = fakeGh(["number", "body", "labels", "title"], calls);
70
+ assert.deepEqual(await hostListFields(gh), ["number", "body", "labels"]);
71
+ assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1 }]);
72
+ assert.equal(calls.at(-1)[calls.at(-1).indexOf("--json") + 1], "number,body,labels");
73
+ });
74
+ it("liveElsewhere holds the unsettled issues of other live batches of the repo only", () => {
75
+ const mk = (o) => ({ readReport: () => o.report ?? null, readPlan: () => ({ repo: o.repo ?? "o/r" }), store: { load: () => ({ order: o.order, results: o.results ?? {} }) } });
76
+ const bs = { a: mk({ order: [1, 2], results: { 1: {} } }), b: mk({ order: [3], report: {} }), c: mk({ order: [4], repo: "x/y" }), self: mk({ order: [5] }) };
77
+ const batchMod = { listBatches: () => Object.keys(bs), openBatch: (_r, id) => bs[id] };
78
+ assert.deepEqual([...liveElsewhere({ batchMod, root: "/r", self: "self", repo: "o/r" })], [2]);
79
+ });
80
+ });
81
+
82
+ describe("#4965 forge.yaml and resume", () => {
83
+ it("readAutoChildren reads auto_add_children and its poll; defaults keep today's behaviour", async () => {
84
+ const { readAutoChildren } = await import("../engine-cli.mjs");
85
+ const { writeFileSync } = await import("node:fs");
86
+ const { join } = await import("node:path");
87
+ const d = tmpDir("ac-");
88
+ assert.equal(readAutoChildren(d).mode, "decomposed");
89
+ assert.equal(readAutoChildren(d).pollS, 120);
90
+ writeFileSync(join(d, "forge.yaml"), "orchestration:\n auto_add_children: epic\n auto_add_children_poll_s: 30\n");
91
+ assert.equal(readAutoChildren(d).mode, "epic");
92
+ assert.equal(readAutoChildren(d).pollS, 30);
93
+ });
94
+ it("--resume keeps the mode: a resumed batch whose plan carries --auto-children epic --epic 9 adds #9's children", async () => {
95
+ const { openBatch } = await import("./batch.mjs");
96
+ const { orchestrateFromCli } = await import("../engine-cli.mjs");
97
+ const fs = await import("node:fs");
98
+ const { join } = await import("node:path");
99
+ process.env.FORGEDOCK_SCHED_DIR = tmpDir("ac-sched-");
100
+ const work = tmpDir("acb-"), root = join(work, "batches"), launches = join(work, "launches");
101
+ fs.writeFileSync(launches, "");
102
+ const stub = join(work, "stub.mjs");
103
+ fs.writeFileSync(stub, `import { appendFileSync, writeFileSync } from "node:fs";
104
+ const a = process.argv.slice(2); const rf = a[a.indexOf("--result-file") + 1];
105
+ appendFileSync(${JSON.stringify(launches)}, a[1] + "\\n"); writeFileSync(rf, JSON.stringify({ terminalReason: "merged", pr: 100 + Number(a[1]), cost_usd: 0 }));
106
+ `);
107
+ const b = openBatch(root, "b20261101-000000-1");
108
+ b.writePlan({ v: 1, id: b.id, argv: ["1", "--repo", "o/r", "--no-prefilter", "--auto-children", "epic", "--epic", "9"], repo: "o/r", cwd: process.cwd(), created: new Date().toISOString() });
109
+ b.setParallel(2);
110
+ const lists = [];
111
+ const gh = async (a) => {
112
+ if (a[0] === "issue" && a[1] === "list" && a.includes("--help")) return "JSON FIELDS\n number, body, labels, parent, subIssues\n\nEXAMPLES\n";
113
+ if (a[0] === "issue" && a[1] === "list" && a.includes("--state") && a.includes("number,body,labels,parent,subIssues")) { lists.push(a); return JSON.stringify([{ number: 9, body: "", labels: [] }, { number: 51, body: "**Parent**: #9", labels: [] }]); }
114
+ if (a.includes("milestone")) return JSON.stringify({ milestone: null });
115
+ if (a[0] === "repo") return "main\n";
116
+ return JSON.stringify({ body: "", state: "OPEN", labels: [] });
117
+ };
118
+ const deps = { batchRoot: root, allowTmpfs: true, laneScript: stub, pollMs: 20, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" },
119
+ leanIo: { gh, git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") } };
120
+ const l = console.log; console.log = () => {};
121
+ let res;
122
+ try { res = await orchestrateFromCli(["--resume", b.id], deps); } finally { console.log = l; }
123
+ assert.ok(lists.length >= 1, "the child list was read: the epic mode survived the resume");
124
+ assert.deepEqual(res.rows.map((r) => r.issue).sort((x, y) => x - y), [1, 51]);
125
+ });
126
+ });
@@ -15,7 +15,7 @@
15
15
  * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path. These
16
16
  * detached lanes are the `local` execution (#4253); `execution.mjs` is the sandbox-provider seam for remote lanes.
17
17
  */
18
- import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
18
+ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
19
19
  import { spawn } from "node:child_process";
20
20
  import { homedir } from "node:os";
21
21
  import { dirname, join, resolve, sep } from "node:path";
@@ -86,7 +86,7 @@ const readAdded = (file) => {
86
86
  };
87
87
 
88
88
  /** `outcome`s `--requeue` may move back to pending (#4550). */
89
- export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused"].includes(outcome) || /^dependency-/.test(String(outcome));
89
+ export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused", "deferred"].includes(outcome) || /^dependency-/.test(String(outcome));
90
90
  /** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
91
91
  export const requeueKey = (e) => `${e.issue}@${e.at}`;
92
92
 
@@ -191,6 +191,8 @@ export function openBatch(root, id) {
191
191
  }
192
192
  return { requeued, refused };
193
193
  },
194
+ /** #4917: the issues still `pending` in state.json (deferred or requeued included); [] when none or unreadable. */
195
+ pendingIssues: () => { const p = readJson(f("state.json"))?.pending; return Array.isArray(p) ? p.filter((n) => Number.isSafeInteger(n)) : []; },
194
196
  /** Every `--requeue` entry, in order; [] when missing or invalid. */
195
197
  readRequeued: () => readRequeueFile(f("requeue")),
196
198
  /** Entries the runner has not applied yet (a finished or unhosted batch with a pending requeue). */
@@ -220,6 +222,12 @@ export function openBatch(root, id) {
220
222
  clearCancel: () => rmSync(f("cancel"), { force: true }),
221
223
  /** Added issues the batch never took into its `order` (a finished or unhosted batch with pending adds). */
222
224
  unrunAdds: () => { const order = new Set(readJson(f("state.json"))?.order ?? []); return readAdded(f("add")).filter((n) => !order.has(n)); },
225
+ /** #4775: epoch ms of the batch's last progress (newest mtime of `state.json` / `log.jsonl`), or null when neither exists. */
226
+ lastProgressMs: () => {
227
+ let best = null;
228
+ for (const n of ["state.json", "log.jsonl"]) { try { const m = statSync(f(n)).mtimeMs; if (best === null || m > best) best = m; } catch { /* absent */ } }
229
+ return best;
230
+ },
223
231
  /** The lane updates in `log.jsonl`, in order (unparsable lines skipped). */
224
232
  readLog: () => {
225
233
  let text = "";
@@ -259,7 +267,7 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
259
267
  const out = openSync(batch.logFile(issue), "a", 0o600);
260
268
  try {
261
269
  // FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
262
- const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, stdio: ["ignore", out, out] });
270
+ const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, windowsHide: true, stdio: ["ignore", out, out] });
263
271
  child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
264
272
  child.unref?.();
265
273
  return { pid: child.pid };