forgedock 1.31.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -309,19 +309,40 @@ async function readIssueRecord(n, io) {
309
309
  }
310
310
 
311
311
  // A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case), optional colon
312
- // inside or after the bold, then one or more `#N` separated by commas, "and", "&" or spaces (#4121).
313
- const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b(?:[ \t]*(?:,|&|\band\b)?[ \t]*#\d+\b)*)/gim;
312
+ // inside or after the bold, then a list of `#N` (#4121, #4506). The list is `#N` items joined by `,` `&` `and` `or`
313
+ // (each item may carry a parenthetical note, whose own `#N` are prose); any other text ends it, so a trailing
314
+ // "see #99", "Fixes #12", a code span or a sentence never adds an edge (release review #4515).
315
+ const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b[^\n]*)/gim;
316
+ const DEP_ITEM_RE = /^(?<![\w/])#(\d+)\b(?:[ \t]*\([^()\n]*\))?/;
317
+ const DEP_JOIN_RE = /^[ \t]*(?:,[ \t]*(?:(?:and|or)\b[ \t]*)?|&[ \t]*|(?:and|or)\b[ \t]*)/i;
318
+
319
+ /** The `#N` list at the start of a declaration's tail: items and joiners only, stopping at the first other text. */
320
+ function dependsOnList(tail) {
321
+ const out = [];
322
+ let rest = tail;
323
+ for (;;) {
324
+ const item = DEP_ITEM_RE.exec(rest);
325
+ if (!item) break;
326
+ out.push(Number(item[1]));
327
+ rest = rest.slice(item[0].length);
328
+ const join = DEP_JOIN_RE.exec(rest);
329
+ if (!join || !/^#\d/.test(rest.slice(join[0].length))) break;
330
+ rest = rest.slice(join[0].length);
331
+ }
332
+ return out;
333
+ }
314
334
 
315
335
  /**
316
336
  * Issue numbers an issue body declares it waits on: `**Depends on**: #X`, `**Depends on** #X, #Y`, `Depends on #X`,
317
- * `- Depends on: #X and #Y`, `Blocked by #X`. Only declaration lines count; a mid-line mention is prose. Shared by the
318
- * engine's admission (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
337
+ * `- Depends on: #X and #Y`, `Depends on #X (why) and #Y (why)`, `Blocked by #X`. Only declaration lines count, and on
338
+ * them only the leading `#N` list; a mid-line or trailing mention is prose. Shared by the engine's admission
339
+ * (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
319
340
  * @returns {number[]} unique, positive, never `issue` itself
320
341
  */
321
342
  export function parseDependsOn(body, issue = null) {
322
343
  const out = new Set();
323
344
  for (const m of String(body ?? "").replace(/\r\n/g, "\n").matchAll(DEPENDS_ON_RE)) {
324
- for (const r of m[1].matchAll(/#(\d+)/g)) out.add(Number(r[1]));
345
+ for (const n of dependsOnList(m[1])) out.add(n);
325
346
  }
326
347
  return [...out].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
327
348
  }
@@ -329,8 +350,8 @@ export function parseDependsOn(body, issue = null) {
329
350
  /**
330
351
  * forge#3932: does the issue declare a `Depends on` / `Blocked by` dependency that is still OPEN?
331
352
  * Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
332
- * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line `parseDependsOn` accepts (mid-line
333
- * prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
353
+ * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line's leading `#N` list as `parseDependsOn` reads it (mid-line
354
+ * and trailing prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
334
355
  * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
335
356
  * An empty read of the issue itself means "no body to trust" and yields `null`.
336
357
  */
@@ -125,7 +125,7 @@ const warnedContextPack = new Set();
125
125
  const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
126
126
  /**
127
127
  * forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
128
- * (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
128
+ * (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
129
129
  * `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
130
130
  * (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
131
131
  */
@@ -133,8 +133,8 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
133
133
  try {
134
134
  const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
135
135
  let mode = String(e.context_pack ?? "").trim().toLowerCase();
136
- if (mode && !["shadow", "on", "off"].includes(mode)) {
137
- warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
136
+ if (mode && !["shadow", "on", "off", "ab"].includes(mode)) {
137
+ warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off|ab); using the default, other context_pack_* keys kept`);
138
138
  mode = "";
139
139
  }
140
140
  const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
@@ -229,6 +229,13 @@ export function readPreconditionCmd(cwd = process.cwd()) {
229
229
  return typeof v === "string" && v.trim() ? v.trim() : null;
230
230
  } catch { return null; }
231
231
  }
232
+ /** #4425: forge.yaml `post_deploy.min_age` ("24h", "7d"; opt-in soak window for parked post-deploy follow-ups) in ms, or null. */
233
+ export async function readPostDeployMinAge(cwd = process.cwd()) {
234
+ try {
235
+ const { parseMinAge } = await import("./lean/prefilter.mjs");
236
+ return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
237
+ } catch { return null; }
238
+ }
232
239
  /** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
233
240
  export function readExplore(cwd = process.cwd()) {
234
241
  try {
@@ -842,7 +849,7 @@ export async function runFromCli(argv, deps = {}) {
842
849
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
843
850
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
844
851
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
845
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
852
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
846
853
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
847
854
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
848
855
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1019,7 +1026,8 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1019
1026
  * `forgedock orchestrate --cancel <batch-id>`
1020
1027
  * Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
1021
1028
  * A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
1022
- * The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
1029
+ * The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`. Every start, requeues included, first
1030
+ * checks GitHub (#4481, `makeDispatchCheck`): a closed issue or a merged `feat/issue-N-lean` PR ends the row without a lane.
1023
1031
  * Declared acceptance probes (#4399, `lean/probe.mjs`) are swept once at batch start unless `--no-probes`.
1024
1032
  * Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
1025
1033
  * mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
@@ -1191,19 +1199,34 @@ async function orchestrateClient(argv, deps = {}) {
1191
1199
  }
1192
1200
  msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
1193
1201
  }
1194
- const sock = await connect();
1202
+ let sock = await connect(), req = msg, reattached = false, losses = 0;
1195
1203
  let id = resumeId;
1196
- try {
1197
- const done = await schedd.request(sock, msg, (m) => {
1198
- if (m.type === "batch") id = m.id;
1199
- else if (m.type === "row") console.log(formatRowLine(m.row));
1200
- else if (m.type === "line") console.log(m.text);
1201
- else if (m.type === "done" && m.replay && m.report) console.log(m.report);
1202
- });
1203
- return done.result;
1204
- } catch (e) {
1205
- if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
1206
- throw e;
1204
+ const onMsg = (m) => {
1205
+ if (m.type === "batch") id = m.id;
1206
+ else if (m.type === "row") { if (!(reattached && m.replay)) console.log(formatRowLine(m.row)); }
1207
+ else if (m.type === "line") console.log(m.text);
1208
+ else if (m.type === "done" && m.replay && m.report) console.log(m.report);
1209
+ };
1210
+ // #4503: when the daemon dies mid-batch, reconnect (spawning one unless `spawnDaemon: null`) and re-attach, within `reconnectMs`.
1211
+ const reconnectMs = deps.reconnectMs ?? 60_000, sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1212
+ for (;;) {
1213
+ try {
1214
+ return (await schedd.request(sock, req, onMsg)).result;
1215
+ } catch (e) {
1216
+ if (!id || !(/closed the connection/.test(e?.message ?? "") || ["ECONNRESET", "EPIPE"].includes(e?.code))) throw e;
1217
+ const giveUp = () => new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
1218
+ if (++losses > 10) throw giveUp();
1219
+ console.log(`schedd connection lost; reconnecting to batch ${id}`);
1220
+ const end = Date.now() + reconnectMs;
1221
+ for (sock = null; !sock;) {
1222
+ try { sock = await connect(); } catch {
1223
+ if (Date.now() >= end) throw giveUp();
1224
+ await sleep(250);
1225
+ }
1226
+ }
1227
+ req = { op: "attach", id };
1228
+ reattached = true;
1229
+ }
1207
1230
  }
1208
1231
  }
1209
1232
 
@@ -1294,7 +1317,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1294
1317
  const branches = readBranches(cwd);
1295
1318
  // #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
1296
1319
  const prefilter = argv.includes("--no-prefilter") ? null
1297
- : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd) });
1320
+ : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
1298
1321
  const fetched = new Set();
1299
1322
  const laneOf = async (n) => {
1300
1323
  const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
@@ -1310,7 +1333,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1310
1333
  const { runCore } = deps.leanModule;
1311
1334
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1312
1335
  lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1313
- admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1336
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1314
1337
  } else {
1315
1338
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1316
1339
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1342,7 +1365,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1342
1365
  const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1343
1366
  lanes = withPlacement({
1344
1367
  runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
1345
- adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
1368
+ adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
1346
1369
  });
1347
1370
  }
1348
1371
  // #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
@@ -1480,7 +1503,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1480
1503
  beforeAdmit,
1481
1504
  issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
1482
1505
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1483
- ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1506
+ ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1484
1507
  ...(remote ? { remote } : {}),
1485
1508
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1486
1509
  ...(recoveryOn ? { recovery: orch.recovery } : {}),
@@ -1491,6 +1514,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1491
1514
  }),
1492
1515
  // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1493
1516
  relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
1517
+ // #4481: a requeued or resumed issue that was closed or whose lane PR merged meanwhile is not run again
1518
+ dispatchCheck: deps.dispatchCheck !== undefined ? deps.dispatchCheck : makeDispatchCheck({ gh: (a) => leanIo.gh(a), repo }),
1494
1519
  // #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
1495
1520
  issueLabels: labelNames,
1496
1521
  // #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
@@ -1550,6 +1575,38 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1550
1575
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
1551
1576
  }
1552
1577
 
1578
+ /**
1579
+ * #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
1580
+ * (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
1581
+ * unreadable (no state, non-JSON, a non-array PR list, a gh error) is null: the lane runs.
1582
+ * Release review #4500: an OPEN issue reopened after that PR merged (a failed acceptance probe, #4399, or an operator
1583
+ * reopening it for rework) is new work, so the lane runs; an unreadable reopen history also runs the lane.
1584
+ */
1585
+ export function makeDispatchCheck({ gh, repo }) {
1586
+ const json = async (args) => { try { return JSON.parse(String(await gh(args))); } catch { return null; } };
1587
+ /** Latest `reopened` event time (ms) on the issue, 0 when never reopened, null when unreadable. */
1588
+ const lastReopen = async (n) => {
1589
+ try {
1590
+ const out = String(await gh(["api", `repos/${repo}/issues/${n}/events`, "--paginate", "--jq", '.[] | select(.event == "reopened") | .created_at']));
1591
+ return out.split("\n").map((l) => Date.parse(l.trim())).filter(Number.isFinite).reduce((a, b) => Math.max(a, b), 0);
1592
+ } catch { return null; }
1593
+ };
1594
+ return async (n) => {
1595
+ const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
1596
+ const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
1597
+ const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
1598
+ const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
1599
+ if (pr !== null && state === "OPEN") {
1600
+ const mergedAt = Date.parse(prs[0]?.mergedAt ?? "");
1601
+ const reopened = await lastReopen(n);
1602
+ if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
1603
+ }
1604
+ if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
1605
+ if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
1606
+ return null;
1607
+ };
1608
+ }
1609
+
1553
1610
  /**
1554
1611
  * #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
1555
1612
  * never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
@@ -1834,7 +1891,7 @@ export async function triageFromCli(argv, deps = {}) {
1834
1891
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1835
1892
  const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1836
1893
  const branches = readBranches();
1837
- const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd() }; // #4389
1894
+ const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() }; // #4389, #4425
1838
1895
  const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
1839
1896
  if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
1840
1897
  // #4141: bounded pool. Each lane is fetched once (memoized promise); output stays in input order.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * Re-read nudge (#4456): a Claude CLI PostToolUse hook the lean engine passes to builder sessions with `--settings`
6
+ * (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
7
+ * definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
8
+ * `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
9
+ * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { readPaths, REREAD_MIN } from "../lean/toolstats.mjs";
16
+
17
+ /** Paths one hook payload reads, resolved against the payload cwd so `sed -n … a.js` and Read `/abs/a.js` agree. */
18
+ export function payloadReads(payload) {
19
+ const inp = payload?.tool_input ?? {};
20
+ const path = inp.file_path ?? inp.path;
21
+ const u = { name: payload?.tool_name ?? null, path: typeof path === "string" ? path : null, cmd: typeof inp.command === "string" ? inp.command : null };
22
+ const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : null;
23
+ return [...new Set(readPaths(u).map((p) => (cwd ? resolve(cwd, p) : p)))];
24
+ }
25
+
26
+ /** Pure core: counts after this payload and the reminder to emit (null when no path just reached REREAD_MIN). */
27
+ export function nudgeFor(payload, counts = {}) {
28
+ const next = { ...counts }, hot = [];
29
+ for (const p of payloadReads(payload)) {
30
+ next[p] = (next[p] ?? 0) + 1;
31
+ if (next[p] === REREAD_MIN) hot.push(p);
32
+ }
33
+ const message = hot.length ? `${hot.join(", ")} ${hot.length > 1 ? "have" : "has"} now been read ${REREAD_MIN} times in this session: `
34
+ + "use your notes or the excerpt already in context; if you need a different part, read only that line range." : null;
35
+ return { counts: next, message };
36
+ }
37
+
38
+ export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
39
+ join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
40
+
41
+ /** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
42
+ export function main(input, { dir } = {}) {
43
+ try {
44
+ const payload = JSON.parse(input);
45
+ if (!payload?.session_id) return "";
46
+ if (!payloadReads(payload).length) return "";
47
+ const file = stateFile(payload.session_id, dir);
48
+ let counts = {};
49
+ try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
50
+ const r = nudgeFor(payload, counts);
51
+ mkdirSync(dirname(file), { recursive: true });
52
+ writeFileSync(file, JSON.stringify(r.counts));
53
+ return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
54
+ } catch {
55
+ return "";
56
+ }
57
+ }
58
+
59
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
60
+ let input = "";
61
+ process.stdin.setEncoding("utf8");
62
+ process.stdin.on("data", (c) => { input += c; });
63
+ process.stdin.on("end", () => { process.stdout.write(main(input)); process.exitCode = 0; });
64
+ process.stdin.on("error", () => { process.exitCode = 0; });
65
+ }
@@ -181,7 +181,10 @@ export async function waitLane({ batch, issue, pid, alive = isLaneAlive, sleep =
181
181
  }
182
182
  }
183
183
 
184
- /** Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). */
184
+ /**
185
+ * Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). A lane
186
+ * that already ended resolves at once with its result file; a live one calls `ctx.onLive()` before it is awaited (#4504).
187
+ */
185
188
  export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive = isLaneAlive, pollMs, spawnFn }) {
186
189
  return {
187
190
  runOne: async (n, lane, ctx = {}) => {
@@ -189,9 +192,12 @@ export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive =
189
192
  ctx.onStart?.({ pid });
190
193
  return waitLane({ batch, issue: n, pid, alive, pollMs });
191
194
  },
192
- adopt: async (n, rec) => {
195
+ adopt: async (n, rec, ctx = {}) => {
193
196
  const rf = batch.resultFile(n);
194
- if (!existsSync(rf) && !alive(rec.pid, rf)) return null;
197
+ if (!existsSync(rf)) {
198
+ if (!alive(rec.pid, rf)) return null;
199
+ ctx.onLive?.();
200
+ }
195
201
  return waitLane({ batch, issue: n, pid: rec.pid, alive, pollMs });
196
202
  },
197
203
  };
@@ -7,8 +7,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
10
- import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
11
- import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } from "../engine-cli.mjs";
10
+ import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
+ import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
12
12
 
13
13
  // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
14
14
  process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
@@ -586,3 +586,76 @@ describe("#4454: orchestrate's shutdown signal (schedd stop)", () => {
586
586
  assert.ok(Date.now() - t0 < 1000);
587
587
  });
588
588
  });
589
+
590
+ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", () => {
591
+ it("a requeued issue whose PR merged meanwhile ends merged without a second lane", async () => {
592
+ const { now, sleep } = fakeClock();
593
+ let runs = 0, checks = 0;
594
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep,
595
+ dispatchCheck: async () => (++checks === 1 ? null : { outcome: "merged", pr: 77, detail: "PR #77 already merged" }),
596
+ runOne: async () => { runs++; return { terminalReason: "deferred", code: "host.busy", retryInSeconds: 60 }; } });
597
+ assert.deepEqual([runs, checks], [1, 2], "checked on every start, the requeue included");
598
+ assert.deepEqual([rows[0].outcome, rows[0].pr], ["merged", 77]);
599
+ assert.match(rows[0].detail, /skipped at dispatch: PR #77 already merged/);
600
+ });
601
+
602
+ it("a closed issue ends invalid without a lane and its dependents still run; a throwing check runs the lane", async () => {
603
+ const ran = [];
604
+ const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "Depends on #1" : ""),
605
+ dispatchCheck: async (n) => (n === 1 ? { outcome: "invalid", detail: "issue closed" } : (() => { throw new Error("gh down"); })()),
606
+ runOne: async (n) => { ran.push(n); return { terminalReason: "merged", cost_usd: 0 }; } });
607
+ assert.deepEqual(ran, [2]);
608
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "invalid"], [2, "merged"]]);
609
+ });
610
+
611
+ it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
612
+ const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
613
+ if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
614
+ if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
615
+ assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
616
+ if (prs instanceof Error) throw prs;
617
+ return prs;
618
+ } });
619
+ const merged = '[{"number":12,"mergedAt":"2026-10-23T08:00:00Z"}]';
620
+ assert.deepEqual(await mk('{"state":"OPEN"}', merged)(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "open, never reopened: the merge is still closing it");
621
+ assert.deepEqual(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n")(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "reopened before the merge");
622
+ assert.equal(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n2026-10-23T09:00:00Z\n")(9), null, "reopened after the merge (failed probe, rework): the lane runs");
623
+ assert.equal(await mk('{"state":"OPEN"}', merged, new Error("api down"))(9), null, "unreadable reopen history: the lane runs");
624
+ assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
625
+ assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
626
+ assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
627
+ assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
628
+ assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
629
+ assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
630
+ });
631
+ });
632
+
633
+ describe("#4504: a resume settles lanes that ended while no dispatcher ran", () => {
634
+ it("#4504: a resume settles a dead lane's result and requeues a dead lane without one before scheduling, while an earlier live lane still runs", async () => {
635
+ const dir = tmpDir("batch-4504-");
636
+ const batch = { resultFile: (n) => join(dir, `${n}.json`), logFile: (n) => join(dir, `${n}.log`) };
637
+ writeFileSync(batch.resultFile(2), JSON.stringify({ terminalReason: "merged", pr: 102 }));
638
+ const { adopt } = detachedLanes({ batch, laneArgs: () => [], script: "x", alive: (pid) => pid === 11, pollMs: 5 });
639
+ let saved = { v: 1, order: [1, 2, 3, 4], deps: { 1: [], 2: [], 3: [], 4: [] }, lanes: {}, crossSeen: [], results: {},
640
+ running: { 1: { startedAt: 1, pid: 11 }, 2: { startedAt: 1, pid: 22 }, 3: { startedAt: 1, pid: 33 } }, pending: [4],
641
+ notBefore: {}, requeues: {}, deferrals: {}, engineErrors: {}, prefilter: {}, errorTimes: [], breaker: null };
642
+ const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
643
+ const launched = [];
644
+ const done = orchestrate({ issues: [], parallel: 3, pollMs: 5, readBody: async () => "", store, adopt,
645
+ runOne: async (n) => { launched.push(n); return { terminalReason: "merged", pr: 100 + n }; } });
646
+ let rows;
647
+ try {
648
+ await until(() => launched.includes(3) && launched.includes(4) && saved.results?.["2"], 3000);
649
+ assert.equal(saved.results["2"].outcome, "merged", "the dead lane's result is settled");
650
+ assert.equal(saved.results["2"].pr, 102);
651
+ assert.ok(saved.running["1"], "the live lane is still running");
652
+ assert.equal(saved.running["2"], undefined, "the settled lane left running");
653
+ assert.ok(!launched.includes(1) && !launched.includes(2), "neither adopted lane is relaunched");
654
+ } finally {
655
+ writeFileSync(batch.resultFile(1), JSON.stringify({ terminalReason: "merged", pr: 101 })); // the live lane ends
656
+ rows = await done;
657
+ }
658
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.pr]), [[1, "merged", 101], [2, "merged", 102], [3, "merged", 103], [4, "merged", 104]]);
659
+ assert.deepEqual(launched.sort(), [3, 4], "the dead lane without a result is requeued and run once");
660
+ });
661
+ });
@@ -21,7 +21,8 @@ import { parseFactsComment, factsLines, FACTS_RANK } from "./facts.mjs";
21
21
  // noComments lives in render.mjs (#4298: one comment-strip helper for context, conformance and prefilter).
22
22
  export { noComments };
23
23
 
24
- export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off"]);
24
+ /** #4444: `ab` is the experiment mode: each issue gets a stable arm, `on` or `shadow` (contextArm). */
25
+ export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off", "ab"]);
25
26
  export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
26
27
  export const DEFAULT_CONTEXT_BYTES = 6144;
27
28
  export const DEFAULT_LEDGER_DAYS = 30;
@@ -53,6 +54,16 @@ export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLane
53
54
  return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS), pitfallLanes: pos(pitfallLanes, DEFAULT_PITFALL_LANES) };
54
55
  }
55
56
 
57
+ /**
58
+ * #4444: the effective mode of one lane. In `ab` the arm comes from a stable hash of `repo#issue`, so every rerun of an
59
+ * issue stays in its arm and the arms are random, matched cohorts per lane type; any other mode passes through.
60
+ */
61
+ export function contextArm({ mode, repo = "", issue } = {}) {
62
+ if (mode !== "ab") return { mode, ab: false };
63
+ const byte = createHash("sha256").update(`${String(repo).toLowerCase()}#${issue}`).digest()[0];
64
+ return { mode: byte % 2 === 0 ? "on" : "shadow", ab: true };
65
+ }
66
+
56
67
  /** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
57
68
  export function parentOf(body) {
58
69
  const m = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?parent(?:\*\*|__)?[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*#(\d+)\b/im.exec(stripComments(body));
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
6
- import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
6
+ import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
7
7
 
8
8
  const REPO = "o/r";
9
9
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -358,6 +358,23 @@ describe("#4298 context-pack cache and shared names", () => {
358
358
  writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack_pitfall_lanes: 20\n");
359
359
  assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { pitfallLanes: 20 }, "#4375: the pitfall aging window");
360
360
  });
361
+
362
+ it("#4444: ab is a valid mode, and contextArm gives each issue a stable on|shadow arm", async () => {
363
+ const { readContextPack } = await import("../engine-cli.mjs");
364
+ const { tmpDir } = await import("../tests/_tmp.mjs");
365
+ const { writeFileSync } = await import("node:fs");
366
+ const d = tmpDir("ctxab-"), warns = [];
367
+ writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: ab\n");
368
+ assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "ab" });
369
+ assert.equal(warns.length, 0);
370
+ assert.equal(parseContextPackConfig({ mode: "AB" }).mode, "ab");
371
+ assert.equal(parseContextPackConfig({}).mode, "shadow", "the default stays shadow");
372
+ for (const mode of ["shadow", "on", "off"]) assert.deepEqual(contextArm({ mode, repo: "o/r", issue: 1 }), { mode, ab: false });
373
+ const arms = Array.from({ length: 40 }, (_, i) => contextArm({ mode: "ab", repo: "o/r", issue: i + 1 }));
374
+ assert.ok(arms.every((a) => a.ab && ["on", "shadow"].includes(a.mode)));
375
+ assert.ok(arms.some((a) => a.mode === "on") && arms.some((a) => a.mode === "shadow"), "both arms are populated");
376
+ assert.deepEqual(contextArm({ mode: "ab", repo: "O/R", issue: 7 }), contextArm({ mode: "ab", repo: "o/r", issue: 7 }), "stable per issue");
377
+ });
361
378
  });
362
379
 
363
380
  describe("#4375 recurring pitfalls in the context pack", () => {
@@ -3,7 +3,7 @@
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
4
  import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
6
+ import { readFileSync, appendFileSync, mkdirSync, mkdtempSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
@@ -23,6 +23,7 @@ import { scopedChecks } from "./checks.mjs";
23
23
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
24
24
  import { followupMap } from "./conformance.mjs";
25
25
  import { toolStats } from "./toolstats.mjs";
26
+ import { detectApiShape } from "./apishape.mjs";
26
27
 
27
28
  const pexec = promisify(execFile);
28
29
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -60,12 +61,18 @@ export function wrapLaneLimits(bin, args, limits) {
60
61
  export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
61
62
  : ["--disallowedTools", "Read", "Grep", "Glob", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task"]);
62
63
 
64
+ /** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
65
+ export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
66
+ export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash",
67
+ hooks: [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }] }] } })];
68
+
63
69
  export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
64
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
70
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
65
71
  const bin = resolveClaudeCliBinary(cwd) || "claude";
66
72
  const caps = getClaudeCliCapabilities(cwd);
67
73
  // #4391: custom subagents (the explorer) for the builder session; null leaves argv unchanged
68
- const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
74
+ const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : []),
75
+ ...(rereadNudge && !readOnly && !noTools ? rereadNudgeFlags() : [])]; // #4456: builder sessions only
69
76
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
70
77
  args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
71
78
  logger: { log() {} },
@@ -227,10 +234,36 @@ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }
227
234
  });
228
235
  }
229
236
 
237
+ /**
238
+ * #4507: a per-run gh HTTP cache (XDG_CACHE_HOME on the gh child only), so concurrent lanes never share a cache entry.
239
+ * `reset()` drops the current dir (only one this cache created) and switches to a fresh one; with no `root` the dirs
240
+ * are mkdtemp dirs under `tmp`, removed by `dispose()`.
241
+ */
242
+ export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
243
+ let n = 0, dir = root ? join(root, "gh-cache") : null, disposed = false;
244
+ const made = new Set();
245
+ const drop = (d) => { if (d && made.has(d)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } made.delete(d); } };
246
+ return {
247
+ env() {
248
+ if (!dir || disposed) return undefined; // after dispose (run teardown) gh falls back to its default cache
249
+ if (!made.has(dir)) { try { mkdirSync(dir, { recursive: true, mode: 0o700 }); made.add(dir); } catch { return undefined; } }
250
+ return { XDG_CACHE_HOME: dir };
251
+ },
252
+ reset() {
253
+ drop(dir);
254
+ if (disposed) return;
255
+ if (root) { dir = join(root, `gh-cache-${++n}`); return; }
256
+ try { dir = mkdtempSync(join(tmp, "forge-gh-cache-")); made.add(dir); } catch { dir = null; }
257
+ },
258
+ dispose() { disposed = true; for (const d of [...made]) drop(d); },
259
+ };
260
+ }
261
+
230
262
  export function makeLeanIo({ repo }) {
231
263
  const base = makeIo();
232
- const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
264
+ const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
233
265
  return {
266
+ ghCache: (root) => makeGhCache({ root }),
234
267
  gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
235
268
  /** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
236
269
  deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
@@ -316,6 +349,10 @@ export function makeLeanIo({ repo }) {
316
349
  async structureDelta({ cwd, base }) {
317
350
  try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
318
351
  },
352
+ /** #4450: public-API-shape changes of base...HEAD in cwd (bin/lean/apishape.mjs); any failure is {status: "unknown"} (an upgrade). */
353
+ async apiShape({ cwd, base }) {
354
+ try { return await detectApiShape({ cwd, base, head: "HEAD" }); } catch (e) { return { status: "unknown", changes: [], unknown: [{ file: "*", lang: null, reason: String(e?.message ?? e).slice(0, 200) }] }; }
355
+ },
319
356
  /** #4375: the repo's learned-pitfall ledger, local only (~/.forge/pitfalls, FORGE_PITFALLS_DIR). */
320
357
  pitfalls: makePitfallStore(),
321
358
  /** forge#4275: the context pack's facts for one issue (bin/lean/context.mjs), from gh and git only; sources fail independently. */
@@ -411,7 +411,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
411
411
  await tear(led, h); // every path, after the result is in (tear never throws)
412
412
  return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
413
413
  },
414
- adopt: async (n, rec) => {
414
+ adopt: async (n, rec, ctx = {}) => {
415
415
  const saved = rec?.handle;
416
416
  if (!saved?.id || saved.provider !== provider.name) return null;
417
417
  // Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
@@ -420,7 +420,9 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
420
420
  led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
421
421
  let r;
422
422
  try {
423
- r = !(await provider.fetchResult(h)) && (await provider.status(h)) !== "running" ? null : await wait(h); // null: relaunched
423
+ const ended = await provider.fetchResult(h);
424
+ if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
425
+ else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
424
426
  } catch (e) {
425
427
  r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
426
428
  }