forgedock 1.29.0 → 1.30.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 (50) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +102 -38
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -48,6 +48,8 @@ export const OUTCOME_CODES = Object.freeze({
48
48
  CLI_TIMEOUT: "cli.timeout",
49
49
  CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
50
50
  ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
51
+ ENV_TOOL_MISSING: "env.tool_missing", // forge#4323: a validate command could not run because a host tool or module is missing; an environment error, not a conformance failure
52
+ ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
51
53
  SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
52
54
  SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
53
55
  LEASE_HELD: "lease.held",
@@ -180,13 +182,13 @@ export function codeForTerminalReason(reason) {
180
182
  * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
181
183
  * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
182
184
  */
183
- export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID]);
185
+ 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.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
184
186
  /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
185
187
  export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
186
188
 
187
189
  /**
188
190
  * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
189
- * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `contract.invalid` after a mid-run issue
191
+ * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `contract.invalid` after a mid-run issue
190
192
  * edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
191
193
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
192
194
  */
@@ -349,9 +349,10 @@ export async function openDependency(issue, io) {
349
349
  return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
350
350
  }
351
351
  const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
352
- if (st === "open") {
353
- // #4153: an open decomposed parent is a tracker; the edge resolves to its sub-issues (satisfied when all close).
354
- const subs = isDecomposedRecord(d) ? await decomposedSubissues(dep, io) : null;
352
+ // #4153 / #4337: a decomposed parent, open or closed, is a tracker; the edge resolves to its sub-issues (satisfied
353
+ // when all close). No usable trail (none, untrusted, revoked, unreadable) keeps the parent itself open (fail closed).
354
+ if (isDecomposedRecord(d) && (st === "open" || st === "closed")) {
355
+ const subs = await decomposedSubissues(dep, io);
355
356
  if (!subs) { open.push(dep); continue; }
356
357
  for (const s of subs) {
357
358
  let sr;
@@ -363,6 +364,7 @@ export async function openDependency(issue, io) {
363
364
  else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
364
365
  }
365
366
  }
367
+ else if (st === "open") open.push(dep);
366
368
  else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
367
369
  }
368
370
  return open.length ? { open: [...new Set(open)].filter((n) => n !== Number(issue)) } : null;
@@ -388,6 +390,21 @@ export async function decomposedSubissues(issue, io) {
388
390
  return subs.length ? subs : null;
389
391
  }
390
392
 
393
+ /**
394
+ * #4337: reopen a CLOSED `workflow:decomposed` issue whose decomposition trusted GitHub state shows revoked
395
+ * (`decompositionRevoked`). `true` only when it was reopened; an open issue, no/untrusted revocation, or any read or
396
+ * write error is `false` (never throws), so the closed tracker keeps gating its dependents.
397
+ */
398
+ export async function reopenRevokedDecomposition(issue, io) {
399
+ try {
400
+ const rec = await readIssueRecord(issue, io);
401
+ if (!rec || rec.empty || String(rec.state).toLowerCase() !== "closed" || !isDecomposedRecord(rec)) return false;
402
+ if (!(await decompositionRevoked(issue, io))) return false;
403
+ await io.gh(["api", "-X", "PATCH", `repos/{owner}/{repo}/issues/${issue}`, "-f", "state=open"]);
404
+ return true;
405
+ } catch { return false; }
406
+ }
407
+
391
408
  /** True when a REST/`gh --json` issue record carries the `workflow:decomposed` label. */
392
409
  export const isDecomposedRecord = (rec) => (rec?.labels ?? []).some((l) => (l?.name ?? l) === "workflow:decomposed");
393
410
 
@@ -166,6 +166,10 @@ export function readReproTest(cwd = process.cwd()) {
166
166
  return ["off", "prefer"].includes(v) ? v : undefined;
167
167
  } catch { return undefined; }
168
168
  }
169
+ /** forge.yaml `decomposition.close_parent` (#3736, #4337): true closes a decomposed parent at decomposition; anything else keeps it open. */
170
+ export function readCloseParent(cwd = process.cwd()) {
171
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.decomposition?.close_parent ?? "").trim() === "true"; } catch { return false; }
172
+ }
169
173
  function readLeanSetting(key, cwd = process.cwd()) {
170
174
  try {
171
175
  const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
@@ -280,6 +284,23 @@ export function readPrecheckCommands(cwd = process.cwd()) {
280
284
  }
281
285
  return out.length ? out : undefined;
282
286
  }
287
+ /**
288
+ * forge.yaml `lean.precheck.max_minutes` (#4331): the pre-PR check round budget in minutes (1..120). The one-level
289
+ * parser flattens a nested `precheck:` block under `lean:` to `lean.max_minutes`; flat `lean.precheck_max_minutes` also
290
+ * works and wins. undefined when absent, invalid or unreadable (runCore then uses verify.precheck_total_seconds, default 600 s).
291
+ */
292
+ export function readPrecheckBudget(cwd = process.cwd()) {
293
+ try {
294
+ const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {};
295
+ const raw = String(l.precheck_max_minutes ?? l.max_minutes ?? "").trim(), n = Number(raw);
296
+ return raw !== "" && Number.isFinite(n) && n >= 1 && n <= 120 ? n : undefined;
297
+ } catch { return undefined; }
298
+ }
299
+ /** runCore `precheck` option: {commands, maxMinutes} from learned.test_commands and lean.precheck.max_minutes; {} when neither is set. */
300
+ function readPrecheckOptions(cwd = process.cwd()) {
301
+ const commands = readPrecheckCommands(cwd), maxMinutes = readPrecheckBudget(cwd);
302
+ return commands || maxMinutes ? { precheck: { ...(commands ? { commands } : {}), ...(maxMinutes ? { maxMinutes } : {}) } } : {};
303
+ }
283
304
  /**
284
305
  * forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
285
306
  * per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
@@ -637,7 +658,7 @@ export function lastLocalRun(dir) {
637
658
  */
638
659
  export async function runFromCli(argv, deps = {}) {
639
660
  const issue = parseInt(argv[0], 10);
640
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n]] [--retry] [--wait-lease]");
661
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n] [--recover-nonfinal]] [--retry] [--wait-lease]");
641
662
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
642
663
  const lane = flag(argv, "--lane");
643
664
  if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
@@ -667,10 +688,11 @@ export async function runFromCli(argv, deps = {}) {
667
688
  const placementWhere = flag(argv, "--placement");
668
689
  const placement = placementWhere === "local" || placementWhere === "remote"
669
690
  ? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
670
- // #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`).
691
+ // #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`); #4302: `--recover-nonfinal`
692
+ // marks an attempt the runner may follow with another, so the lane leaves the needs-human relabel to the runner.
671
693
  const recoverCode = flag(argv, "--recover");
672
694
  const recoverAttempt = Number(flag(argv, "--recover-attempt"));
673
- const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
695
+ const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1, final: !argv.includes("--recover-nonfinal") } : null;
674
696
  const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
675
697
  try {
676
698
  await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
@@ -735,7 +757,7 @@ export async function runFromCli(argv, deps = {}) {
735
757
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
736
758
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
737
759
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
738
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
760
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
739
761
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
740
762
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
741
763
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1071,6 +1093,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1071
1093
  sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
1072
1094
  const dir = deps.dir ?? runDir();
1073
1095
  const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists } = await import("./lean/orchestrate.mjs");
1096
+ const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
1074
1097
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1075
1098
  const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
1076
1099
  const prefilter = argv.includes("--no-prefilter") ? null
@@ -1091,14 +1114,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1091
1114
  const { runCore } = deps.leanModule;
1092
1115
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1093
1116
  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,
1094
- 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) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
1117
+ 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) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(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) } : {}) }) };
1095
1118
  } else {
1096
1119
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1097
1120
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
1098
1121
  // #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
1099
1122
  const placementOf = new Map(), recoverOf = new Map();
1100
1123
  const laneArgs = (n, ln) => { const pl = placementOf.get(n), rc = recoverOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
1101
- ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1)] : [])]; };
1124
+ ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1), ...(rc.final === false ? ["--recover-nonfinal"] : [])] : [])]; };
1102
1125
  const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
1103
1126
  if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
1104
1127
  if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
@@ -1153,6 +1176,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1153
1176
  }
1154
1177
  }
1155
1178
  const orch = deps.orchestration ?? readOrchestration(cwd);
1179
+ // #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
1180
+ const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
1181
+ if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
1156
1182
  // #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
1157
1183
  const limitsMod = await import("./lean/limits.mjs");
1158
1184
  const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
@@ -1224,38 +1250,50 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1224
1250
  } catch { near = false; }
1225
1251
  if (near) await runGcPhase("admission");
1226
1252
  };
1227
- const rows = await orchestrate({
1228
- beforeAdmit,
1229
- issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
1230
- breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1231
- ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1232
- ...(remote ? { remote } : {}),
1233
- // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1234
- ...(orch.recovery?.maxAttempts ? { recovery: orch.recovery } : {}),
1235
- decide: deps.decide ?? (async (d) => {
1236
- if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
1237
- const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
1238
- try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
1239
- }),
1240
- // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1241
- relabelNeedsHuman: deps.relabelNeedsHuman ?? (async ({ issue, evidence }) => {
1242
- try {
1243
- await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`]);
1244
- await leanIo.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
1245
- } catch { /* best effort: the batch row keeps the evidence */ }
1246
- }),
1247
- conflicting: deps.conflicting ?? (async (_n, pr) => {
1248
- try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
1249
- }),
1250
- onCrossLane: (e) => {
1251
- crossLane.push(e);
1252
- 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`);
1253
- },
1254
- readBody: maint || registry || hybrid
1255
- ? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
1256
- : async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
1257
- onUpdate: (r) => sink.row(r),
1258
- });
1253
+ // #4318: SIGINT/SIGTERM of a foreground dispatcher run the batch-exit relabel, then exit 128+signo (the lane pattern above).
1254
+ let exitRelabel = null, interrupted = false;
1255
+ const on = deps.processOn ?? ((s, f) => process.on(s, f)), off = deps.processOff ?? ((s, f) => process.removeListener(s, f));
1256
+ const exitProc = deps.exit ?? ((c) => process.exit(c));
1257
+ const SIGNO = { SIGINT: 2, SIGTERM: 15 };
1258
+ const handlers = daemon ? {} : Object.fromEntries(Object.keys(SIGNO).map((sig) => [sig, async () => {
1259
+ if (interrupted) return;
1260
+ interrupted = true;
1261
+ try { if (exitRelabel) await exitRelabel(`interrupted by ${sig}`); } catch { /* exit anyway */ }
1262
+ exitProc(128 + SIGNO[sig]);
1263
+ }]));
1264
+ for (const [sig, f] of Object.entries(handlers)) on(sig, f);
1265
+ let rows;
1266
+ try {
1267
+ rows = await orchestrate({
1268
+ beforeAdmit,
1269
+ issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
1270
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1271
+ ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1272
+ ...(remote ? { remote } : {}),
1273
+ // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1274
+ ...(recoveryOn ? { recovery: orch.recovery } : {}),
1275
+ decide: deps.decide ?? (async (d) => {
1276
+ if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
1277
+ const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
1278
+ try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
1279
+ }),
1280
+ // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1281
+ relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
1282
+ // #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
1283
+ ...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
1284
+ conflicting: deps.conflicting ?? (async (_n, pr) => {
1285
+ try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
1286
+ }),
1287
+ onCrossLane: (e) => {
1288
+ crossLane.push(e);
1289
+ 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`);
1290
+ },
1291
+ readBody: maint || registry || hybrid
1292
+ ? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
1293
+ : async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
1294
+ onUpdate: (r) => sink.row(r),
1295
+ });
1296
+ } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
1259
1297
  const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
1260
1298
  const pfLine = formatPrefilterSummary(rows);
1261
1299
  if (pfLine) report.push(pfLine);
@@ -1263,6 +1301,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1263
1301
  try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
1264
1302
  const waitLine = formatWaitSummary(rows, heavyStats);
1265
1303
  if (waitLine) report.push(waitLine);
1304
+ const precheckLine = formatPrecheckSummary(rows); // #4331
1305
+ if (precheckLine) report.push(precheckLine);
1266
1306
  let day = null;
1267
1307
  try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
1268
1308
  const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
@@ -1276,6 +1316,30 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1276
1316
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
1277
1317
  }
1278
1318
 
1319
+ /**
1320
+ * #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
1321
+ * never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
1322
+ * resume retries); then the capped FORGE:NEEDS_HUMAN comment, whose failure keeps the label → true.
1323
+ */
1324
+ export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
1325
+ return async ({ issue, evidence }) => {
1326
+ const { stateOf } = await import("./lean/context.mjs");
1327
+ try {
1328
+ const v = JSON.parse(await gh(["issue", "view", String(issue), "-R", repo, "--json", "state,body"]));
1329
+ if (String(v?.state ?? "").toUpperCase() === "CLOSED") return "skipped";
1330
+ const lease = stateOf(v?.body)?.lease;
1331
+ if (lease && Number(lease.until) > now()) return "skipped";
1332
+ } catch { /* unreadable: relabel as before */ }
1333
+ try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); } catch { return "failed"; }
1334
+ try {
1335
+ // #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
1336
+ const { capComment } = await import("./lean/render.mjs");
1337
+ await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
1338
+ } catch { /* best effort: the label is on, the batch row keeps the evidence */ }
1339
+ return true;
1340
+ };
1341
+ }
1342
+
1279
1343
  /** The batch report's "reclaimed" line (#4183). */
1280
1344
  export function formatReclaimed(r) {
1281
1345
  if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
package/bin/engine.mjs CHANGED
@@ -9,7 +9,7 @@ import { execFileSync } from "node:child_process";
9
9
  import { createHash, scryptSync } from "node:crypto";
10
10
  import { hostname as osHostname, homedir } from "node:os";
11
11
  import { appendEvent, readLog, deriveState, rewriteLog, scopeEvents } from "./engine/runlog.mjs";
12
- import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
12
+ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency, reopenRevokedDecomposition } from "./engine/phases.mjs";
13
13
  import { reconcileState } from "./engine/reconcile.mjs";
14
14
  import { makeProjector } from "./engine/projector.mjs";
15
15
  import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
@@ -570,6 +570,8 @@ export async function runIssue(opts) {
570
570
  // revocation alone suffices now that the size gate is gone. Anything else keeps the terminal; an
571
571
  // unreadable comment trail fails closed.
572
572
  if (state.terminalReason === "decomposed" && await decompositionRevoked(issue, io)) {
573
+ // #4337: a parent closed at decomposition is reopened on GitHub too, or the pre-phase divergence check ends it invalid.
574
+ await reopenRevokedDecomposition(issue, io);
573
575
  reopenUncommit = ["build", "decompose"];
574
576
  } else if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
575
577
  state.terminalReason !== "needs-human") {
@@ -8,7 +8,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
10
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
11
- import { orchestrateFromCli, runFromCli, readOrchestration } from "../engine-cli.mjs";
11
+ import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } 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-");
@@ -232,3 +232,64 @@ describe("lane launch env (#4179)", () => {
232
232
  assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
233
233
  });
234
234
  });
235
+
236
+ describe("#4318: the batch dispatcher's signal relabel and the needs-human relabel hook", () => {
237
+ it("SIGINT of a foreground dispatcher relabels a queued recovery with the signal as cause, then exits 130; handlers are removed", async () => {
238
+ const leanIo = {
239
+ gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
240
+ git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
241
+ };
242
+ const handlers = new Map(), removed = [], exits = [], relabels = [];
243
+ const l = console.log;
244
+ console.log = () => {};
245
+ try {
246
+ await orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("sig-"), batchRoot: tmpDir("sig-batch-"), allowTmpfs: true,
247
+ emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null, decide: () => {},
248
+ orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 2 } },
249
+ processOn: (s, f) => handlers.set(s, f), processOff: (s, f) => { if (handlers.get(s) === f) removed.push(s); }, exit: (c) => exits.push(c),
250
+ relabelNeedsHuman: async (x) => { relabels.push(x); return true; },
251
+ leanModule: { runCore: async (o) => {
252
+ if (o.issue === 1 && !o.recover) return { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" };
253
+ if (o.issue === 2) await handlers.get("SIGINT")(); // issue 1's attempt is queued behind issue 2 (K=1)
254
+ return { terminalReason: "merged", pr: 50 + o.issue };
255
+ } } });
256
+ } finally { console.log = l; }
257
+ assert.deepEqual(exits, [130]);
258
+ assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
259
+ assert.match(relabels[0].evidence, /the batch stopped \(interrupted by SIGINT\)/);
260
+ assert.deepEqual(removed.sort(), ["SIGINT", "SIGTERM"]);
261
+ });
262
+
263
+ describe("makeRelabelNeedsHuman", () => {
264
+ const LIVE = (until) => `body\n<!-- FORGE:STATE\n${JSON.stringify({ v: 1, issue: 7, lease: { by: "core_1", until, host: "h", pid: 1 } })}\n-->`;
265
+ const hook = (view, fail = {}) => {
266
+ const calls = [];
267
+ const gh = async (a) => {
268
+ calls.push(a[1]);
269
+ if (a[1] === "view") return JSON.stringify(view);
270
+ if (fail[a[1]]) throw new Error(`${a[1]} failed`);
271
+ return "";
272
+ };
273
+ return { calls, run: () => makeRelabelNeedsHuman({ gh, repo: "o/r", now: () => 1000 })({ issue: 7, evidence: "why" }) };
274
+ };
275
+ it("a closed issue or a live lease is skipped without a write", async () => {
276
+ for (const view of [{ state: "CLOSED", body: "" }, { state: "OPEN", body: LIVE(5000) }]) {
277
+ const h = hook(view);
278
+ assert.equal(await h.run(), "skipped");
279
+ assert.deepEqual(h.calls, ["view"]);
280
+ }
281
+ });
282
+ it("an expired lease relabels: the label first, then the comment", async () => {
283
+ const h = hook({ state: "OPEN", body: LIVE(500) });
284
+ assert.equal(await h.run(), true);
285
+ assert.deepEqual(h.calls, ["view", "edit", "comment"]);
286
+ });
287
+ it("a failed label add posts no comment and reports failed; a failed comment keeps the label", async () => {
288
+ const a = hook({ state: "OPEN", body: "" }, { edit: true });
289
+ assert.equal(await a.run(), "failed");
290
+ assert.deepEqual(a.calls, ["view", "edit"]);
291
+ const b = hook({ state: "OPEN", body: "" }, { comment: true });
292
+ assert.equal(await b.run(), true);
293
+ });
294
+ });
295
+ });
@@ -18,8 +18,8 @@ export const DECISION_SOURCES = Object.freeze(["doc", "lint", "precedent", "spec
18
18
  const REQ_STATUS = ["implemented", "partial", "missing", "deferred", "post-deploy", "not-applicable"];
19
19
  const COVER_STATUS = ["covered", "narrowed", "missing"];
20
20
  const PRESERVE_STATUS = ["intact", "violated", "unverified"];
21
- /** The first path:line anywhere in the evidence text (backticks, quotes and brackets around it are ignored). */
22
- const EVIDENCE_RE = /([^\s:`'"()[\]<>,;]+):(\d+)/;
21
+ /** A path:line inside one evidence token; the path may hold ( ) [ ] segments (route groups, [slug], [...slug]) (#4327). */
22
+ const EVIDENCE_RE = /([^:]+):(\d+)/;
23
23
  /** Test files in any common ecosystem: test dirs, *.test.* / *.spec.*, test_*.*, *_test.*, *Test(s).java|kt|cs|php|scala, *_spec.rb, tests.rs, conftest.py, snapshots. */
24
24
  const TEST_RE = /(^|\/)(test|tests|__tests__|spec|specs|testdata|__snapshots__)\/|\.(test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.[a-z0-9]+$|_test\.[a-z0-9]+$|Tests?\.(java|kt|kts|cs|php|scala|swift)$|_spec\.rb$|(^|\/)tests\.rs$|(^|\/)conftest\.py$|\.bats$|\.snap$/;
25
25
  /** Files any change may touch without being a deliverable: changelogs and dependency lockfiles. */
@@ -112,6 +112,42 @@ export function movingBaseDiff(command) {
112
112
  return null;
113
113
  }
114
114
 
115
+ /** Option names whose value a tool writes (output, report, cache or temp dirs), not reads (#4325). */
116
+ const WRITE_OPT = /^-(?:o|O|-?[\w-]*(?:out|dir|temp|tmp|cache|report|junit|xml|log)[\w-]*)$/i;
117
+ const TMP_PATH = /\$(?:\{TMPDIR(?::-[^}]*)?\}|TMPDIR)\/([^\s"'`;|&<>()=]+)/g;
118
+ /**
119
+ * #4325: the `$TMPDIR/<name>` paths a frozen command reads without creating them itself. The run TMPDIR is per run, so such
120
+ * a file (written by the builder before) is gone for the engine's red-green check and for any rerun. A path counts as
121
+ * created when it, or a directory above it, was earlier the target of `>`/`>>`, `tee`, `touch|mkdir|mktemp`, the last
122
+ * argument of `cp|mv|ln|install`, an output-like option (`-o`, `--output`, `--basetemp`, `--junitxml`, `--cache-dir`…) or an
123
+ * env assignment (`X=$TMPDIR/y`). Returns the read paths in first-use order (deduped), e.g. ["$TMPDIR/req.txt"].
124
+ */
125
+ export function tmpdirInputs(command) {
126
+ const made = [], reads = [];
127
+ const createdAbove = (p) => made.some((m) => p === m || p.startsWith(`${m}/`));
128
+ for (const seg of String(command ?? "").split(/&&|\|\||;|\||\n/)) {
129
+ const toks = seg.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']+|["']+$/g, ""));
130
+ const words = toks.filter((t) => !/^[A-Za-z_]\w*=/.test(t));
131
+ const cmd = (words[0] ?? "").replace(/^.*\//, "");
132
+ toks.forEach((tok, i) => {
133
+ for (const m of tok.matchAll(TMP_PATH)) {
134
+ const path = m[1].replace(/\/+$/, "");
135
+ const before = tok.slice(0, m.index), prev = toks[i - 1] ?? "";
136
+ const opt = /^(-[\w-]+)=["']?$/.exec(before)?.[1] ?? (before === "" && /^-/.test(prev) ? prev : null);
137
+ const write = /\d*>>?$|&>$/.test(before) || (before === "" && /^(?:\d*>>?|&>)$/.test(prev))
138
+ || /^[A-Za-z_]\w*=["']?$/.test(before) || (opt !== null && WRITE_OPT.test(opt))
139
+ || cmd === "tee" || ["touch", "mkdir", "mktemp"].includes(cmd)
140
+ || (["cp", "mv", "ln", "install"].includes(cmd) && i === toks.length - 1);
141
+ if (write) made.push(path);
142
+ else if (!createdAbove(path) && !reads.includes(`$TMPDIR/${path}`)) reads.push(`$TMPDIR/${path}`);
143
+ }
144
+ });
145
+ }
146
+ return reads;
147
+ }
148
+
149
+ const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
150
+
115
151
  export function validateContract(c, issueText, { repro = false } = {}) {
116
152
  const problems = [];
117
153
  if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
@@ -142,11 +178,13 @@ export function validateContract(c, issueText, { repro = false } = {}) {
142
178
  if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
143
179
  const ref = movingBaseDiff(v.command);
144
180
  if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
181
+ for (const p of tmpdirInputs(v.command)) problems.push(tmpInputProblem(v.id, p));
145
182
  }
146
183
  if (repro) {
147
184
  const t = c.repro_test;
148
185
  if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
149
186
  else if (!t || typeof t !== "object" || Array.isArray(t) || blank(t.path) || blank(t.command)) problems.push(`this is a bug: repro_test must be {"path","id","command"} (a test that fails on the base and passes after the fix) or "none" with a repro_test_reason`);
187
+ else for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
150
188
  }
151
189
  if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
152
190
  return { ok: problems.length === 0, problems };
@@ -187,14 +225,51 @@ export function applyAmendments(frozen, amendments, issueText = null) {
187
225
  return { contract: c, applied, toTrack, problems };
188
226
  }
189
227
 
228
+ const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
229
+ const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
230
+ const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
231
+ const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
232
+ /**
233
+ * forge#4323: the host tool or module a failed command could not find, from its {rc, output}: bash/sh "X: command not found"
234
+ * (or rc 127), python "No module named x" (top-level package), node "Cannot find module|package 'x'" (bare specifiers only).
235
+ * Returns {tool, kind: command|python-module|node-module} or null. A path, a relative specifier or a name the repo tracks
236
+ * (`repoHas(name)`) is null: a module the change forgot to create is a code defect, not the environment.
237
+ */
238
+ export function missingTool({ rc, output } = {}, { repoHas = () => false, command = "" } = {}) {
239
+ const out = String(output ?? "");
240
+ const local = (name) => !name || name.startsWith(".") || name.startsWith("/") || name.startsWith("file:") || repoHas(name);
241
+ const py = PY_MISSING_RE.exec(out);
242
+ if (py) { const tool = String(py[1] ?? py[2]).split(".")[0]; return local(tool) ? null : { tool, kind: "python-module" }; }
243
+ const js = NODE_MISSING_RE.exec(out);
244
+ if (js) {
245
+ const spec = js[1], scoped = spec.startsWith("@");
246
+ if (!scoped && spec.includes("/")) return null;
247
+ const tool = scoped ? spec.split("/").slice(0, 2).join("/") : spec;
248
+ return local(tool) || (scoped && !tool.includes("/")) ? null : { tool, kind: "node-module" };
249
+ }
250
+ const cmd = CMD_NOT_FOUND_RE.exec(out)?.[1] ?? (rc === 127 ? SH_NOT_FOUND_RE.exec(out)?.[1] ?? String(command).trim().split(/\s+/)[0] : null);
251
+ if (!cmd || cmd.includes("/") || local(cmd)) return null;
252
+ return { tool: cmd, kind: "command" };
253
+ }
254
+
255
+ /** `repoHas` for missingTool: true when any tracked path has a segment equal to `name` or `name.py`. */
256
+ export function repoTracks(paths) {
257
+ const segs = new Set();
258
+ for (const p of paths ?? []) for (const x of String(p).split("/")) segs.add(x);
259
+ return (name) => segs.has(name) || segs.has(`${name}.py`);
260
+ }
261
+
190
262
  /**
191
263
  * Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
192
264
  * requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[]}; each failure carries a
193
- * `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green) so callers can
265
+ * `kind` (scope_creep, validate_missing, validate_failed, validate_env, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green, repro_env) so callers can
194
266
  * tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
195
267
  * engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails. `repro` (#4271): the repro_test red-green words, checked when given.
268
+ * `validateRuns` (#4323): the engine's rerun {[id]: {rc, output}} of validate commands recorded as failed. A rerun that passes
269
+ * counts as passed; one that could not find a host tool or module (missingTool, with `repoHas`) is kind validate_env, code
270
+ * env.tool_missing (skipped on a CI-verified head), not a conformance failure.
196
271
  */
197
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null }) {
272
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false }) {
198
273
  const failures = [];
199
274
  const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
200
275
  if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
@@ -205,7 +280,14 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
205
280
  const leftToCi = !r || (r.ci === true && r.exit === undefined);
206
281
  if (ciVerified && leftToCi) continue;
207
282
  if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
208
- else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
283
+ else if (r.exit !== 0) {
284
+ const rerun = validateRuns?.[v.id];
285
+ if (rerun?.rc === 0) continue;
286
+ const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
287
+ if (miss && ciVerified) continue;
288
+ if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
289
+ else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
290
+ }
209
291
  }
210
292
  const st = Array.isArray(result?.requirements) ? result.requirements : [];
211
293
  for (const r of contract.requirements ?? []) {
@@ -224,13 +306,20 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
224
306
  /**
225
307
  * #4271: the frozen repro_test's red-green proof ({base, head} words from scripts/acceptance-red-green.sh). Anything but
226
308
  * HEAD=RAN BASE=FAILED fails; code conformance.unimplemented, so it blocks in shadow too and goes back to the builder.
309
+ * #4334: HEAD=UNRUNNABLE or no words (the harness could not run the test) is env.repro_unrunnable, kind repro_env: an
310
+ * environment error, never a builder resume.
227
311
  */
228
312
  export function reproFailures(contract, { base, head } = {}) {
229
313
  if (head === "RAN" && base === "FAILED") return [];
230
314
  const t = contract?.repro_test ?? {};
231
315
  const name = [t.path, t.id].filter((x) => !blank(x)).join(" ") || "repro_test";
316
+ if (!head || head === "UNRUNNABLE") {
317
+ return [{ code: C.ENV_REPRO_UNRUNNABLE, kind: "repro_env",
318
+ detail: `repro test ${name} (\`${t.command ?? ""}\`): the harness could not run the test on head (environment, not the change); got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
319
+ }
320
+ const skipped = base === "SKIPPED" ? " (base not run: head did not pass)" : "";
232
321
  return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
233
- detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
322
+ detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head}${skipped}` }];
234
323
  }
235
324
 
236
325
  const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
@@ -288,10 +377,42 @@ export function resolveEvidencePath(p, changedFiles = []) {
288
377
  const n = normalizeEvidencePath(p, { changedFiles });
289
378
  return n && changedFiles.includes(n) ? n : null;
290
379
  }
380
+ const count = (s, ch) => s.split(ch).length - 1;
381
+ /** Drops a leading or trailing bracket that has no partner inside the path: `(src/a.js` -> `src/a.js`, `(g)/a.tsx` stays. */
382
+ function trimUnbalanced(p) {
383
+ for (let again = true; again && p;) {
384
+ again = false;
385
+ for (const [o, c] of [["(", ")"], ["[", "]"]]) {
386
+ if (p.startsWith(o) && count(p, o) > count(p, c)) { p = p.slice(1); again = true; }
387
+ if (p.endsWith(c) && count(p, c) > count(p, o)) { p = p.slice(0, -1); again = true; }
388
+ }
389
+ }
390
+ return p;
391
+ }
392
+ /**
393
+ * The first path:line anywhere in the evidence text, verbatim (#4327: `(group)`, `[slug]`, `[...slug]` segments kept).
394
+ * Backticks and quotes are ignored, a markdown link `[label](path:line)` yields its target, and only brackets with no
395
+ * partner inside the path (prose like `(src/a.js:3)`) are dropped.
396
+ */
397
+ export function evidencePathLine(text) {
398
+ for (let tok of String(text ?? "").replace(/[`'"]/g, "").split(/[\s,;<>]+/)) {
399
+ const link = tok.lastIndexOf("](");
400
+ if (link >= 0) tok = tok.slice(link + 2);
401
+ const m = EVIDENCE_RE.exec(tok);
402
+ const path = m && trimUnbalanced(m[1]);
403
+ if (path) return { path, line: m[2] };
404
+ }
405
+ return null;
406
+ }
291
407
  /** The path:line in an evidence string, with the path normalized; null when there is none. */
292
408
  function evidenceOf(text, ctx) {
293
- const m = EVIDENCE_RE.exec(String(text ?? ""));
294
- return m ? { path: normalizeEvidencePath(m[1], ctx), line: m[2] } : null;
409
+ const m = evidencePathLine(text);
410
+ return m ? { path: normalizeEvidencePath(m.path, ctx), line: m.line } : null;
411
+ }
412
+ const MISSING_PATH_RE = /^[^:]+: evidence path .+ does not exist$/;
413
+ /** #4327: true when every problem of an incomplete review is an evidence path that did not resolve (a re-ask can fix it). */
414
+ export function evidencePathOnly(problems = []) {
415
+ return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)));
295
416
  }
296
417
  const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
297
418