forgedock 1.43.1 → 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 (47) 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 +44 -8
  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 +55 -14
  11. package/bin/lean/conformance.test.mjs +69 -10
  12. package/bin/lean/decision-engine.mjs +1 -1
  13. package/bin/lean/decision-engine.test.mjs +11 -0
  14. package/bin/lean/gatewatch.test.mjs +5 -5
  15. package/bin/lean/lane.mjs +22 -4
  16. package/bin/lean/lane.test.mjs +33 -1
  17. package/bin/lean/lean.mjs +22 -6
  18. package/bin/lean/lean.test.mjs +31 -0
  19. package/bin/lean/orchestrate-blocked.test.mjs +12 -0
  20. package/bin/lean/orchestrate-children.test.mjs +16 -4
  21. package/bin/lean/orchestrate.mjs +58 -11
  22. package/bin/lean/pmap.mjs +17 -0
  23. package/bin/lean/pmap.test.mjs +27 -0
  24. package/bin/lean/prodwatch-triage.mjs +6 -0
  25. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  26. package/bin/lean/prodwatch.mjs +1 -3
  27. package/bin/lean/recovery.test.mjs +21 -4
  28. package/bin/lean/retry.mjs +11 -0
  29. package/bin/lean/retry.test.mjs +16 -0
  30. package/bin/lean/sched.mjs +25 -2
  31. package/bin/lean/sched.test.mjs +58 -1
  32. package/bin/lean/selftest-live.mjs +56 -31
  33. package/bin/lean/selftest-live.test.mjs +48 -6
  34. package/bin/lean/selftest-nightly.mjs +7 -3
  35. package/bin/lean/selftest-nightly.test.mjs +24 -2
  36. package/bin/lean/selftest-static.mjs +18 -11
  37. package/bin/lean/selftest-static.test.mjs +8 -0
  38. package/bin/lean/signoff.mjs +5 -3
  39. package/bin/lean/signoff.test.mjs +21 -0
  40. package/bin/pool/cli.mjs +29 -1
  41. package/commands/orchestrate.md +5 -2
  42. package/docs/CONFIG.md +9 -4
  43. package/docs/DECISIONS.md +5 -1
  44. package/docs/OUTCOME-CODES.md +2 -2
  45. package/docs/TELEMETRY.md +1 -1
  46. package/package.json +1 -1
  47. 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";
@@ -1327,6 +1327,26 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
1327
1327
  /** #4592: a resume's release line, only when it freed something. */
1328
1328
  const onlyReleased = (l, out = (t) => console.log(t)) => { if (!l.endsWith("(none)")) out(l); };
1329
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
+
1330
1350
  /**
1331
1351
  * `--hold <id> N...` / `--release <id> N...` (#4592): the batch's `hold` file, through the daemon (`viaDaemon(op, issues)`, null
1332
1352
  * when none runs) or directly. A held issue is not admitted (a running lane is not stopped); `--release` (or a `--resume` that
@@ -1569,12 +1589,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1569
1589
  await holdBatch({ op: "release", id: resumeId, tokens: resumeExtra, root, print: (l) => onlyReleased(l, (t) => sink.line(t)) }); // #4592
1570
1590
  }
1571
1591
  let batch, plan = null;
1592
+ const baseWaits = new Map(); // #5031: issue -> [{dep, sha, base}] merge commits not yet on its base at start
1572
1593
  if (resumeId) {
1573
1594
  batch = batchMod.openBatch(root, resumeId);
1574
1595
  batch.scrubPlan(); // #4787: a pre-fix plan.json keeps no secret; the lanes re-read it from the environment
1575
1596
  plan = batch.readPlan();
1576
1597
  if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
1577
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"));
1578
1601
  }
1579
1602
  const issues = issuesOf(argv);
1580
1603
  if (!issues.length) throw new Error(ORCH_USAGE);
@@ -1602,16 +1625,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1602
1625
  const { unmetDependencies } = await import("../scripts/plan-validate-live.mjs");
1603
1626
  const gateBranches = readBranches(cwd);
1604
1627
  const gateIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1605
- 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) => {
1606
1633
  const r = await resolveLane({ issue: n, repo, io: gateIo, cwd, branches: gateBranches, requested: lane, override: laneOverride });
1607
1634
  return r.ok ? r.lane : null;
1608
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");
1609
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>".`);
1610
1643
  }
1611
1644
  const base = deps.batchId ?? batchMod.newBatchId();
1612
1645
  batch = batchMod.openBatch(root, base);
1613
1646
  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 } : {}) };
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 } : {}) };
1615
1648
  batch.writePlan(plan);
1616
1649
  batch.setParallel(parallel);
1617
1650
  } else {
@@ -1644,8 +1677,10 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1644
1677
  const prefilter = argv.includes("--no-prefilter") ? null
1645
1678
  : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
1646
1679
  const fetched = new Set();
1680
+ const laneCache = makeLaneCache(), milestoneOf = new Map(); // #4963: shared default-branch / ls-remote reads; the milestone readBody already fetched
1647
1681
  const laneOf = async (n) => {
1648
- 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 });
1649
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 */ } }
1650
1685
  return r;
1651
1686
  };
@@ -1886,7 +1921,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1886
1921
  trusted: (n) => trustedOf.get(n) === true,
1887
1922
  autoChildren,
1888
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 }) }) } : {}),
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,
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,
1890
1925
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
1891
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)) } : {}),
1892
1927
  ...(remote ? { remote } : {}),
@@ -1928,8 +1963,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1928
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`);
1929
1964
  },
1930
1965
  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
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
1933
1968
  onUpdate: (r) => sink.row(r),
1934
1969
  });
1935
1970
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
@@ -2301,11 +2336,12 @@ export async function schedFromCli(argv, deps = {}) {
2301
2336
  const st = reg.list();
2302
2337
  const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
2303
2338
  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}`);
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}`);
2305
2340
  const ws = Object.entries(st.weights ?? {});
2306
2341
  console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
2307
2342
  for (const e of st.slots) console.log(` running ${who(e)} dispatcher ${e.pid}${e.lanePid ? ` lane ${e.lanePid}` : ""}`);
2308
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
2309
2345
  return st;
2310
2346
  }
2311
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)) {
@@ -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
  });