forgedock 1.40.0 → 1.42.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 (62) hide show
  1. package/bin/engine/outcome-codes.mjs +6 -3
  2. package/bin/engine-cli.mjs +40 -4
  3. package/bin/forgedock.mjs +22 -1
  4. package/bin/lean/batch.test.mjs +33 -1
  5. package/bin/lean/capwait.mjs +36 -0
  6. package/bin/lean/capwait.test.mjs +48 -0
  7. package/bin/lean/conformance.mjs +150 -2
  8. package/bin/lean/conformance.test.mjs +126 -0
  9. package/bin/lean/depcache.mjs +10 -2
  10. package/bin/lean/depcache.test.mjs +22 -17
  11. package/bin/lean/execution.mjs +21 -5
  12. package/bin/lean/execution.test.mjs +7 -1
  13. package/bin/lean/heavy.mjs +22 -1
  14. package/bin/lean/lanecheck.mjs +129 -0
  15. package/bin/lean/lanecheck.test.mjs +118 -0
  16. package/bin/lean/lanelock.mjs +31 -0
  17. package/bin/lean/lean.mjs +102 -30
  18. package/bin/lean/lean.test.mjs +173 -3
  19. package/bin/lean/orchestrate.mjs +42 -7
  20. package/bin/lean/phaseledger.mjs +116 -0
  21. package/bin/lean/phaseledger.test.mjs +72 -0
  22. package/bin/lean/prodwatch.mjs +12 -1
  23. package/bin/lean/prodwatch.test.mjs +32 -3
  24. package/bin/lean/prompts.mjs +19 -0
  25. package/bin/lean/render.mjs +10 -2
  26. package/bin/lean/sandbox-provider.contract.test.mjs +48 -0
  27. package/bin/lean/sandbox-remote.mjs +8 -1
  28. package/bin/lean/schedd.mjs +5 -1
  29. package/bin/lean/selftest-gate.mjs +46 -0
  30. package/bin/lean/selftest-gate.test.mjs +40 -0
  31. package/bin/lean/selftest-live.mjs +248 -0
  32. package/bin/lean/selftest-live.test.mjs +154 -0
  33. package/bin/lean/selftest-nightly.mjs +81 -0
  34. package/bin/lean/selftest-nightly.test.mjs +84 -0
  35. package/bin/lean/selftest-static.mjs +81 -0
  36. package/bin/lean/selftest-static.test.mjs +32 -0
  37. package/bin/pool/agent.mjs +16 -5
  38. package/bin/pool/router.mjs +17 -1
  39. package/bin/pool/warm.mjs +80 -0
  40. package/bin/review-staging.mjs +28 -6
  41. package/bin/stats/aggregate.mjs +2 -1
  42. package/bin/stats/efficiency.mjs +3 -1
  43. package/bin/stats/efficiency.test.mjs +4 -1
  44. package/bin/stats/github-source.mjs +2 -1
  45. package/bin/stats/local-source.mjs +1 -0
  46. package/bin/stats/render.mjs +2 -0
  47. package/commands/deploy-pr.md +3 -3
  48. package/commands/review-pr-staging.md +2 -2
  49. package/commands/work-on/build.md +2 -2
  50. package/docs/CONFIG.md +33 -0
  51. package/docs/OUTCOME-CODES.md +1 -1
  52. package/docs/PROD-WATCH.md +1 -1
  53. package/docs/RELEASE-NOTES.md +10 -0
  54. package/docs/SELFTEST.md +34 -0
  55. package/docs/eval/conformance-labels-v1.json +13 -0
  56. package/package.json +1 -1
  57. package/pool/image/Dockerfile +2 -1
  58. package/pool/image/entrypoint.sh +44 -11
  59. package/pool/image/warm-refresh.sh +14 -0
  60. package/scripts/conformance-calibrate.mjs +132 -0
  61. package/scripts/file-deferred-followups.sh +21 -8
  62. package/scripts/file-deferred-followups.test.sh +27 -6
@@ -191,17 +191,20 @@ export function codeForTerminalReason(reason) {
191
191
  }
192
192
  }
193
193
 
194
+ /** forge#4906: a validate command timed out (exit 124) on the engine's rerun: infrastructure, not an unimplemented contract item. Kept outside OUTCOME_CODES (the base enum guard lists that enum); recoverable and mechanical like the other env codes. */
195
+ export const ENV_VALIDATE_TIMEOUT = "env.validate_timeout";
196
+
194
197
  /**
195
198
  * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
196
199
  * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
197
200
  */
198
- export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
201
+ export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
199
202
  /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
200
203
  export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
201
204
 
202
205
  /**
203
206
  * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
204
- * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
207
+ * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.validate_timeout`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
205
208
  * edit (`issueEdited`) or when every problem was an unmatched quote (`quoteMismatch`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
206
209
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
207
210
  */
@@ -227,7 +230,7 @@ function classifyNeedsHumanBase(row) {
227
230
  }
228
231
 
229
232
  /** forge#4725: mechanical/environment codes recover on their own budget (`recovery.mechanicalMaxAttempts`), apart from conformance failures. */
230
- export const MECHANICAL_CODES = Object.freeze([OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE]);
233
+ export const MECHANICAL_CODES = Object.freeze([OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, ENV_VALIDATE_TIMEOUT, OUTCOME_CODES.ENV_CAPABILITY_REMOTE]);
231
234
 
232
235
  /** `mechanical` | `conformance`: the recovery budget class of a lane result row (a conflicting `ci-pending` is a base conflict). */
233
236
  export function recoveryClassOf(row) {
@@ -23,7 +23,9 @@ import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/ev
23
23
  import { readExperiment } from "./experiment.mjs";
24
24
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
25
25
  import { parseVerify } from "./lean/verify.mjs";
26
+ import { normalizeKindModes } from "./lean/conformance.mjs";
26
27
  import { viewIssue } from "./lean/gh-assoc.mjs";
28
+ import { otherLaneOwner } from "./lean/lanelock.mjs";
27
29
  import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
28
30
  import { relayPrefetchCommands } from "./lean/relay.mjs";
29
31
  import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
@@ -114,6 +116,17 @@ export function readConformanceMode(cwd = process.cwd()) {
114
116
  return ["shadow", "enforce"].includes(v) ? v : undefined;
115
117
  } catch { return undefined; }
116
118
  }
119
+ /** forge.yaml `engine.conformance_kinds`: `"code=mode, code=mode"` (flat: the reader has no third level), mode enforce|shadow; invalid entries are dropped, undefined when none is left. */
120
+ export function readConformanceKinds(cwd = process.cwd()) {
121
+ try {
122
+ const k = normalizeKindModes(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.conformance_kinds);
123
+ return Object.keys(k).length ? k : undefined;
124
+ } catch { return undefined; }
125
+ }
126
+ /** forge.yaml `engine.conformance_mutation` (off|report, #4913): rerun each evidence test with the change reverted; undefined (off) when absent or invalid. */
127
+ export function readConformanceMutation(cwd = process.cwd()) {
128
+ try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.conformance_mutation ?? "").trim() === "report" ? "report" : undefined; } catch { return undefined; }
129
+ }
117
130
  /** forge.yaml `engine.review_incomplete` (needs-human|degraded, #4160); undefined when absent or invalid (the core defaults to needs-human). */
118
131
  export function readReviewIncomplete(cwd = process.cwd()) {
119
132
  try {
@@ -940,7 +953,7 @@ export async function runFromCli(argv, deps = {}) {
940
953
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
941
954
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
942
955
  emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
943
- 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() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), 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() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
956
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readConformanceKinds() ? { conformanceKinds: readConformanceKinds() } : {}), ...(readConformanceMutation() ? { conformanceMutation: "report" } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), 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() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
944
957
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
945
958
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
946
959
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1578,6 +1591,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1578
1591
  migrateRunLogs(dir); // #4432: split legacy per-issue logs by repo before any lane runs (best effort, never throws)
1579
1592
  const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists, formatAlertLines, estimateLaneMinutes, laneHistory, formatEstimateSummary } = await import("./lean/orchestrate.mjs");
1580
1593
  const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
1594
+ const { formatLedgerSummary } = await import("./lean/phaseledger.mjs");
1581
1595
  const { formatFirstPassSummary } = await import("./lean/fixround.mjs");
1582
1596
  const { formatDecompositionSummary } = await import("./lean/decompose.mjs");
1583
1597
  const { formatShadowSummary } = await import("./lean/conformance.mjs");
@@ -1611,7 +1625,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1611
1625
  const { runCore } = deps.leanModule;
1612
1626
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1613
1627
  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,
1614
- 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) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(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) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
1628
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readConformanceKinds(cwd) ? { conformanceKinds: readConformanceKinds(cwd) } : {}), ...(readConformanceMutation(cwd) ? { conformanceMutation: "report" } : {}), ...(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) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(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) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
1615
1629
  } else {
1616
1630
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1617
1631
  // #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
@@ -1650,7 +1664,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1650
1664
  const bud = exec.budget ?? {};
1651
1665
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1652
1666
  ...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
1653
- ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1667
+ ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
1668
+ logTail: (h) => { try { return readFileSync(batch.logFile(h.issue), "utf8").slice(-4000); } catch { return ""; } } }); // #4866: a clone failure in the lane log types sandbox.clone
1654
1669
  remoteStop = remoteLanes.stopLane;
1655
1670
  lanes = withPlacement(remoteLanes);
1656
1671
  {
@@ -1813,11 +1828,19 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1813
1828
  try { const { readLocalRuns } = await import("./stats/local-source.mjs"); history = laneHistory(readLocalRuns(dir), repo); } catch { history = {}; }
1814
1829
  estimate = async (n, { body }) => estimateLaneMinutes({ labels: await labelNames(n), body, history });
1815
1830
  }
1831
+ // #4866: lane preflight (pool auth/version, PATH shim loops, gh fields): failing checks hold admission, re-checked each pass.
1832
+ let preflight = deps.preflight === undefined ? null : deps.preflight;
1833
+ if (deps.preflight === undefined && !testDeps) {
1834
+ const laneCheck = await import("./lean/lanecheck.mjs");
1835
+ const hostIo = await laneCheck.hostLaneIo({ env });
1836
+ preflight = async () => (await laneCheck.runLaneChecks({ exec: execForNeeds, env, io: hostIo })).filter((c) => !c.ok);
1837
+ }
1816
1838
  let rows;
1817
1839
  try {
1818
1840
  rows = await orchestrate({
1819
1841
  ...(estimate ? { estimate } : {}),
1820
1842
  beforeAdmit,
1843
+ ...(preflight ? { preflight } : {}),
1821
1844
  ...(orch.blockedLabel ? { getBlocked: async () => JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--label", orch.blockedLabel, "--state", "open", "--limit", "200", "--json", "number"])).map((x) => x.number) } : {}), // #4773
1822
1845
  trusted: (n) => trustedOf.get(n) === true,
1823
1846
  issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
@@ -1875,6 +1898,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1875
1898
  try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
1876
1899
  const waitLine = formatWaitSummary(rows, heavyStats);
1877
1900
  if (waitLine) report.push(waitLine);
1901
+ report.push(formatLedgerSummary(rows)); // #4908: per-phase wall time, n/a without ledger rows
1878
1902
  const precheckLine = formatPrecheckSummary(rows); // #4331
1879
1903
  if (precheckLine) report.push(precheckLine);
1880
1904
  const firstPassLine = formatFirstPassSummary(rows); // #4362
@@ -1937,6 +1961,11 @@ export function makeDispatchCheck({ gh, repo }) {
1937
1961
  }
1938
1962
  if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
1939
1963
  if (state === "CLOSED") return notPlanned ? { outcome: "invalid", detail: "issue closed (not planned)", final: true } : { outcome: "invalid", detail: "issue closed" }; // #4687: final = its dependents stay skipped
1964
+ // #4920: a live lease held by another lane, read from GitHub (any batch, any host, across restarts): no lane, no sandbox.
1965
+ // Release review #4931: never the open feat/issue-N-lean PR here. That branch is this issue's own lane, which a
1966
+ // relaunch or recovery lane resumes by design; a lane still running on it holds the lease.
1967
+ const owner = state === "OPEN" ? await otherLaneOwner({ gh, repo, issue: n, checkPr: false }).catch(() => null) : null;
1968
+ if (owner) return { outcome: "deferred", code: "lease.held", detail: owner.detail };
1940
1969
  return null;
1941
1970
  };
1942
1971
  }
@@ -2003,6 +2032,12 @@ async function scheddDue() {
2003
2032
  await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
2004
2033
  await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
2005
2034
  try { const prem = await import("./lean/needs-human-premise.mjs"); await prem.sweepPremises({ repo, io: { gh: (a) => leanIo.gh(a), pkgVersion: prem.registryPkgVersion } }); } catch { /* best effort (#4725) */ } // clear needs-human whose recorded premise now holds
2035
+ try { // #4894: opt-in nightly live selftest (forge.yaml selftest.nightly_interval_hours); best effort
2036
+ const { nightlyDue, launchNightly } = await import("./lean/selftest-nightly.mjs");
2037
+ // Detached child: the harness calls back into this daemon (orchestrate --add), so it must never run on its event loop.
2038
+ const launch = () => launchNightly({ cli: fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), repo });
2039
+ await nightlyDue({ repo, cwd: process.cwd(), io: leanIo, launch });
2040
+ } catch { /* never stops the other sweeps */ }
2006
2041
  }
2007
2042
 
2008
2043
  /** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
@@ -2036,7 +2071,8 @@ export async function scheddFromCli(argv, deps = {}) {
2036
2071
  if (argv.length) throw new Error("usage: forgedock schedd [status | watchdog [--stall-sec N]]");
2037
2072
  (deps.renice ?? (await import("./lean/schedd-health.mjs")).bestEffortRenice)(); // #4780: lanes must not starve the scheduler
2038
2073
  process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
2039
- const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
2074
+ const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }),
2075
+ ...(deps.daemonPreflight !== undefined ? { preflight: deps.daemonPreflight } : deps.batchDeps ? {} : { preflight: async () => { const lc = await import("./lean/lanecheck.mjs"); return (await lc.runLaneChecks({ exec: readExecution(process.cwd()), io: await lc.hostLaneIo() })).filter((c) => !c.ok); } }) });
2040
2076
  if (!d.running) return d;
2041
2077
  // #4454: a signal stops the hosted batch loops, then releases the lock and socket, then exits 128+signo
2042
2078
  schedd.exitOnSignals(d, { ...(deps.processOn ? { on: deps.processOn } : {}), ...(deps.exit ? { exit: deps.exit } : {}) });
package/bin/forgedock.mjs CHANGED
@@ -3362,6 +3362,7 @@ function help() {
3362
3362
  ["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
3363
3363
  ["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
3364
3364
  ["npx forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--dry-run] | status [--name <w>]", "Named, scoped, capped watches (forge.yaml issue_watch.watches) that claim matching open issues into lanes or one long-lived orchestrate batch, or (mode: release) pool them and cut themed releases; see docs/ISSUE-WATCH.md"],
3365
+ ["npx forgedock selftest [--static] | --live --repo <owner/sandbox> [--lang node|python] [--pr <N>]", "Live contract harness: drives the real gh, git and orchestrate through a disposable sandbox repo (steps create, add, body, resume, merge, remote, cleanup) plus a static check of every gh --json field list; exits non-zero naming the failed step; --pr posts the FORGE:SELFTEST result the staging->main review requires; see docs/SELFTEST.md"],
3365
3366
  ["npx forgedock prod-watch run [--repo owner/repo] [--once] [--interval <s>] [--dry-run] | status", "Production-side watcher (forge.yaml prod_watch): read-only signal sources -> triage -> governor -> issues, kill-switch file, owner digest; see docs/PROD-WATCH.md"],
3366
3367
  ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3367
3368
  ["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
@@ -3381,6 +3382,7 @@ function help() {
3381
3382
  ["npx forgedock stats [--since <ver|date>] [--repo owner/repo] [--json] [--refresh]", "Version-over-version cost/time/flow/review/gate report from GitHub telemetry (no model)"],
3382
3383
  ["npx forgedock doctor", "Check installation health"],
3383
3384
  ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block) and reclaim ForgeDock-owned leftovers (forge.yaml hygiene TTLs)"],
3385
+ ["npx forgedock doctor --lanes", "Lane preflight: pool token, pool agent version, PATH shim loops, gh --json fields; prints every check pass/fail"],
3384
3386
  ["npx forgedock update", "Pull latest & reinstall"],
3385
3387
  ["npx forgedock uninstall", "Remove commands"],
3386
3388
  ["npx forgedock opencode install", "Install token-efficient native OpenCode commands"],
@@ -3952,7 +3954,7 @@ async function watch() {
3952
3954
  const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3953
3955
  const KNOWN_COMMANDS = new Set([
3954
3956
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3955
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
3957
+ "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "prod-watch", "selftest", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "guide", "help", "--help", "-h",
3956
3958
  "version", "--version", "-v",
3957
3959
  ]);
3958
3960
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4223,6 +4225,16 @@ switch (command) {
4223
4225
  }
4224
4226
  break;
4225
4227
  }
4228
+ case "selftest": {
4229
+ const { selftestFromCli } = await import("./lean/selftest-live.mjs");
4230
+ try {
4231
+ if (!(await selftestFromCli(restArgs)).ok) exitCode = 1;
4232
+ } catch (err) {
4233
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4234
+ exitCode = 1;
4235
+ }
4236
+ break;
4237
+ }
4226
4238
  case "prod-watch": {
4227
4239
  const { prodWatchFromCli } = await import("./lean/prodwatch.mjs");
4228
4240
  try {
@@ -4354,6 +4366,15 @@ switch (command) {
4354
4366
  await demo();
4355
4367
  break;
4356
4368
  case "doctor":
4369
+ if (restArgs.includes("--lanes")) {
4370
+ // #4866: the lane preflight on demand: every check with pass/fail; exit 1 when any fails
4371
+ const lc = await import("./lean/lanecheck.mjs");
4372
+ const { readExecution } = await import("./engine-cli.mjs");
4373
+ const checks = await lc.runLaneChecks({ exec: readExecution(process.cwd()), io: await lc.hostLaneIo() });
4374
+ console.log(lc.formatLaneChecks(checks));
4375
+ exitCode = checks.every((c) => c.ok) ? 0 : 1;
4376
+ break;
4377
+ }
4357
4378
  exitCode = await doctor(restArgs.includes("--fix"));
4358
4379
  break;
4359
4380
  case "report": {
@@ -754,8 +754,40 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
754
754
  assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "invalid"], [2, "merged"]]);
755
755
  });
756
756
 
757
+ it("#4920 makeDispatchCheck defers an issue another lane owns: a live foreign lease; an expired or unreadable one runs, and the issue's own open lane PR runs (#4931)", async () => {
758
+ const mk = (issue, opts) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
759
+ if (a[0] === "issue" && a[a.length - 1] === "body") { if (opts.body instanceof Error) throw opts.body; return opts.body ?? "{}"; }
760
+ if (a[0] === "issue") return issue;
761
+ if (a.includes("open")) { if (opts.open instanceof Error) throw opts.open; return opts.open ?? "[]"; }
762
+ return "[]";
763
+ } });
764
+ const lease = (until) => JSON.stringify({ body: `x\n<!-- FORGE:STATE\n${JSON.stringify({ v: 1, issue: 9, lease: { by: "core_1", until, host: "sbx", pid: 1 } })}\n-->` });
765
+ const OPEN = '{"state":"OPEN"}';
766
+ const held = await mk(OPEN, { body: lease(Date.now() + 600000) })(9);
767
+ assert.equal(held.outcome, "deferred"); assert.equal(held.code, "lease.held"); assert.match(held.detail, /leased by core_1 until \d{4}-/);
768
+ assert.equal(await mk(OPEN, { body: lease(Date.now() - 1000) })(9), null, "expired lease: run");
769
+ // #4931: an open feat/issue-9-lean PR is this issue's own lane (ci.red / base.conflict recovery, a relaunch after
770
+ // engine-error or ci-pending resumes it), never another lane's: the lane runs
771
+ assert.equal(await mk(OPEN, { open: '[{"number":4919}]' })(9), null, "own open lane PR: run (resume it)");
772
+ assert.equal(await mk(OPEN, { body: new Error("down"), open: new Error("down") })(9), null, "unreadable: fail open");
773
+ assert.equal((await mk('{"state":"CLOSED"}', { body: lease(Date.now() + 600000) })(9)).outcome, "invalid", "closed wins");
774
+ });
775
+ it("#4920 a deferred dispatch check launches no lane and no sandbox, and a restarted batch (new orchestrate) defers again", async () => {
776
+ for (const round of [1, 2]) { // round 2: a fresh orchestrate call = a daemon restart; the answer is read from GitHub each time
777
+ const { now, sleep } = fakeClock();
778
+ let launched = 0;
779
+ const rows = await orchestrate({ issues: [5], readBody: async () => "", now, sleep, maxDeferrals: 1,
780
+ remote: { max: 1, place: () => ({ where: "remote", reason: "heavy" }) },
781
+ dispatchCheck: async () => ({ outcome: "deferred", code: "lease.held", detail: "issue 5 leased by core_1 until 2999-01-01T00:00:00.000Z" }),
782
+ runOne: async () => { launched++; return { terminalReason: "merged", cost_usd: 0 }; } });
783
+ assert.equal(launched, 0, `round ${round}: no lane, no sandbox`);
784
+ assert.equal(rows[0].outcome, "deferred", `round ${round}`);
785
+ }
786
+ });
757
787
  it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
758
- const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
788
+ const mk = (issue, prs, events = "", { body = "{}", open = "[]" } = {}) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
789
+ if (a[0] === "issue" && a[a.length - 1] === "body") return body; // #4920: the lease lookup
790
+ if (a[0] === "pr" && a.includes("open")) return open; // #4920: the open lane PR lookup
759
791
  if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
760
792
  if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
761
793
  assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
@@ -0,0 +1,36 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Capacity wait (#4909): a lane whose sandbox launch was refused for capacity (`sandbox.capacity`) is admitted again only
5
+ * once the pool reports a free slot (`capacity() -> {free}`), not on a blind timer retry; the wait is recorded as queue
6
+ * time (`waits.queue`, ms). A pool that does not report `free` is unknown: the provider's retry-in applies as before.
7
+ */
8
+ export function makeCapWait({ now = Date.now } = {}) {
9
+ const since = new Map(); // issue -> when its launch was refused for capacity
10
+ const waited = new Map(); // issue -> queue ms accumulated, not yet recorded on a row
11
+ const isFree = (cap) => Boolean(cap) && Number.isInteger(cap.free) && cap.free > 0;
12
+ const isFull = (cap) => Boolean(cap) && Number.isInteger(cap.free) && cap.free <= 0;
13
+ return {
14
+ /** A launch of `issue` was refused for capacity. */
15
+ refused(issue) { if (!since.has(issue)) since.set(issue, now()); },
16
+ /** True while a refused `issue` must keep waiting: the sampled pool reports no free slot. */
17
+ blocked(issue, cap) { return since.has(issue) && isFull(cap); },
18
+ /** `issue` was admitted: bank its wait as queue time. Returns the ms waited (0 when it was not refused). */
19
+ admitted(issue) {
20
+ if (!since.has(issue)) return 0;
21
+ const ms = Math.max(0, now() - since.get(issue));
22
+ since.delete(issue);
23
+ waited.set(issue, (waited.get(issue) ?? 0) + ms);
24
+ return ms;
25
+ },
26
+ /** `waits` ({phase: ms}) of a lane result with the banked queue wait added; the same object when none. */
27
+ take(issue, waits) {
28
+ const ms = waited.get(issue);
29
+ if (!ms) return waits;
30
+ waited.delete(issue);
31
+ const base = waits && typeof waits === "object" ? waits : {};
32
+ return { ...base, queue: (Number.isFinite(base.queue) ? base.queue : 0) + ms };
33
+ },
34
+ isFree,
35
+ };
36
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** #4909: capacity wait as queue time. */
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { makeCapWait } from "./capwait.mjs";
7
+ import { orchestrate } from "./orchestrate.mjs";
8
+
9
+ describe("makeCapWait (#4909)", () => {
10
+ it("blocks a refused issue only while the pool reports no free slot, and banks the wait as queue time", () => {
11
+ let t = 1000;
12
+ const w = makeCapWait({ now: () => t });
13
+ assert.equal(w.blocked(1, { free: 0 }), false, "never refused: not blocked");
14
+ w.refused(1);
15
+ assert.equal(w.blocked(1, { free: 0 }), true);
16
+ assert.equal(w.blocked(1, null), false, "unknown capacity falls back to the retry-in timer");
17
+ assert.equal(w.blocked(1, { online: 2 }), false);
18
+ t += 5000;
19
+ assert.equal(w.blocked(1, { free: 1 }), false);
20
+ assert.equal(w.admitted(1), 5000);
21
+ assert.deepEqual(w.take(1, { heavy: 7 }), { heavy: 7, queue: 5000 });
22
+ assert.equal(w.take(1, { heavy: 7 }).queue, undefined, "banked once");
23
+ assert.equal(w.take(2, undefined), undefined);
24
+ });
25
+ });
26
+
27
+ describe("at pool capacity a lane waits for a free slot (#4909)", () => {
28
+ it("a capacity-refused lane does not relaunch until free > 0; the wait is its queue time", async () => {
29
+ const c = { t: 1_000_000 };
30
+ let sleeps = 0, freeAt = Infinity;
31
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; if (++sleeps >= freeAt) free = 1; };
32
+ let free = 1, launches = 0, refusedAt = null, relaunchFree = null;
33
+ const rows = await orchestrate({ issues: [1], parallel: 1, now: () => c.t, sleep, pollMs: 1000, readBody: async () => "",
34
+ remote: { max: 1, isRemote: () => true, capacity: () => ({ free, at: c.t }) },
35
+ runOne: async () => {
36
+ launches++;
37
+ if (launches === 1) { refusedAt = c.t; free = 0; freeAt = sleeps + 3; return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 60, detail: "HTTP 503" }; }
38
+ relaunchFree = free;
39
+ return { terminalReason: "merged", pr: 101 };
40
+ } });
41
+ assert.equal(rows[0].outcome, "merged");
42
+ assert.equal(launches, 2);
43
+ assert.ok(relaunchFree > 0, "relaunched only with a free slot");
44
+ assert.ok(c.t - refusedAt < 60000, "not the blind 60s retry");
45
+ assert.ok(c.t - refusedAt >= 3000, "waited for the slot");
46
+ assert.ok(Number.isFinite(rows[0].waits?.queue) && rows[0].waits.queue >= 0, "wait recorded as queue time");
47
+ });
48
+ });
@@ -7,9 +7,11 @@
7
7
  * The reviewer supplies per-row judgements; the engine computes the verdict. A review without rows (a generic
8
8
  * "PASS") is incomplete, and incomplete never merges.
9
9
  */
10
- import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
10
+ import { OUTCOME_CODES, ENV_VALIDATE_TIMEOUT } from "../engine/outcome-codes.mjs";
11
11
  import { noComments } from "./context.mjs";
12
12
  import { activationFailures } from "./activation.mjs";
13
+ import * as nodeFs from "node:fs";
14
+ import { tmpdir } from "node:os";
13
15
 
14
16
  export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
15
17
  /** Dispositions the engine files as a tracked follow-up issue before anything merges. */
@@ -321,6 +323,8 @@ const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g,
321
323
  const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
322
324
  const GUARD_ID_RE = /^#?\d+$/;
323
325
  const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
326
+ // #4916: near-synonyms builders emit for the per-guard `supersedes` amendment
327
+ const SUPERSEDES_SYNONYMS = ["supersede-guard", "supersede", "supersedes-guard"];
324
328
 
325
329
  /** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
326
330
  function normalizeAmendment(a) {
@@ -329,6 +333,7 @@ function normalizeAmendment(a) {
329
333
  if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
330
334
  // #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
331
335
  if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
336
+ if (SUPERSEDES_SYNONYMS.includes(n.kind)) n.kind = "supersedes";
332
337
  if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
333
338
  return n;
334
339
  }
@@ -378,6 +383,13 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
378
383
  const ids = splitRequirementRefs(n.requirement);
379
384
  if (ids.length > 1) { for (const id of ids) rows.push({ a: { ...n, requirement: id }, raw }); continue; }
380
385
  }
386
+ if (n?.kind === "supersedes" && Array.isArray(n.guards) && n.guards.length) {
387
+ // #4916: one supersedes row per guard id; each still hits the GUARD_ID_RE check below
388
+ const { guards, ...rest } = n;
389
+ const ids = blank(rest.guard) ? guards : [rest.guard, ...guards];
390
+ for (const guard of ids) rows.push({ a: { ...rest, guard }, raw });
391
+ continue;
392
+ }
381
393
  rows.push({ a: n, raw });
382
394
  }
383
395
  for (const { a, raw } of rows) {
@@ -524,6 +536,12 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
524
536
  const rerun = validateRuns?.[v.id];
525
537
  if (rerun?.rc === 0) continue;
526
538
  if (rerun && cliUsageError(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) is a CLI usage error of the runner, a defect of the frozen command rather than of the change` }); continue; }
539
+ // #4906: the rerun timed out (even after the longer retry): host load, not a verdict on the change. A CI-verified head covers it; else an environment error.
540
+ if (rerun?.rc === 124) {
541
+ if (ciVerified) { ciCovered.push(v.id); continue; }
542
+ failures.push({ code: ENV_VALIDATE_TIMEOUT, kind: "validate_timeout", detail: `validate ${v.id} (\`${v.command}\`) timed out (exit 124) on the engine's rerun: infrastructure (host load), not a failed check` });
543
+ continue;
544
+ }
527
545
  const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
528
546
  if (miss && ciVerified) { ciCovered.push(v.id); continue; }
529
547
  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)` });
@@ -885,7 +903,10 @@ export function formatShadowSummary(rows) {
885
903
  const falseB = sh.filter((c) => UNUSABLE_REVIEW.includes(c.would)).length;
886
904
  const pct = (k) => (sh.length ? `${Math.round((k / sh.length) * 100)}%` : "n/a");
887
905
  const risk = cs.filter((c) => c.risk === true).length;
888
- return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}`;
906
+ const byKind = {};
907
+ for (const c of cs) for (const k of new Set(Array.isArray(c.kinds) ? c.kinds : [])) byKind[k] = (byKind[k] ?? 0) + 1;
908
+ const kinds = Object.entries(byKind).sort(([a], [b]) => a.localeCompare(b)).map(([k, n]) => `${k} ${n}/${cs.length} (${Math.round((n / cs.length) * 100)}%)`);
909
+ return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}${kinds.length ? `; per kind: ${kinds.join(", ")}` : ""}`;
889
910
  }
890
911
 
891
912
  /** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
@@ -971,3 +992,130 @@ export function structureNote(delta) {
971
992
  const s = grow.length ? `this PR adds ${pairs} (files); ${config}` : `this PR loosens the repo's boundary config (${files})`;
972
993
  return `Structure (report only): ${s}; flag only if the contract or the architecture doc says otherwise.`.replace(/[\r\n]+/g, " ");
973
994
  }
995
+
996
+ /** engine.conformance_kinds: a failure code's own gate mode; "enforce" blocks, "shadow" is recorded only. */
997
+ export const KIND_MODES = Object.freeze(["enforce", "shadow"]);
998
+
999
+ /**
1000
+ * The valid `{code: mode}` entries of a config map, or of forge.yaml's flat form `"code=mode, code=mode"` (the
1001
+ * forge.yaml reader has no third nesting level); anything else is dropped. {} when absent.
1002
+ */
1003
+ export function normalizeKindModes(raw) {
1004
+ const out = {};
1005
+ if (typeof raw === "string") raw = Object.fromEntries(raw.split(",").map((x) => x.trim()).filter(Boolean).map((p) => p.split("=").map((x) => x.trim())));
1006
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) for (const [k, v] of Object.entries(raw)) if (String(k).trim() && KIND_MODES.includes(String(v).trim())) out[String(k).trim()] = String(v).trim();
1007
+ return out;
1008
+ }
1009
+
1010
+ /**
1011
+ * Split gate failures into those that block and those only recorded. A kind with its own mode follows it; every other
1012
+ * kind follows the lane: an enforce lane blocks it, a shadow lane blocks only the codes in `allow` (the previous reviewer's set).
1013
+ * @param {{code?: string}[]} failures
1014
+ * @param {{shadow?: boolean, kinds?: Record<string,string>|null, allow?: string[]}} [o]
1015
+ */
1016
+ export function blockingFailures(failures, { shadow = false, kinds = null, allow = [] } = {}) {
1017
+ const block = [], record = [];
1018
+ for (const f of failures ?? []) {
1019
+ const own = kinds?.[f?.code];
1020
+ (own ? own === "enforce" : !shadow || allow.includes(f?.code)) ? block.push(f) : record.push(f);
1021
+ }
1022
+ return { block, record };
1023
+ }
1024
+
1025
+ /**
1026
+ * Deliver requirements with no executed evidence test: one row per requirement id. Evidence is a test deliverable linked to the
1027
+ * requirement (`deliverables[].requirements`) that a validate command names and that ran green (the engine's rerun, else the
1028
+ * builder's result, else CI). Requirements verified out of band are judged by their instrumentation and skipped.
1029
+ * @returns {{id: string, reason: string}[]}
1030
+ */
1031
+ export function unverifiedRequirements(contract, { runs = [], reruns = null, ciCovered = [] } = {}) {
1032
+ const out = [];
1033
+ for (const r of contract?.requirements ?? []) {
1034
+ if (r.disposition !== "deliver" || isOutOfBand(r)) continue;
1035
+ const tests = evidenceTests(contract, r);
1036
+ if (!tests.length) { out.push({ id: r.id, reason: "no test deliverable linked to the requirement" }); continue; }
1037
+ if (!greenEvidence(contract, tests, { runs, reruns, ciCovered }).length) out.push({ id: r.id, reason: `no executed passing validate runs its test (${tests.join(", ")})` });
1038
+ }
1039
+ return out;
1040
+ }
1041
+
1042
+ const evidenceTests = (contract, r) => (contract.deliverables ?? []).filter((d) => (d.requirements ?? []).includes(r.id) && isTestPath(d.path)).map((d) => String(d.path));
1043
+ /** The validate commands that name one of `tests` and ran green (the engine's rerun, else the builder's result, else CI). */
1044
+ function greenEvidence(contract, tests, { runs = [], reruns = null, ciCovered = [] } = {}) {
1045
+ const green = (v) => {
1046
+ const rerun = reruns?.[v.id];
1047
+ if (rerun) return rerun.rc === 0;
1048
+ return (Array.isArray(runs) ? runs : []).some((x) => x?.id === v.id && x.exit === 0) || ciCovered.includes(v.id);
1049
+ };
1050
+ return (contract.validate ?? []).filter((v) => tests.some((t) => String(v.command).includes(t) || String(v.command).includes(t.split("/").pop())) && green(v));
1051
+ }
1052
+
1053
+ /** #4913 engine.conformance_mutation: the failure code of an evidence test that still passes with the change reverted. */
1054
+ export const MUTATION_SURVIVED = "conformance.mutation_survived";
1055
+
1056
+ /**
1057
+ * Deliver requirements whose executed evidence test can be mutation-checked: one row per requirement id with the green
1058
+ * evidence commands and the non-test files of the diff (the change to revert). Out-of-band requirements and requirements
1059
+ * with no green evidence are skipped; with no non-test file changed there is nothing to revert.
1060
+ * @returns {{id: string, commands: {id: string, command: string}[]}[]}
1061
+ */
1062
+ export function mutationTargets(contract, evidence = {}) {
1063
+ const out = [];
1064
+ for (const r of contract?.requirements ?? []) {
1065
+ if (r.disposition !== "deliver" || isOutOfBand(r)) continue;
1066
+ const tests = evidenceTests(contract, r);
1067
+ const commands = tests.length ? greenEvidence(contract, tests, evidence).map((v) => ({ id: v.id, command: String(v.command) })) : [];
1068
+ if (commands.length) out.push({ id: r.id, commands });
1069
+ }
1070
+ return out;
1071
+ }
1072
+
1073
+ /** conformance.mutation_survived rows: a result still green with the change reverted. A skipped or killed result never fails. */
1074
+ export function mutationFailures(results) {
1075
+ return (results ?? []).filter((x) => x?.status === "survived").map((x) => ({
1076
+ code: MUTATION_SURVIVED, kind: "mutation_survived",
1077
+ detail: `${x.id} evidence test passes with the change reverted (\`${x.command}\`, exit 0): it does not prove the requirement`,
1078
+ }));
1079
+ }
1080
+
1081
+ /**
1082
+ * Rerun each target's evidence commands with the diff's non-test files reverted to the merge base (git apply -R of the
1083
+ * non-test diff), then restore them from HEAD in a finally. The commits stay in HEAD, so no work is lost. A dirty tree,
1084
+ * a failed revert, a timeout (124) or a missing runner (127) is skipped, never a failure.
1085
+ * @returns {{results: {id: string, command: string, status: "survived"|"killed"|"skipped", note?: string}[], skipped: string, ms: number}}
1086
+ */
1087
+ export async function runMutationCheck({ targets, files, base, git, runCommand, cwd, env, capMs = 120000, tmp = tmpdir(), fs = nodeFs, now = Date.now }) {
1088
+ const t0 = now(), results = [];
1089
+ const nonTest = (files ?? []).filter((f) => !isTestPath(f));
1090
+ const skip = (note) => ({ results, skipped: note, ms: now() - t0 });
1091
+ if (!targets?.length) return skip("");
1092
+ if (!nonTest.length) return skip("no non-test file changed");
1093
+ if (String(await git(["status", "--porcelain"]).catch(() => "x")).trim()) return skip("working tree not clean");
1094
+ const patch = `${tmp}/mutation-${process.pid}.patch`;
1095
+ let reverted = false, restoreFailed = "";
1096
+ try {
1097
+ fs.writeFileSync(patch, await git(["diff", "--binary", `${base}...HEAD`, "--", ...nonTest]));
1098
+ try { await git(["apply", "-R", "--whitespace=nowarn", patch]); reverted = true; } catch { return skip("could not revert the change"); }
1099
+ for (const t of targets) for (const c of t.commands) {
1100
+ const left = capMs - (now() - t0);
1101
+ if (left <= 0) { results.push({ id: t.id, command: c.command, status: "skipped", note: "over the cap" }); continue; }
1102
+ let r;
1103
+ try { r = await runCommand({ command: c.command, cwd, timeoutMs: left, env }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
1104
+ const rc = Number.isInteger(r?.rc) ? r.rc : 1;
1105
+ results.push({ id: t.id, command: c.command, status: rc === 124 || rc === 127 ? "skipped" : rc === 0 ? "survived" : "killed" });
1106
+ }
1107
+ } finally {
1108
+ // Release review #4931: the tree was clean, so HEAD is the exact restore point. `checkout HEAD -- <files>` failed as a
1109
+ // whole on a file the change deleted (the revert re-creates it untracked) and restored nothing; reset + clean of
1110
+ // those paths does not, and a tree still dirty there afterwards throws (fail closed: never push the reverted change).
1111
+ if (reverted) {
1112
+ await git(["reset", "-q", "--hard", "HEAD"]).catch(() => {});
1113
+ await git(["clean", "-fdq", "--", ...nonTest]).catch(() => {});
1114
+ const left = await git(["status", "--porcelain", "--", ...nonTest]).catch(() => "unreadable");
1115
+ if (String(left).trim()) restoreFailed = String(left).trim();
1116
+ }
1117
+ try { fs.rmSync(patch, { force: true }); } catch { /* best effort */ }
1118
+ }
1119
+ if (restoreFailed) throw new Error(`mutation check could not restore the worktree: ${restoreFailed}`);
1120
+ return { results, skipped: "", ms: now() - t0 };
1121
+ }