forgedock 1.43.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +59 -10
  4. package/bin/forgedock.mjs +50 -24
  5. package/bin/gc.mjs +3 -1
  6. package/bin/hooks/pre-tool-use.mjs +54 -3
  7. package/bin/journey.mjs +2 -2
  8. package/bin/lean/autochildren.mjs +13 -5
  9. package/bin/lean/autochildren.test.mjs +36 -18
  10. package/bin/lean/batch.mjs +3 -1
  11. package/bin/lean/batch.test.mjs +83 -0
  12. package/bin/lean/conformance.mjs +72 -13
  13. package/bin/lean/conformance.test.mjs +100 -9
  14. package/bin/lean/decision-engine.mjs +1 -1
  15. package/bin/lean/decision-engine.test.mjs +11 -0
  16. package/bin/lean/effects.mjs +1 -1
  17. package/bin/lean/execution.test.mjs +9 -6
  18. package/bin/lean/gatewatch.test.mjs +5 -5
  19. package/bin/lean/issuewatch.mjs +2 -2
  20. package/bin/lean/lane.mjs +22 -4
  21. package/bin/lean/lane.test.mjs +33 -1
  22. package/bin/lean/lean.mjs +65 -17
  23. package/bin/lean/lean.test.mjs +84 -2
  24. package/bin/lean/orchestrate-blocked.test.mjs +28 -0
  25. package/bin/lean/orchestrate-children.test.mjs +16 -4
  26. package/bin/lean/orchestrate.mjs +58 -11
  27. package/bin/lean/pmap.mjs +17 -0
  28. package/bin/lean/pmap.test.mjs +27 -0
  29. package/bin/lean/probe.mjs +1 -1
  30. package/bin/lean/prodwatch-triage.mjs +6 -0
  31. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  32. package/bin/lean/prodwatch.mjs +1 -3
  33. package/bin/lean/recovery.test.mjs +21 -4
  34. package/bin/lean/retry.mjs +11 -0
  35. package/bin/lean/retry.test.mjs +16 -0
  36. package/bin/lean/sched.mjs +25 -2
  37. package/bin/lean/sched.test.mjs +58 -1
  38. package/bin/lean/schedd.mjs +38 -5
  39. package/bin/lean/schedd.test.mjs +109 -0
  40. package/bin/lean/selftest-live.mjs +57 -32
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +8 -4
  43. package/bin/lean/selftest-nightly.test.mjs +24 -2
  44. package/bin/lean/selftest-static.mjs +18 -11
  45. package/bin/lean/selftest-static.test.mjs +8 -0
  46. package/bin/lean/signoff.mjs +14 -2
  47. package/bin/lean/signoff.test.mjs +45 -1
  48. package/bin/pool/agent.mjs +1 -1
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/runner.mjs +1 -1
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +9 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +3 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/ci-local/run.mjs +1 -1
  58. package/scripts/plan-validate-live.mjs +15 -8
@@ -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",
@@ -198,13 +199,13 @@ export const ENV_VALIDATE_TIMEOUT = "env.validate_timeout";
198
199
  * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
199
200
  * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
200
201
  */
201
- export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
202
+ export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED, OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_OMISSION, OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]);
202
203
  /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
203
204
  export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
204
205
 
205
206
  /**
206
207
  * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
207
- * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
208
+ * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `conformance.unimplemented` / `conformance.omission` / `conformance.unjustified_deferral` (#5035; regression, guard_broken, scope_creep and e2e_unverified stay terminal), `contract.invalid` after a mid-run issue
208
209
  * edit (`issueEdited`) or when every problem was an unmatched quote (`quoteMismatch`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
209
210
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
210
211
  */
@@ -8,7 +8,7 @@
8
8
  * RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
9
9
  * builder_tier/class/model/escalated 0|1, forge#4394)
10
10
  * SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
11
- * DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766)
11
+ * DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766; emitted once the engine is wired)
12
12
  * PRODWATCH_METRICS -> `prodwatch_metrics` (source, sample, invalid, fp_rate, fix_ineffective_rate, recurrence_rate, signal_to_issue/merge/verified_ms; forge#4837)
13
13
  * STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
14
14
  * lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
@@ -21,7 +21,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
21
21
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
22
22
  import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/events.mjs";
23
23
  import { readExperiment } from "./experiment.mjs";
24
- import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
24
+ import { readBranches, resolveLane, isDefaultLane, makeLaneCache } from "./lean/lane.mjs";
25
25
  import { parseVerify } from "./lean/verify.mjs";
26
26
  import { normalizeKindModes } from "./lean/conformance.mjs";
27
27
  import { viewIssue } from "./lean/gh-assoc.mjs";
@@ -1283,6 +1283,11 @@ async function addedEdges({ added, records, io, repo }) {
1283
1283
  * state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
1284
1284
  * when `viaDaemon(issues)` answers (non-null), else written here (batch.mjs `addIssues` skips issues already in the batch).
1285
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
+
1286
1291
  async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l) => console.log(l) }) {
1287
1292
  const batchMod = await import("./lean/batch.mjs");
1288
1293
  const b = batchMod.openBatch(root, id);
@@ -1308,11 +1313,13 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1308
1313
  let res = { added: [], skipped: [], requeued: [] };
1309
1314
  if (open.length) {
1310
1315
  res = (viaDaemon ? await viaDaemon(open) : null) ?? b.addIssues(open);
1311
- 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
1312
1317
  }
1313
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; }
1314
1320
  out.edges = await addedEdges({ added: out.added, records, io, repo }); // #4773
1315
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));
1316
1323
  if (out.edges.length) print(`batch ${id}: edges ${out.edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ")}`);
1317
1324
  return out;
1318
1325
  }
@@ -1320,6 +1327,26 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1320
1327
  /** #4592: a resume's release line, only when it freed something. */
1321
1328
  const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
1322
1329
 
1330
+ /**
1331
+ * #5031: the `baseWait` seam of `orchestrate` for issues admitted while a dependency's merge commit was not yet on their base:
1332
+ * a detail string until every awaited commit is an ancestor of origin/<base> (fetched at most every 30s per base), else null.
1333
+ */
1334
+ function baseWaitOf(waits, { io, leanIo, cwd, now = () => Date.now() }) {
1335
+ const fetched = new Map();
1336
+ return async (n) => {
1337
+ const l = waits.get(n);
1338
+ if (!l) return null;
1339
+ const missing = [];
1340
+ for (const w of l) {
1341
+ if (now() - (fetched.get(w.base) ?? -Infinity) > 30000) { fetched.set(w.base, now()); try { await leanIo.git(["fetch", "origin", w.base], { cwd }); } catch { /* offline: the local ref */ } }
1342
+ let on = false;
1343
+ for (const ref of [`origin/${w.base}`, w.base]) if (!on) on = await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", w.sha, ref]).then(() => true, () => false);
1344
+ if (!on) missing.push(w);
1345
+ }
1346
+ return missing.length ? `waiting for ${missing.map((w) => `#${w.dep} (${w.sha.slice(0, 10)})`).join(", ")} on ${missing[0].base}` : null;
1347
+ };
1348
+ }
1349
+
1323
1350
  /**
1324
1351
  * `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
1325
1352
  * when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
@@ -1371,7 +1398,9 @@ async function requeueBatch({ id, tokens, reason, root, io, viaDaemon = null, pr
1371
1398
  if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // no daemon: the next --resume reruns it
1372
1399
  }
1373
1400
  const out = { ok: true, batch: id, requeued: res.requeued ?? [], refused: [...(res.refused ?? []), ...refused] };
1401
+ if (res.hosted !== undefined) { out.hosted = !!res.hosted; out.reopened = !!res.reopened; }
1374
1402
  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(", ")}` : ""}`);
1403
+ if (out.requeued.length) print(hostedLine(id, res));
1375
1404
  return out;
1376
1405
  }
1377
1406
 
@@ -1560,12 +1589,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1560
1589
  await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
1561
1590
  }
1562
1591
  let batch, plan = null;
1592
+ const baseWaits = new Map(); // #5031: issue -> [{dep, sha, base}] merge commits not yet on its base at start
1563
1593
  if (resumeId) {
1564
1594
  batch = batchMod.openBatch(root, resumeId);
1565
1595
  batch.scrubPlan(); // #4787: a pre-fix plan.json keeps no secret; the lanes re-read it from the environment
1566
1596
  plan = batch.readPlan();
1567
1597
  if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
1568
1598
  argv = plan.argv;
1599
+ // #5031: a resume (operator or schedd restart) keeps the base waits recorded at start; a landed commit releases on the first check
1600
+ for (const [n, l] of Object.entries(plan.baseWaits ?? {})) if (Array.isArray(l) && l.length) baseWaits.set(Number(n), l.filter((w) => w && typeof w.sha === "string" && typeof w.base === "string"));
1569
1601
  }
1570
1602
  const issues = issuesOf(argv);
1571
1603
  if (!issues.length) throw new Error(ORCH_USAGE);
@@ -1593,16 +1625,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1593
1625
  const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
1594
1626
  const gateBranches = readBranches(cwd);
1595
1627
  const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1596
- const unmet = await unmetDependencies({ issues, repo, gh: io.gh, baseOf: async (n) => {
1628
+ const isAncestor = async (sha, b) => {
1629
+ for (const ref of [`origin/${b}`, b]) if (await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", sha, ref]).then(() => true, () => false)) return true;
1630
+ return false;
1631
+ };
1632
+ let unmet = await unmetDependencies({ issues, repo, gh: io.gh, isAncestor, baseOf: async (n) => {
1597
1633
  const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
1598
1634
  return r.ok ? r.lane : null;
1599
1635
  } });
1636
+ // #5031: a dependency merged but not yet on the base is admitted and held until its commit lands; any other unmet edge still refuses
1637
+ for (const u of unmet.filter((x) => x.kind === "commit")) {
1638
+ const list = baseWaits.get(u.issue) ?? []; list.push({ dep: u.dep, sha: u.sha, base: u.base }); baseWaits.set(u.issue, list);
1639
+ }
1640
+ for (const [n, l] of baseWaits) sink.line(`held: #${n} waits for ${l.map((w) => `#${w.dep} (${w.sha.slice(0, 10)}) on ${w.base}`).join(", ")}; admitted once the commit lands`);
1641
+ unmet = unmet.filter((x) => x.kind !== "commit");
1600
1642
  if (unmet.length) throw new Error(`parallel milestone start refused: ${unmet.map((u) => `#${u.issue} depends on #${u.dep} (${u.why}; base ${u.base})`).join("; ")}. Merge the dependencies first, or pass --force "<reason>".`);
1601
1643
  }
1602
1644
  const base = deps.batchId ?? batchMod.newBatchId();
1603
1645
  batch = batchMod.openBatch(root, base);
1604
1646
  for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
1605
- plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
1647
+ plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(baseWaits.size ? { baseWaits: Object.fromEntries(baseWaits) } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
1606
1648
  batch.writePlan(plan);
1607
1649
  batch.setParallel(parallel);
1608
1650
  } else {
@@ -1635,8 +1677,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1635
1677
  const prefilter = argv.includes("--no-prefilter") ? null
1636
1678
  : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
1637
1679
  const fetched = new Set();
1680
+ const laneCache = makeLaneCache(), milestoneOf = new Map(); // #4963: shared default-branch / ls-remote reads; the milestone readBody already fetched
1638
1681
  const laneOf = async (n) => {
1639
- const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
1682
+ const known = milestoneOf.get(n); milestoneOf.delete(n); // used once: a later retry re-reads the live milestone
1683
+ const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride, cache: laneCache, milestone: known });
1640
1684
  if (r.ok && prefilter && !fetched.has(r.lane)) { fetched.add(r.lane); try { await leanIo.git(["fetch", "origin", r.lane], { cwd }); } catch { /* offline: use the local ref */ } }
1641
1685
  return r;
1642
1686
  };
@@ -1877,7 +1921,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1877
1921
  trusted: (n) => trustedOf.get(n) === true,
1878
1922
  autoChildren,
1879
1923
  ...(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 }) }) } : {}),
1880
- 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,
1924
+ 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, baseWait: baseWaits.size ? baseWaitOf(baseWaits, { io, leanIo, cwd }) : null, ...lanes,
1881
1925
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1882
1926
  ...(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)) } : {}),
1883
1927
  ...(remote ? { remote } : {}),
@@ -1919,8 +1963,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1919
1963
  sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
1920
1964
  },
1921
1965
  readBody: maint || registry || hybrid
1922
- ? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author"); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
1923
- : async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author"); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
1966
+ ? async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,labels,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
1967
+ : async (n) => { const v = await viewIssue(leanIo.gh, repo, n, "body,comments,author,milestone"); if ("milestone" in v) milestoneOf.set(n, v.milestone?.title ?? null); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
1924
1968
  onUpdate: (r) => sink.row(r),
1925
1969
  });
1926
1970
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
@@ -1962,7 +2006,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1962
2006
  } catch { /* alerts never fail a batch */ }
1963
2007
  for (const l of report) sink.line(l);
1964
2008
  const ok = rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome));
1965
- try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
2009
+ // #4917: a batch with any pending issue is not finished: no report, so a resume or the daemon picks it up
2010
+ let unfinished = false;
2011
+ try { unfinished = batch.pendingIssues().length > 0 && !batch.readCancel(); } catch { unfinished = false; } // a cancel ends the batch for good
2012
+ if (!unfinished) try { batch.writeReport({ rows, crossLane, ok, reclaimed, report: report.join("\n") }); } catch { /* the report is a cache of the rows */ }
1966
2013
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
1967
2014
  }
1968
2015
 
@@ -2106,6 +2153,7 @@ export async function scheddFromCli(argv, deps = {}) {
2106
2153
  const pend = b.pendingAdds?.length ? `, pending adds: ${b.pendingAdds.map((n) => `#${n}`).join(" ")}` : "";
2107
2154
  console.log(` ${b.id}${b.running ? "" : " (not hosted)"}: ${age}${pend}`);
2108
2155
  }
2156
+ 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})`);
2109
2157
  return { running: true, ...st };
2110
2158
  }
2111
2159
  if (argv[0] === "watchdog") { // #4780: run by the schedd watchdog timer; signals a live daemon whose heartbeat went stale
@@ -2288,11 +2336,12 @@ export async function schedFromCli(argv, deps = {}) {
2288
2336
  const st = reg.list();
2289
2337
  const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
2290
2338
  console.log(deps.tick ?? health.tickText(health.stallCheck({ dir: schedMod.schedDir() })));
2291
- console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
2339
+ console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}, remote ${(st.remote ?? []).length}`);
2292
2340
  const ws = Object.entries(st.weights ?? {});
2293
2341
  console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
2294
2342
  for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
2295
2343
  for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
2344
+ for (const l of schedMod.remoteLines(st)) console.log(l); // #4964
2296
2345
  return st;
2297
2346
  }
2298
2347
  if (sub === "cap") {
package/bin/forgedock.mjs CHANGED
@@ -221,6 +221,19 @@ function detectInstallPaths() {
221
221
 
222
222
  const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
223
223
 
224
+ /**
225
+ * True when a link at ~/.claude/<kind>/<rel> points at a ForgeDock-owned
226
+ * source: the package file, or its copy in the persisted ~/.forge home
227
+ * (persistHome(), #1943, makes npm installs link there). Path-only, so links
228
+ * left dangling after ~/.forge is deleted still count as ours (#4962).
229
+ */
230
+ function isOwnedLinkTarget(dest, src, kind, rel) {
231
+ return (
232
+ dest === src ||
233
+ resolve(dest) === resolve(join(HOME, ".forge", kind, rel))
234
+ );
235
+ }
236
+
224
237
  // PIPELINE_SCRIPTS (the allowlist of universal pipeline-agent scripts linked
225
238
  // to ~/.claude/scripts/) is defined in journey.mjs — forge()'s
226
239
  // linkPipelineScripts() step is what writes them there (forge#1885,
@@ -1183,7 +1196,7 @@ async function uninstall() {
1183
1196
  const stats = await lstat(target);
1184
1197
  if (stats.isSymbolicLink()) {
1185
1198
  const current = await readlink(target);
1186
- if (current === file) {
1199
+ if (isOwnedLinkTarget(current, file, "commands", rel)) {
1187
1200
  await unlink(target);
1188
1201
  console.log(` ${RED}Removed${RESET}: ${rel}`);
1189
1202
  removed++;
@@ -1259,7 +1272,7 @@ async function uninstall() {
1259
1272
  const stats = await lstat(target);
1260
1273
  if (stats.isSymbolicLink()) {
1261
1274
  const current = await readlink(target);
1262
- if (current === file) {
1275
+ if (isOwnedLinkTarget(current, file, "scripts", rel)) {
1263
1276
  await unlink(target);
1264
1277
  console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
1265
1278
  scriptsRemoved++;
@@ -2507,10 +2520,16 @@ async function doctor(fix = false) {
2507
2520
  const lstats = await lstat(tgt);
2508
2521
  if (lstats.isSymbolicLink()) {
2509
2522
  const dest = await readlink(tgt);
2510
- if (dest !== src) {
2523
+ if (dest !== src && !isOwnedLinkTarget(dest, src, "commands", rel)) {
2511
2524
  broken++;
2512
2525
  brokenLinks.push(rel);
2513
2526
  symlinkOk = false;
2527
+ } else if (dest !== src && readFileSync(tgt, "utf-8") !== readFileSync(src, "utf-8")) {
2528
+ // Linked into the persisted ~/.forge home: valid only while
2529
+ // that copy matches the package source (#4962).
2530
+ broken++;
2531
+ brokenLinks.push(`${rel} (stale ~/.forge copy)`);
2532
+ symlinkOk = false;
2514
2533
  } else {
2515
2534
  try {
2516
2535
  // readlink() only validates the stored target. Opening the
@@ -3111,34 +3130,41 @@ async function doctor(fix = false) {
3111
3130
  // server presence is orthogonal to the core ForgeDock install — but missing
3112
3131
  // it causes /qa-sweep to silently fail mid-sweep.
3113
3132
  {
3133
+ // Claude Code registers servers in ~/.claude.json (user + per-project) and
3134
+ // project .mcp.json; the legacy mcp_servers.json is still honoured (#4962).
3114
3135
  const mcpServersPath = join(GLOBAL_CLAUDE_DIR, "mcp_servers.json");
3115
3136
  let playwrightFound = false;
3116
3137
  let fileReadable = false;
3117
3138
  let hasMcpServers = false;
3118
-
3119
- try {
3120
- const mcpRaw = readFileSync(mcpServersPath, "utf-8");
3121
- const mcpConfig = JSON.parse(mcpRaw);
3122
- // Read + parse succeeded — the file exists and contains valid JSON.
3139
+ const readJson = (p) => {
3140
+ try {
3141
+ return JSON.parse(readFileSync(p, "utf-8"));
3142
+ } catch {
3143
+ return null;
3144
+ }
3145
+ };
3146
+ const userCfg = readJson(join(HOME, ".claude.json"));
3147
+ const sources = [
3148
+ readJson(mcpServersPath),
3149
+ userCfg,
3150
+ userCfg?.projects?.[process.cwd()],
3151
+ readJson(join(process.cwd(), ".mcp.json")),
3152
+ ];
3153
+ for (const cfg of sources) {
3154
+ if (!cfg || typeof cfg !== "object") continue;
3123
3155
  fileReadable = true;
3124
- const servers = mcpConfig?.mcpServers;
3125
- if (servers && typeof servers === "object") {
3126
- hasMcpServers = true;
3127
- // Search for a registered server whose name or command references playwright
3128
- for (const [name, entry] of Object.entries(servers)) {
3129
- const nameLower = name.toLowerCase();
3130
- const cmdStr = [
3131
- entry?.command ?? "",
3132
- ...(Array.isArray(entry?.args) ? entry.args : []),
3133
- ].join(" ").toLowerCase();
3134
- if (nameLower.includes("playwright") || cmdStr.includes("playwright")) {
3135
- playwrightFound = true;
3136
- break;
3137
- }
3156
+ const servers = cfg.mcpServers;
3157
+ if (!servers || typeof servers !== "object") continue;
3158
+ hasMcpServers = true;
3159
+ for (const [name, entry] of Object.entries(servers)) {
3160
+ const cmdStr = [
3161
+ entry?.command ?? "",
3162
+ ...(Array.isArray(entry?.args) ? entry.args : []),
3163
+ ].join(" ").toLowerCase();
3164
+ if (name.toLowerCase().includes("playwright") || cmdStr.includes("playwright")) {
3165
+ playwrightFound = true;
3138
3166
  }
3139
3167
  }
3140
- } catch {
3141
- // File absent or unreadable — treat as no MCP servers configured
3142
3168
  }
3143
3169
 
3144
3170
  if (!fileReadable) {
package/bin/gc.mjs CHANGED
@@ -600,7 +600,9 @@ export function hygieneCheck(plan, { applied = null, ttlMs = plan?.ttlMs ?? hygi
600
600
  if (owned.length) parts.push(`${owned.length} ${t}${owned.length === 1 ? "" : "s"} ${fmtSize(owned.reduce((s, i) => s + (i.sizeBytes ?? 0), 0))}`);
601
601
  }
602
602
  const reclaimable = items.filter((i) => i.owned && i.action === "reclaim").length;
603
- const stale = items.filter((i) => !i.owned && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
603
+ // Unrelated docker projects (compose etc.) are not ours to nag about (#4962): only ForgeDock-named docker resources count.
604
+ const forgeNamed = (i) => (i.type !== "container" && i.type !== "volume") || /^(forgedock|forge-|fdrsbx)/i.test(String(i.id));
605
+ const stale = items.filter((i) => !i.owned && forgeNamed(i) && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
604
606
  const bits = [`owned: ${parts.length ? parts.join(", ") : "none"}`];
605
607
  if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}${applied.deferred?.length ? ` (${applied.deferred.length} deferred)` : ""}`);
606
608
  else bits.push(`${reclaimable} reclaimable`);
@@ -602,7 +602,7 @@ async function main() {
602
602
  if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
603
603
 
604
604
  // --- Rule 1: PR branch target validation ---
605
- const prViolation = checkPrTarget(command);
605
+ const prViolation = await checkPrTarget(command);
606
606
  if (prViolation) {
607
607
  process.stderr.write(prViolation);
608
608
  process.exit(2);
@@ -749,6 +749,51 @@ async function isForgeDockManagedCwd() {
749
749
  // Rule 1: PR branch target validation
750
750
  // ---------------------------------------------------------------------------
751
751
 
752
+ /**
753
+ * Resolve the `owner/repo` a `gh pr create` command targets: `-R`/`--repo`,
754
+ * else the cwd's `origin` remote. Lower-cased; null when unresolvable.
755
+ *
756
+ * @param {string} command
757
+ * @returns {string|null}
758
+ */
759
+ function resolvePrTargetRepo(command) {
760
+ const slug = (v) => {
761
+ const m = String(v || "").trim().match(/([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
762
+ return m ? `${m[1]}/${m[2]}`.toLowerCase() : null;
763
+ };
764
+ const flagged = extractFlag(command, "--repo") || extractFlag(command, "-R");
765
+ if (flagged) return slug(flagged);
766
+ try {
767
+ const { execFileSync: exec } = _require("child_process");
768
+ return slug(exec("git", ["remote", "get-url", "origin"], {
769
+ encoding: "utf-8",
770
+ stdio: ["pipe", "pipe", "pipe"],
771
+ timeout: 5000,
772
+ }));
773
+ } catch {
774
+ return null;
775
+ }
776
+ }
777
+
778
+ /**
779
+ * `owner/repo` of the forge.yaml in cwd (project.owner / project.repo),
780
+ * lower-cased; null when absent or unreadable.
781
+ *
782
+ * @returns {Promise<string|null>}
783
+ */
784
+ async function configuredRepoSlug() {
785
+ try {
786
+ const raw = _require("fs").readFileSync(join(process.cwd(), "forge.yaml"), "utf-8");
787
+ const { parseForgeYaml } = await import(
788
+ pathToFileURL(join(FORGE_HOME, "bin", "forge-utils.mjs")).href
789
+ );
790
+ const { owner, repo } = parseForgeYaml(raw)?.project ?? {};
791
+ return owner && repo ? `${owner}/${repo}`.toLowerCase() : null;
792
+ } catch {
793
+ return null;
794
+ }
795
+ }
796
+
752
797
  /**
753
798
  * Check whether a gh pr create command targets a forbidden base branch.
754
799
  *
@@ -757,9 +802,9 @@ async function isForgeDockManagedCwd() {
757
802
  * main are still hard-blocked.
758
803
  *
759
804
  * @param {string} command
760
- * @returns {string|null} Error message to show, or null if allowed.
805
+ * @returns {Promise<string|null>} Error message to show, or null if allowed.
761
806
  */
762
- function checkPrTarget(command) {
807
+ async function checkPrTarget(command) {
763
808
  // Only check gh pr create commands.
764
809
  if (!/gh\s+pr\s+create/.test(command)) return null;
765
810
 
@@ -767,6 +812,12 @@ function checkPrTarget(command) {
767
812
  if (!base) return null; // no --base flag — gh will use the default
768
813
 
769
814
  if (FORBIDDEN_PR_BASES.includes(base.toLowerCase())) {
815
+ // The staging/milestone rule only governs the configured forge repo; a PR
816
+ // to a positively-identified other repo may legitimately target main.
817
+ const target = resolvePrTargetRepo(command);
818
+ const configured = await configuredRepoSlug();
819
+ if (target && configured && target !== configured) return null;
820
+
770
821
  // Allow staging → main (the deploy path, not a pipeline violation).
771
822
  const head = extractFlag(command, "--head") || extractFlag(command, "-H") || currentGitBranch();
772
823
  if (head && /^staging$/.test(head)) {
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.
@@ -4,6 +4,8 @@
4
4
  // Pure and deterministic: the child scan reads one open-issue list per pass; no LLM call.
5
5
  import { parentOf } from "./context.mjs";
6
6
  import { parseHelpFields } from "./selftest-static.mjs";
7
+ import { listAssociations } from "./gh-assoc.mjs";
8
+ import { isTrustedActor } from "../engine/phases.mjs";
7
9
 
8
10
  export const AUTO_MODES = ["off", "decomposed", "epic", "all"];
9
11
  export const DEFAULT_AUTO_MODE = "decomposed";
@@ -41,9 +43,11 @@ const labelsOf = (it) => nodesOf(it?.labels).map((l) => String(l?.name ?? l));
41
43
  * @param {Iterable<number>} roots the batch issues plus the `--epic N` epics
42
44
  * @param {Iterable<number>} skip issues the batch already holds
43
45
  * @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.
46
+ * an issue labelled `needs-human` is left out. #4982: so is a child whose author is not trusted (the `Blocked by` edge
47
+ * rule, `isTrustedActor`; a missing association fails closed), reported through `onSkip({number, reason})`.
48
+ * @param {(s:{number:number, reason:string})=>void} [onSkip]
45
49
  */
46
- export function selectChildren({ list, roots, skip = [] }) {
50
+ export function selectChildren({ list, roots, skip = [], onSkip }) {
47
51
  const rootSet = new Set(roots), skipSet = new Set(skip), open = new Set(), out = new Set();
48
52
  for (const it of list ?? []) if (Number.isSafeInteger(it?.number)) open.add(it.number);
49
53
  for (const it of list ?? []) {
@@ -54,10 +58,12 @@ export function selectChildren({ list, roots, skip = [] }) {
54
58
  if (p != null && rootSet.has(p)) out.add(n);
55
59
  }
56
60
  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);
61
+ const trusted = (it) => isTrustedActor({ association: it?.authorAssociation, type: it?.author?.is_bot ? "Bot" : "User", login: it?.author?.login });
62
+ return [...out].filter((n) => !skipSet.has(n) && !labelsOf(byNum.get(n)).includes("needs-human")).sort((a, b) => a - b)
63
+ .filter((n) => trusted(byNum.get(n)) || (onSkip?.({ number: n, reason: "untrusted author" }), false));
58
64
  }
59
65
 
60
- const REQUIRED_FIELDS = ["number", "body", "labels"], OPTIONAL_FIELDS = ["parent", "subIssues"];
66
+ const REQUIRED_FIELDS = ["number", "body", "labels", "author"], OPTIONAL_FIELDS = ["parent", "subIssues"];
61
67
 
62
68
  /** 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
69
  export async function hostListFields(gh) {
@@ -74,7 +80,9 @@ export async function readChildList({ gh, repo, limit = 1000, fields = null }) {
74
80
  const f = fields ?? (await hostListFields(gh));
75
81
  const raw = await gh(["issue", "list", "-R", repo, "--state", "open", "--limit", String(limit), "--json", f.join(",")]);
76
82
  const v = JSON.parse(raw);
77
- return Array.isArray(v) ? v : [];
83
+ if (!Array.isArray(v)) return [];
84
+ const assoc = await listAssociations(gh, repo); // #4982: not a `gh issue list` field (#4859); unreadable = every author untrusted
85
+ return v.map((it) => ({ ...it, authorAssociation: assoc.get(it?.number) }));
78
86
  }
79
87
 
80
88
  /**
@@ -26,12 +26,12 @@ describe("#4965 modes", () => {
26
26
  describe("#4965 selectChildren", () => {
27
27
  const list = [
28
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: [] },
29
+ { number: 2, body: "x\n\n**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
30
+ { number: 3, body: "", labels: [], parent: { number: 1 }, authorAssociation: "MEMBER" },
31
+ { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }], authorAssociation: "MEMBER" },
32
+ { number: 5, body: "", labels: [], authorAssociation: "MEMBER" },
33
+ { number: 6, body: "**Parent**: #77", labels: [], authorAssociation: "MEMBER" },
34
+ { number: 7, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
35
35
  ];
36
36
  it("finds children by Parent line, parent field and sub-issues; skips needs-human, closed sub-issues and unrelated", () => {
37
37
  assert.deepEqual(selectChildren({ list, roots: [1], skip: [1] }), [2, 3, 5, 7]);
@@ -42,8 +42,25 @@ describe("#4965 selectChildren", () => {
42
42
  it("an --epic root outside the batch yields its children", () => {
43
43
  assert.deepEqual(selectChildren({ list, roots: [77] }), [6]);
44
44
  });
45
+ it("#4982 selectChildren drops children of an untrusted author and reports each", () => {
46
+ const l = [
47
+ { number: 1, body: "", labels: [], authorAssociation: "OWNER", subIssues: { nodes: [{ number: 5 }] } },
48
+ { number: 2, body: "**Parent**: #1", labels: [], authorAssociation: "NONE", author: { login: "eve" } },
49
+ { number: 3, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" },
50
+ { number: 4, body: "**Parent**: #1", labels: [], author: { login: "app", is_bot: true } },
51
+ { number: 5, body: "", labels: [] },
52
+ { number: 6, body: "", labels: [], parent: { number: 1 } },
53
+ ];
54
+ const skipped = [];
55
+ assert.deepEqual(selectChildren({ list: l, roots: [1], skip: [1], onSkip: (s) => skipped.push(s) }), [3, 4]);
56
+ assert.deepEqual(skipped, [2, 5, 6].map((number) => ({ number, reason: "untrusted author" })));
57
+ });
58
+ it("#4982 readChildList merges the REST author association per issue", async () => {
59
+ const gh = async (a) => a[0] === "api" ? "1\tMEMBER\n" : a.includes("--help") ? "JSON FIELDS\n number, body, labels, author\n\nEXAMPLES\n" : "[{\"number\":1},{\"number\":2}]";
60
+ assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1, authorAssociation: "MEMBER" }, { number: 2, authorAssociation: undefined }]);
61
+ });
45
62
  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]);
63
+ assert.deepEqual(selectChildren({ list: [...list, { number: 8, body: "**Parent**: #1", labels: [], authorAssociation: "MEMBER" }], roots: [1], skip: [1, 2, 3, 5, 7] }), [8]);
47
64
  });
48
65
  });
49
66
 
@@ -59,17 +76,17 @@ describe("#4965 readers", () => {
59
76
  };
60
77
  it("readChildList asks the host gh for its fields, then makes one list call", async () => {
61
78
  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 }]);
79
+ const r = await readChildList({ gh: fakeGh(["number", "body", "labels", "author", "parent", "subIssues", "title"], calls), repo: "o/r" });
80
+ assert.equal(calls.filter((a) => a.includes("--json")).length, 1);
81
+ assert.equal(calls.findLast((a) => a.includes("--json"))[calls.findLast((a) => a.includes("--json")).indexOf("--json") + 1], "number,body,labels,author,parent,subIssues");
82
+ assert.deepEqual(r, [{ number: 1, authorAssociation: undefined }]);
66
83
  });
67
84
  it("a host gh without parent/subIssues still lists, with the fields it has (children found by Parent line)", async () => {
68
85
  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");
86
+ const gh = fakeGh(["number", "body", "labels", "author", "title"], calls);
87
+ assert.deepEqual(await hostListFields(gh), ["number", "body", "labels", "author"]);
88
+ assert.deepEqual(await readChildList({ gh, repo: "o/r" }), [{ number: 1, authorAssociation: undefined }]);
89
+ assert.equal(calls.findLast((a) => a.includes("--json"))[calls.findLast((a) => a.includes("--json")).indexOf("--json") + 1], "number,body,labels,author");
73
90
  });
74
91
  it("liveElsewhere holds the unsettled issues of other live batches of the repo only", () => {
75
92
  const mk = (o) => ({ readReport: () => o.report ?? null, readPlan: () => ({ repo: o.repo ?? "o/r" }), store: { load: () => ({ order: o.order, results: o.results ?? {} }) } });
@@ -109,11 +126,12 @@ appendFileSync(${JSON.stringify(launches)}, a[1] + "\\n"); writeFileSync(rf, JSO
109
126
  b.setParallel(2);
110
127
  const lists = [];
111
128
  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: [] }]); }
129
+ if (a[0] === "issue" && a[1] === "list" && a.includes("--help")) return "JSON FIELDS\n number, body, labels, author, parent, subIssues\n\nEXAMPLES\n";
130
+ if (a[0] === "issue" && a[1] === "list" && a.includes("--state") && a.includes("number,body,labels,author,parent,subIssues")) { lists.push(a); return JSON.stringify([{ number: 9, body: "", labels: [], authorAssociation: "MEMBER" }, { number: 51, body: "**Parent**: #9", labels: [], authorAssociation: "MEMBER" }]); }
131
+ if (a[0] === "api" && /issues\?state=open/.test(a[1])) return "9\tMEMBER\n51\tMEMBER\n";
114
132
  if (a.includes("milestone")) return JSON.stringify({ milestone: null });
115
133
  if (a[0] === "repo") return "main\n";
116
- return JSON.stringify({ body: "", state: "OPEN", labels: [] });
134
+ return JSON.stringify({ body: "", state: "OPEN", labels: [], authorAssociation: "MEMBER" });
117
135
  };
118
136
  const deps = { batchRoot: root, allowTmpfs: true, laneScript: stub, pollMs: 20, orchestration: { breaker: null, maxDeferrals: 3 }, io: { gh: async () => "o/r\n" },
119
137
  leanIo: { gh, git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") } };
@@ -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). */
@@ -265,7 +267,7 @@ export function launchLane({ batch, issue, args = [], bin = process.execPath, sc
265
267
  const out = openSync(batch.logFile(issue), "a", 0o600);
266
268
  try {
267
269
  // FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
268
- 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] });
269
271
  child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
270
272
  child.unref?.();
271
273
  return { pid: child.pid };