forgedock 1.43.1 → 1.45.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 (57) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +50 -11
  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/lean/autochildren.mjs +13 -5
  8. package/bin/lean/autochildren.test.mjs +36 -18
  9. package/bin/lean/batch.test.mjs +46 -0
  10. package/bin/lean/conformance.mjs +107 -16
  11. package/bin/lean/conformance.test.mjs +126 -13
  12. package/bin/lean/decision-engine.mjs +1 -1
  13. package/bin/lean/decision-engine.test.mjs +11 -0
  14. package/bin/lean/execution.mjs +7 -4
  15. package/bin/lean/execution.test.mjs +21 -0
  16. package/bin/lean/gatewatch.test.mjs +5 -5
  17. package/bin/lean/lane.mjs +22 -4
  18. package/bin/lean/lane.test.mjs +33 -1
  19. package/bin/lean/lean.mjs +49 -16
  20. package/bin/lean/lean.test.mjs +66 -0
  21. package/bin/lean/orchestrate-blocked.test.mjs +35 -0
  22. package/bin/lean/orchestrate-children.test.mjs +16 -4
  23. package/bin/lean/orchestrate.mjs +61 -13
  24. package/bin/lean/pmap.mjs +17 -0
  25. package/bin/lean/pmap.test.mjs +27 -0
  26. package/bin/lean/postdeploy-check.test.mjs +3 -1
  27. package/bin/lean/preflight.mjs +8 -3
  28. package/bin/lean/preflight.test.mjs +13 -1
  29. package/bin/lean/prodwatch-triage.mjs +6 -0
  30. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  31. package/bin/lean/prodwatch.mjs +1 -3
  32. package/bin/lean/prompts.mjs +1 -1
  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/sandbox-provider.contract.test.mjs +15 -4
  37. package/bin/lean/sandbox-remote.mjs +19 -7
  38. package/bin/lean/sched.mjs +25 -2
  39. package/bin/lean/sched.test.mjs +58 -1
  40. package/bin/lean/selftest-live.mjs +56 -31
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +7 -3
  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 +16 -3
  47. package/bin/lean/signoff.test.mjs +52 -1
  48. package/bin/pool/agent.mjs +3 -3
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/pool/router.mjs +17 -6
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +10 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +2 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/plan-validate-live.mjs +15 -8
@@ -199,13 +199,13 @@ export const ENV_VALIDATE_TIMEOUT = "env.validate_timeout";
199
199
  * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
200
200
  * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
201
201
  */
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]);
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]);
203
203
  /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
204
204
  export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
205
205
 
206
206
  /**
207
207
  * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
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`, `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
209
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
210
210
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
211
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";
@@ -525,10 +525,12 @@ export function readEnvConfig(cwd = process.cwd()) {
525
525
  const e = y?.env ?? {};
526
526
  const tr = y?.verification?.test_runners; // #4857: {path prefix: runner command prefix} for validate commands
527
527
  const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
528
+ const rm = readYamlMap(cwd, "validate", "recipes"); // #5020: y?.validate?.recipes, a block map {match regex: replacement} applied to frozen validate and repro commands
529
+ const recipes = Object.entries(rm ?? {}).map(([match, replace]) => ({ match, replace }));
528
530
  const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
529
531
  const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
530
532
  return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
531
- ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}) };
533
+ ...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}) };
532
534
  } catch { return {}; }
533
535
  }
534
536
  export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
@@ -1327,6 +1329,26 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1327
1329
  /** #4592: a resume's release line, only when it freed something. */
1328
1330
  const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
1329
1331
 
1332
+ /**
1333
+ * #5031: the `baseWait` seam of `orchestrate` for issues admitted while a dependency's merge commit was not yet on their base:
1334
+ * a detail string until every awaited commit is an ancestor of origin/<base> (fetched at most every 30s per base), else null.
1335
+ */
1336
+ function baseWaitOf(waits, { io, leanIo, cwd, now = () => Date.now() }) {
1337
+ const fetched = new Map();
1338
+ return async (n) => {
1339
+ const l = waits.get(n);
1340
+ if (!l) return null;
1341
+ const missing = [];
1342
+ for (const w of l) {
1343
+ 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 */ } }
1344
+ let on = false;
1345
+ 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);
1346
+ if (!on) missing.push(w);
1347
+ }
1348
+ return missing.length ? `waiting for ${missing.map((w) => `#${w.dep} (${w.sha.slice(0, 10)})`).join(", ")} on ${missing[0].base}` : null;
1349
+ };
1350
+ }
1351
+
1330
1352
  /**
1331
1353
  * `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
1332
1354
  * when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
@@ -1569,12 +1591,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1569
1591
  await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
1570
1592
  }
1571
1593
  let batch, plan = null;
1594
+ const baseWaits = new Map(); // #5031: issue -> [{dep, sha, base}] merge commits not yet on its base at start
1572
1595
  if (resumeId) {
1573
1596
  batch = batchMod.openBatch(root, resumeId);
1574
1597
  batch.scrubPlan(); // #4787: a pre-fix plan.json keeps no secret; the lanes re-read it from the environment
1575
1598
  plan = batch.readPlan();
1576
1599
  if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
1577
1600
  argv = plan.argv;
1601
+ // #5031: a resume (operator or schedd restart) keeps the base waits recorded at start; a landed commit releases on the first check
1602
+ 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"));
1578
1603
  }
1579
1604
  const issues = issuesOf(argv);
1580
1605
  if (!issues.length) throw new Error(ORCH_USAGE);
@@ -1602,16 +1627,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1602
1627
  const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
1603
1628
  const gateBranches = readBranches(cwd);
1604
1629
  const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1605
- const unmet = await unmetDependencies({ issues, repo, gh: io.gh, baseOf: async (n) => {
1630
+ const isAncestor = async (sha, b) => {
1631
+ for (const ref of [`origin/${b}`, b]) if (await io.git?.(["-C", cwd, "merge-base", "--is-ancestor", sha, ref]).then(() => true, () => false)) return true;
1632
+ return false;
1633
+ };
1634
+ let unmet = await unmetDependencies({ issues, repo, gh: io.gh, isAncestor, baseOf: async (n) => {
1606
1635
  const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
1607
1636
  return r.ok ? r.lane : null;
1608
1637
  } });
1638
+ // #5031: a dependency merged but not yet on the base is admitted and held until its commit lands; any other unmet edge still refuses
1639
+ for (const u of unmet.filter((x) => x.kind === "commit")) {
1640
+ const list = baseWaits.get(u.issue) ?? []; list.push({ dep: u.dep, sha: u.sha, base: u.base }); baseWaits.set(u.issue, list);
1641
+ }
1642
+ 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`);
1643
+ unmet = unmet.filter((x) => x.kind !== "commit");
1609
1644
  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>".`);
1610
1645
  }
1611
1646
  const base = deps.batchId ?? batchMod.newBatchId();
1612
1647
  batch = batchMod.openBatch(root, base);
1613
1648
  for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
1614
- plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString(), ...(force ? { forced: force } : {}), ...(daemon ? { daemon: true, env: kept } : {}) };
1649
+ 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 } : {}) };
1615
1650
  batch.writePlan(plan);
1616
1651
  batch.setParallel(parallel);
1617
1652
  } else {
@@ -1644,8 +1679,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1644
1679
  const prefilter = argv.includes("--no-prefilter") ? null
1645
1680
  : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
1646
1681
  const fetched = new Set();
1682
+ const laneCache = makeLaneCache(), milestoneOf = new Map(); // #4963: shared default-branch / ls-remote reads; the milestone readBody already fetched
1647
1683
  const laneOf = async (n) => {
1648
- const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
1684
+ const known = milestoneOf.get(n); milestoneOf.delete(n); // used once: a later retry re-reads the live milestone
1685
+ const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride, cache: laneCache, milestone: known });
1649
1686
  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 */ } }
1650
1687
  return r;
1651
1688
  };
@@ -1727,12 +1764,13 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1727
1764
  return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
1728
1765
  })();
1729
1766
  // #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
1730
- let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity; // #4547: online = agents alive, null unknown
1767
+ let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity, capFailed = false; // #5048: capFailed = last probe rejected // #4547: online = agents alive, null unknown
1731
1768
  const capacity = () => {
1769
+ if (capFailed) throw new Error("capacity probe failed"); // #5048: until the next successful sample, so every caller sees it (release review CONC-1)
1732
1770
  const t = deps.now ? deps.now() : Date.now();
1733
1771
  if (typeof provider.capacity === "function" && t - capLast >= (deps.pollMs ?? 5000)) {
1734
1772
  capLast = t;
1735
- Promise.resolve().then(() => provider.capacity()).then((c) => { capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capAt++; });
1773
+ Promise.resolve().then(() => provider.capacity()).then((c) => { capFailed = false; capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capFailed = true; capAt++; });
1736
1774
  }
1737
1775
  return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1738
1776
  };
@@ -1886,7 +1924,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1886
1924
  trusted: (n) => trustedOf.get(n) === true,
1887
1925
  autoChildren,
1888
1926
  ...(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 }) }) } : {}),
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,
1927
+ 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,
1890
1928
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1891
1929
  ...(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)) } : {}),
1892
1930
  ...(remote ? { remote } : {}),
@@ -1928,8 +1966,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1928
1966
  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`);
1929
1967
  },
1930
1968
  readBody: maint || registry || hybrid
1931
- ? 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; }
1932
- : 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
1969
+ ? 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; }
1970
+ : 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
1933
1971
  onUpdate: (r) => sink.row(r),
1934
1972
  });
1935
1973
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
@@ -2301,11 +2339,12 @@ export async function schedFromCli(argv, deps = {}) {
2301
2339
  const st = reg.list();
2302
2340
  const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
2303
2341
  console.log(deps.tick ?? health.tickText(health.stallCheck({ dir: schedMod.schedDir() })));
2304
- console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
2342
+ 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}`);
2305
2343
  const ws = Object.entries(st.weights ?? {});
2306
2344
  console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
2307
2345
  for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
2308
2346
  for (const e of st.queue) console.log(` queued ${who(e)} dispatcher ${e.pid}`);
2347
+ for (const l of schedMod.remoteLines(st)) console.log(l); // #4964
2309
2348
  return st;
2310
2349
  }
2311
2350
  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)) {
@@ -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" : "") } };
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { resolveLane, makeLaneCache } from "./lane.mjs";
9
10
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
11
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
12
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
@@ -1194,4 +1195,49 @@ describe("batch plan secrets (#4787)", () => {
1194
1195
  assert.equal(statSync(join(b.dir, "plan.json")).mode & 0o777, 0o600);
1195
1196
  assert.deepEqual(b.scrubPlan(), []);
1196
1197
  });
1198
+
1199
+ it("setup reads run with bounded concurrency, keep order and deps, and write an early setup state (#4963)", async () => {
1200
+ const saves = []; let live = 0, peak = 0;
1201
+ const bodies = { 1: "Blocked by #2", 2: "", 3: "Depends on #1", 4: "", 5: "" };
1202
+ const store = { load: () => null, save: (s) => saves.push(JSON.parse(JSON.stringify(s))) };
1203
+ await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 1, store, setupConcurrency: 2, trusted: () => true,
1204
+ readBody: async (n) => { live++; peak = Math.max(peak, live); await new Promise((r) => setTimeout(r, 15 - n * 2)); live--; return bodies[n]; },
1205
+ runOne: async () => ({ terminalReason: "merged" }), now: () => Date.now(), sleep: async () => {}, pollMs: 1 });
1206
+ assert.equal(peak, 2);
1207
+ const setupSaves = saves.filter((s) => s.phase === "setup");
1208
+ assert.deepEqual(setupSaves[0].setup, { read: 0, total: 5 });
1209
+ assert.equal(setupSaves.at(-1).setup.read, 5);
1210
+ assert.deepEqual(setupSaves[0].order, [1, 2, 3, 4, 5]);
1211
+ const last = saves.at(-1);
1212
+ assert.equal(last.phase, undefined);
1213
+ assert.deepEqual(last.deps, { 1: [2], 2: [], 3: [1], 4: [], 5: [] });
1214
+ });
1215
+
1216
+ it("a setup save never records an issue as read before its lane is resolved (#4963 resume safety)", async () => {
1217
+ const saves = [];
1218
+ const store = { load: () => null, save: (s) => saves.push(JSON.parse(JSON.stringify(s))) };
1219
+ await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, store, setupConcurrency: 4, trusted: () => true,
1220
+ readBody: async () => "",
1221
+ resolveLane: async (n) => { await new Promise((r) => setTimeout(r, 5 + n * 10)); return { ok: true, lane: "staging" }; },
1222
+ runOne: async () => ({ terminalReason: "merged" }), now: () => Date.now(), sleep: async () => {}, pollMs: 1 });
1223
+ for (const s of saves.filter((x) => x.phase === "setup")) for (const n of Object.keys(s.deps ?? {})) assert.ok(s.lanes?.[n], `setup save lists #${n} in deps without its lane`);
1224
+ });
1225
+
1226
+ it("5 issues with slow gh/git calls reach their first admitted lane in a fraction of the serial setup time (#4963)", async () => {
1227
+ const LAT = 30; // ms per gh/git call, standing in for a loaded host
1228
+ const wait = () => new Promise((r) => setTimeout(r, LAT));
1229
+ const firstLane = async (setupConcurrency, cache) => {
1230
+ const io = { gh: async (a) => { await wait(); return a[0] === "repo" ? "main\n" : JSON.stringify({ milestone: null }); }, git: async () => { await wait(); return "abc\trefs/heads/staging\n"; } };
1231
+ const t0 = Date.now(); let first = null;
1232
+ await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 5, setupConcurrency, trusted: () => true, now: () => Date.now(), sleep: async () => {}, pollMs: 1,
1233
+ readBody: async () => { await wait(); return ""; },
1234
+ resolveLane: (n) => resolveLane({ issue: n, repo: "o/r", io, cwd: "/r", ...(cache ? { cache: makeLaneCache(), milestone: null } : {}) }),
1235
+ runOne: async () => { first ??= Date.now() - t0; return { terminalReason: "merged" }; } });
1236
+ return first;
1237
+ };
1238
+ const serial = await firstLane(1, false), fast = await firstLane(6, true);
1239
+ // Serial: 5 issues x 4 calls x LAT = 600 ms; bounded + shared lookups: about 3 rounds = 90 ms. The 2-minute target is a 5x cut or better here.
1240
+ assert.ok(fast < serial / 3, `fast ${fast} ms vs serial ${serial} ms`);
1241
+ assert.ok(fast < 10 * LAT, `first lane after ${fast} ms`);
1242
+ });
1197
1243
  });