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
package/bin/lean/lean.mjs CHANGED
@@ -10,27 +10,30 @@ import { hostname as osHostname } from "node:os";
10
10
  import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
- import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
13
+ import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
14
14
  import { createHash } from "node:crypto";
15
- import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
15
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
16
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
17
17
  import { namedPaths } from "./prefilter.mjs";
18
+ import { preflightProbes, preflightProblems } from "./preflight.mjs";
18
19
  import { resolveLane, isDefaultLane } from "./lane.mjs";
19
20
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
20
21
  import { repoKey } from "../runner.mjs";
21
22
  import { prepareToolchain } from "./toolchain.mjs";
22
- import { readTestMap, readTestMapAtRef, selectCommands, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
23
+ import { readTestMap, readTestMapAtRef, selectCommands, scopeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
24
+ import { runChecks } from "./precheck.mjs";
23
25
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
24
- import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
26
+ import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
25
27
  import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
26
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
28
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
29
+ import { unjustified } from "./enumerating.mjs";
27
30
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
28
31
  import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
29
32
  import { laneBackendId } from "./limits.mjs";
30
33
  import { SPLIT_LABEL } from "./refactor.mjs";
31
34
  import { writeHeavyShims, sumWaits } from "./heavy.mjs";
32
35
  import { priorityFromLabels } from "./sched.mjs";
33
- import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts } from "./context.mjs";
36
+ import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp } from "./context.mjs";
34
37
 
35
38
  /**
36
39
  * #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
@@ -41,7 +44,11 @@ export const RECOVER_HOW = Object.freeze({
41
44
  [OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
42
45
  [OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
43
46
  [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
47
+ [OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
48
+ [OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
44
49
  });
50
+ /** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
51
+ const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
45
52
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
46
53
  /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
47
54
  export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
@@ -120,7 +127,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
120
127
  * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
121
128
  * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
122
129
  * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
123
- * @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
130
+ * @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
131
+ * maxMinutes: lean.precheck.max_minutes, the round budget (#4331, else verify.precheck_total_seconds)
132
+ * @param {boolean} [o.closeParent] forge.yaml decomposition.close_parent (#3736): close a decomposed parent at decomposition (default false)
124
133
  * @param {"off"|"prefer"} [o.reproTest] forge.yaml lean.repro_test (#4271): prefer (default) freezes a red-green repro_test on bug lanes; off is today's contract
125
134
  * @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
126
135
  * @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
@@ -129,12 +138,13 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
129
138
  * @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
130
139
  * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
131
140
  * @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
132
- * @param {object} [o.recover] {code, attempt}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
133
- * posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note
141
+ * @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
142
+ * posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
143
+ * (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
134
144
  */
135
145
  export async function runCore(o) {
136
146
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
137
- toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
147
+ toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
138
148
  const now = io.now ?? (() => Date.now());
139
149
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
140
150
  if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
@@ -143,8 +153,8 @@ export async function runCore(o) {
143
153
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
144
154
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
145
155
  let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
146
- // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
147
- const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
156
+ // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
157
+ const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1, final: o.recover.final !== false } : null;
148
158
  const branch = `feat/issue-${issue}-lean`;
149
159
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
150
160
  const t0 = now();
@@ -173,7 +183,8 @@ export async function runCore(o) {
173
183
  const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
174
184
  return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
175
185
  };
176
- const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); } catch { /* labels are best effort */ } };
186
+ /** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
187
+ const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); return true; } catch { /* labels are best effort */ return false; } };
177
188
  const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
178
189
 
179
190
  // ---- lease (same FORGE:STATE lease the full path uses) ----
@@ -217,6 +228,7 @@ export async function runCore(o) {
217
228
  const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
218
229
  let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
219
230
  const waits = {};
231
+ let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
220
232
  const addWait = (phase, ms, queued = 0) => {
221
233
  if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
222
234
  if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
@@ -322,10 +334,11 @@ export async function runCore(o) {
322
334
  await emit(term);
323
335
  // #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
324
336
  // decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
325
- // (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
326
- if (recoverUnlabelled && reason === "ci-pending") {
337
+ // (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
338
+ // either launches the next attempt or relabels in settle.
339
+ if (recoverUnlabelled && recover.final && reason === "ci-pending") {
327
340
  try {
328
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${recoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
341
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
329
342
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
330
343
  } catch { /* best effort */ }
331
344
  }
@@ -341,14 +354,14 @@ export async function runCore(o) {
341
354
  }
342
355
  await releaseLease(reason, code);
343
356
  done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
344
- ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
357
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}), ...extra };
345
358
  return done;
346
359
  }
347
- /** #4297: what a recovery lane that did not merge tried and how it ended; the issue stays needs-human with this. */
348
- const recoveryEvidence = (reason, code, detail) => `Recovery pass for \`${recover.code}\` (attempt ${recover.attempt}) did not merge: it ended \`${reason}\`${code ? ` (\`${code}\`)` : ""}${String(detail ?? "").trim() ? `: ${String(detail).trim().split("\n")[0].slice(0, 300)}` : ""}. The issue stays \`needs-human\`.`;
360
+ /** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
361
+ const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
349
362
  async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
350
- try {
351
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${recoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
363
+ if (!recover || recover.final) try {
364
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
352
365
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
353
366
  } catch { /* best effort */ }
354
367
  return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
@@ -423,18 +436,25 @@ export async function runCore(o) {
423
436
  if (!io.host?.cores) return null;
424
437
  try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
425
438
  }
439
+ /**
440
+ * #4325: the lane env every engine-run command shares with the builder: shared package-manager stores (#4220), the run
441
+ * TMPDIR (#4104), FORGE_HOST_PARALLELISM (#4182) and the pinned toolchain (#4163); the engine's own keys win over the stores.
442
+ */
443
+ function laneEnv() {
444
+ let deps = {};
445
+ try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
446
+ const par = verifyParallelism();
447
+ return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
448
+ }
426
449
  /** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
427
450
  async function callRunner(args) {
428
451
  let pauses = 0;
429
452
  for (;;) {
430
453
  // #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
431
454
  const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
432
- const par = verifyParallelism(); // #4182
433
- let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
434
- try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
435
455
  // #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
436
456
  const heavy = heavyEnv ? { ...heavyEnv, FORGE_LANE_PHASE: args.role === "builder" ? stage : String(args.role ?? "step"), FORGE_LANE_ISSUE: String(issue), FORGE_LANE_PRIORITY: String(lanePriority()) } : {};
437
- try { return await io.runner({ ...args, env: { ...deps, FORGE_DOCKER_LABELS: labels, ...(runTmp ? { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
457
+ try { return await io.runner({ ...args, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
438
458
  catch (e) {
439
459
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
440
460
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
@@ -504,7 +524,8 @@ export async function runCore(o) {
504
524
  let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
505
525
  const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
506
526
  let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
507
- const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
527
+ let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
528
+ const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + CONTINUE_NOTE.replace("BASE", lane);
508
529
  async function builder(n, prompt, resume, live = false, restart = implementRestart) {
509
530
  let timeoutRetries = 0, p = prompt;
510
531
  for (;;) {
@@ -578,11 +599,13 @@ export async function runCore(o) {
578
599
  try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
579
600
  return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
580
601
  }
581
- if (String(v?.state).toUpperCase() !== "OPEN") continue;
582
- // #4153: an open decomposed parent resolves to its open sub-issues; it is satisfied once they all close.
583
- const subs = isDecomposedRecord(v) ? await decomposedSubissues(d, io) : null;
602
+ // #4153 / #4337: a decomposed parent, open or closed, resolves to its open sub-issues; it is satisfied once they all
603
+ // close. Without a usable trail the parent itself is the dependency, even when closed (fail closed).
604
+ const decomposed = isDecomposedRecord(v) && ["OPEN", "CLOSED"].includes(String(v?.state).toUpperCase());
605
+ if (!decomposed && String(v?.state).toUpperCase() !== "OPEN") continue;
606
+ const subs = decomposed ? await decomposedSubissues(d, io) : null;
584
607
  const recs = [];
585
- if (!subs) recs.push([d, v]);
608
+ if (!subs) recs.push([d, { ...v, state: "OPEN" }]);
586
609
  else for (const n of subs) {
587
610
  try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
588
611
  return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
@@ -627,8 +650,16 @@ export async function runCore(o) {
627
650
  }
628
651
  } catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
629
652
  T.decisions.push(`Builder block: split into ${refs(filed)}`);
630
- try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); } catch { /* best effort */ }
631
- await label("decomposed");
653
+ let marked = false;
654
+ try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); marked = true; } catch { /* best effort */ }
655
+ // #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
656
+ if (await label("decomposed") && marked && closeParent) {
657
+ try {
658
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned", "--comment",
659
+ `Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
660
+ T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
661
+ } catch { /* best effort: the parent stays open, as before #4337 */ }
662
+ }
632
663
  return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
633
664
  }
634
665
  return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
@@ -693,7 +724,13 @@ export async function runCore(o) {
693
724
  try {
694
725
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
695
726
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
696
- if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
727
+ if (String(iss.state).toUpperCase() === "CLOSED") {
728
+ // #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
729
+ // them). A trusted revocation reopens it and the run continues. An unreadable trail lists none (fail closed).
730
+ if (!isDecomposedRecord(iss)) return finish("invalid", "issue already closed");
731
+ if (await reopenRevokedDecomposition(issue, io)) iss.state = "OPEN";
732
+ else return finish("decomposed", "closed decomposed tracker; dependents wait on its sub-issues", { code: OUTCOME_CODES.DECOMPOSED, subissues: (await decomposedSubissues(issue, io)) ?? [] });
733
+ }
697
734
 
698
735
  try {
699
736
  // ---- 1. claim ----
@@ -828,7 +865,7 @@ export async function runCore(o) {
828
865
  if (typeof envSetup === "string" && envSetup.trim()) {
829
866
  let r;
830
867
  if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
831
- else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: { ...(runTmp ? { TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}) } })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
868
+ else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: laneEnv() })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
832
869
  ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
833
870
  if (r?.exit !== 0) {
834
871
  const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
@@ -862,7 +899,7 @@ export async function runCore(o) {
862
899
  if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
863
900
  return null;
864
901
  }
865
- const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
902
+ const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
866
903
  const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
867
904
  ev({ event: "REPRO_RED_GREEN", ...words });
868
905
  T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
@@ -894,7 +931,9 @@ export async function runCore(o) {
894
931
  if (ctxCfg.mode === "off" || !io.contextFacts) return null;
895
932
  try {
896
933
  const headSha = String(await git(["rev-parse", base])).trim();
897
- const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { updatedAt: iss.updatedAt ?? null, attempts: priorAttempts(iss.body, trustedComments) },
934
+ // #4316: linked-issue states are part of the key; a failed lookup only drops them from it
935
+ const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
936
+ const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
898
937
  gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
899
938
  const p = r.pack;
900
939
  const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
@@ -949,6 +988,23 @@ export async function runCore(o) {
949
988
  }
950
989
  T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
951
990
  }
991
+ /** #4328: probe the host for the tools and modules a contract's validate commands need; the misses, [] without io.probeCommand. */
992
+ async function preflightValidates(contract) {
993
+ if (!io.probeCommand || !contract || typeof contract !== "object") return [];
994
+ const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
995
+ const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
996
+ if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
997
+ const env = laneEnv(), missing = new Set(), misses = []; // #4325: the env the validate commands run with
998
+ for (const p of probes) {
999
+ if (p.needs && missing.has(p.needs)) continue;
1000
+ let r;
1001
+ try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
1002
+ if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
1003
+ if (p.kind === "command") missing.add(p.tool);
1004
+ misses.push(p);
1005
+ }
1006
+ return misses;
1007
+ }
952
1008
  /**
953
1009
  * The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
954
1010
  * (#4265): a trusted comment that the engine did not post came after it.
@@ -1046,9 +1102,10 @@ export async function runCore(o) {
1046
1102
  }
1047
1103
  await label("building");
1048
1104
  hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
1105
+ enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
1049
1106
  facts = await factsPath([wt, cwd], io.fs?.exists);
1050
1107
  const ctxPack = frozen ? null : await contextPackStep();
1051
- const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1108
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1052
1109
  if (!frozen) {
1053
1110
  await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
1054
1111
  planned = true;
@@ -1070,12 +1127,23 @@ export async function runCore(o) {
1070
1127
  let contract = await readContract();
1071
1128
  await refreshIssue("contract validation");
1072
1129
  let vc = validateContract(contract, issueText, { repro: reproOn() });
1073
- if (!vc.ok && sessionId) {
1074
- T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
1075
- await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, planPrompt);
1130
+ const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
1131
+ let misses = missed;
1132
+ if ((!vc.ok || missed.length) && sessionId) {
1133
+ const problems = [...vc.problems, ...preflightProblems(missed)];
1134
+ T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
1135
+ await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt);
1076
1136
  contract = await readContract();
1077
1137
  await refreshIssue("contract validation");
1078
1138
  vc = validateContract(contract, issueText, { repro: reproOn() });
1139
+ if (missed.length) misses = await preflightValidates(contract);
1140
+ }
1141
+ if (missed.length) {
1142
+ ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind })), corrected: misses.length === 0 });
1143
+ if (misses.length) {
1144
+ T.anomalies.push(`Validate preflight: the host lacks ${misses.map((m) => m.tool).join(", ")} after the contract correction (advisory; a failed rerun ends env.tool_missing)`);
1145
+ T.decisions.push(`Validate preflight left ${misses.length} miss(es) after one correction; contract frozen anyway`);
1146
+ }
1079
1147
  }
1080
1148
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
1081
1149
  if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
@@ -1092,7 +1160,7 @@ export async function runCore(o) {
1092
1160
  }
1093
1161
  T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1094
1162
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1095
- const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
1163
+ const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
1096
1164
  liveKeys = ["implementation"];
1097
1165
  stage = "implement";
1098
1166
  await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
@@ -1117,23 +1185,29 @@ export async function runCore(o) {
1117
1185
  const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1118
1186
  // #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
1119
1187
  const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
1120
- const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return m.length ? { commands: m, source: "test-map" } : { commands: learnedCommands, source: "learned" }; };
1188
+ // #4331: a learned command the map covers runs only when its globs match a changed file; unmapped ones always run (#4315)
1189
+ const pickChecks = () => { const m = scopeChecks(testMap, files, learnedCommands); return { ...m, selected: testMap ? selectCommands(testMap, files).length : 0 }; };
1121
1190
  let picked = pickChecks();
1122
1191
  // #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
1123
1192
  const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
1124
- if (picked.source === "test-map") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.commands.length} of ${testMap.entries.length} entries`);
1193
+ if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length - picked.dropped.length} learned` : ""}${picked.dropped.length ? `; ${picked.dropped.length} mapped learned command(s) not matching the changed files left to CI` : ""}`);
1125
1194
  if (picked.commands.length && io.runCommand) {
1195
+ precheckRan = true;
1126
1196
  for (let round = 0; ; round++) {
1127
1197
  await renew();
1128
1198
  if (round) picked = pickChecks();
1129
1199
  const precheckCommands = picked.commands;
1130
- const results = [];
1131
- for (const command of precheckCommands) {
1132
- let r;
1133
- try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
1134
- results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
1135
- }
1136
- ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source === "test-map" ? { source: "test-map" } : {}) });
1200
+ // #4318/#4331: one round has a total budget (lean.precheck.max_minutes, else verify.precheck_total_seconds); the
1201
+ // commands run concurrently up to the host worker cap under ONE heavy slot; the rest are deferred to CI, not failed
1202
+ const maxMin = Number(o.precheck?.maxMinutes), totalMs = (maxMin > 0 ? maxMin * 60 : Number(vcfg.precheckTotalSeconds) || 600) * 1000;
1203
+ const parallelism = Number(verifyParallelism()) || 1;
1204
+ const round$ = await heavyStep("precheck", () => runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: vcfg.precheckTimeoutSeconds * 1000, now,
1205
+ run: ({ command, timeoutMs }) => io.runCommand({ command, cwd: wt, timeoutMs, env: laneEnv() }) }));
1206
+ const { results, deferred } = round$;
1207
+ precheckMs += round$.ms; precheckDeferred += deferred.length;
1208
+ if (deferred.length) T.anomalies.push(`deferred-to-ci: pre-PR check budget ${totalMs / 1000} s reached; left to CI: ${deferred.map((c) => `\`${c}\``).join(", ")}`);
1209
+ ev({ event: "PRECHECK_COMMANDS", round, results: [...results.map(({ command, rc, ms }) => ({ command, rc, ms })), ...deferred.map((command) => ({ command, status: "deferred-to-ci" }))],
1210
+ ms: round$.ms, parallelism, ...(picked.source !== "learned" ? { source: picked.source } : {}) });
1137
1211
  verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
1138
1212
  const failed = results.filter((r) => r.rc !== 0);
1139
1213
  if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
@@ -1198,6 +1272,38 @@ export async function runCore(o) {
1198
1272
  return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
1199
1273
  }
1200
1274
  }
1275
+ // ---- 3d. enumerating tests (#4322): new symbols that tests listing their siblings do not name; one resume, then an
1276
+ // anomaly (CI stays the gate). Sequence numbers the base already uses: renumbered at hand-off, still colliding = base.conflict.
1277
+ if (io.enumeratingMissing && !isSplit) {
1278
+ const missingNow = async () => unjustified((await io.enumeratingMissing({ cwd: wt, base }).catch(() => null)) ?? [], result?.not_members);
1279
+ let missing = await missingNow();
1280
+ if (missing.length && sessionId) {
1281
+ ev({ event: "ENUMERATING_MISSING", count: missing.length });
1282
+ T.decisions.push(`Enumerating test(s) miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}; builder resumed once`);
1283
+ await builder(++attemptN, missingEntriesPrompt(missing, vcfg.mode), sessionId);
1284
+ await upsertStage("implementation");
1285
+ result = (await readResult()) ?? result;
1286
+ missing = await missingNow();
1287
+ }
1288
+ if (missing.length) T.anomalies.push(`Enumerating test(s) still miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}`);
1289
+ }
1290
+ /** #4322: null (nothing to do), {done} (terminal), or {renumbered: true}. Fetches the lane, so the check sees its tip. */
1291
+ async function sequenceHandoff(trigger) {
1292
+ if (!io.sequenceCheck || isSplit) return null;
1293
+ try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
1294
+ const collisions = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
1295
+ if (!collisions.length) return null;
1296
+ const before = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
1297
+ ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
1298
+ T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ")}; builder renumbers`);
1299
+ await builder(++attemptN, sequencePrompt(collisions, lane, vcfg.mode), sessionId ?? null);
1300
+ result = (await readResult()) ?? result;
1301
+ const v = (await io.sequenceVerify?.({ cwd: wt, base, before }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
1302
+ if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the builder renumbered (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
1303
+ files = await changedFiles();
1304
+ return { renumbered: true };
1305
+ }
1306
+ { const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
1201
1307
  const structureLine = structureNote(structure);
1202
1308
 
1203
1309
  // ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
@@ -1287,7 +1393,12 @@ export async function runCore(o) {
1287
1393
  const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1288
1394
  const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
1289
1395
  let d = null, first = null;
1290
- for (let t = 0; t < 2; t++) {
1396
+ for (let t = 0; t < 3; t++) {
1397
+ // #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
1398
+ if (t === 2) {
1399
+ if (d.state !== "review.incomplete" || !evidencePathOnly(d.failures.map((f) => f.detail))) break;
1400
+ T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
1401
+ }
1291
1402
  // #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
1292
1403
  const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
1293
1404
  await renew();
@@ -1306,6 +1417,21 @@ export async function runCore(o) {
1306
1417
  }
1307
1418
  return d;
1308
1419
  }
1420
+ /** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
1421
+ async function rerunFailedValidates(contract) {
1422
+ if (!io.runCommand) return null;
1423
+ const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
1424
+ const env = laneEnv();
1425
+ for (const v of contract.validate ?? []) {
1426
+ const r = runs.find((x) => x?.id === v.id);
1427
+ if (!r || r.exit === undefined || r.exit === 0) continue;
1428
+ let x;
1429
+ try { x = await heavyStep("precheck", () => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env })); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
1430
+ out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
1431
+ }
1432
+ if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
1433
+ return out;
1434
+ }
1309
1435
  /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
1310
1436
  async function gate() {
1311
1437
  await refreshIssue("conformance");
@@ -1314,14 +1440,20 @@ export async function runCore(o) {
1314
1440
  const amendProblems = await amend();
1315
1441
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1316
1442
  const hotspots = await hotspotsOf({ base, paths: files });
1317
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
1443
+ const validateRuns = await rerunFailedValidates(effective);
1444
+ const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
1445
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas });
1318
1446
  Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
1319
- const pre = [...pc.failures, ...amendProblems];
1447
+ // #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
1448
+ // never a conformance failure, never a builder resume
1449
+ const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
1450
+ const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems];
1320
1451
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
1321
1452
  if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
1322
1453
  // shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
1323
1454
  const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
1324
1455
  if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
1456
+ if (envFails.length) return { state: "environment", by: "precheck", failures: envFails };
1325
1457
  // #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
1326
1458
  if (triggerMode !== "off") {
1327
1459
  let t = null;
@@ -1366,9 +1498,21 @@ export async function runCore(o) {
1366
1498
  }
1367
1499
  ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
1368
1500
  }
1369
- /** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
1501
+ /** #4323: a validate command the host could not run ends needs-human env.tool_missing (recoverable), never a builder resume. */
1502
+ // #4334: a repro test the harness could not run on head (and nothing else) ends env.repro_unrunnable (recoverable)
1503
+ async function envNeedsHuman(failures) {
1504
+ if (failures.every((f) => f.code === OUTCOME_CODES.ENV_REPRO_UNRUNNABLE)) {
1505
+ T.anomalies.push("Environment error: the repro harness could not run the repro test on head");
1506
+ return await needsHuman(`Environment error (not a conformance failure): the repro harness could not run the repro test on head; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nRun the repro command through the repo's own toolchain and installed dependencies, then rerun.`, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE);
1507
+ }
1508
+ const tools = [...new Set(failures.filter((f) => f.tool).map((f) => f.tool))].join(", ");
1509
+ T.anomalies.push(`Environment error: validate command(s) could not run, the host is missing ${tools}`);
1510
+ return await needsHuman(`Environment error (not a conformance failure): the host is missing ${tools}, so validate command(s) could not run; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nInstall it on the host or run the check through the repo's own toolchain, then rerun.`, OUTCOME_CODES.ENV_TOOL_MISSING);
1511
+ }
1512
+ /** Shape the base-sync path expects: {unusable: state} when no usable review, {env: failures} for a missing host tool, else {fail, verdict, reason, failures}. */
1370
1513
  const doReview = async () => {
1371
1514
  const d = await gate();
1515
+ if (d.state === "environment") return { env: d.failures };
1372
1516
  if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
1373
1517
  return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
1374
1518
  };
@@ -1386,6 +1530,7 @@ export async function runCore(o) {
1386
1530
  if (outcome.state === "conforms") break;
1387
1531
  const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
1388
1532
  const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1533
+ if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
1389
1534
  if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
1390
1535
  if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
1391
1536
  degraded = { state: outcome.state, missing: outcome.missing ?? [] };
@@ -1518,7 +1663,7 @@ export async function runCore(o) {
1518
1663
  how = "clean merge of the base into the branch";
1519
1664
  } else {
1520
1665
  T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
1521
- const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge. Do not push.`;
1666
+ const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge.${sequenceSyncNote(files)} Do not push.`;
1522
1667
  await builder(++attemptN, prompt, sessionId ?? null);
1523
1668
  if (await unresolved()) {
1524
1669
  await gitOut(["merge", "--abort"]);
@@ -1528,6 +1673,9 @@ export async function runCore(o) {
1528
1673
  const r2 = await readResult();
1529
1674
  tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
1530
1675
  }
1676
+ const sq = await sequenceHandoff(`base-sync ${syncs}`); // #4322: a clean text merge can still bring a taken number
1677
+ if (sq?.done) return sq.done;
1678
+ if (sq?.renumbered) { how += "; builder renumbered sequence-numbered files"; }
1531
1679
  const after = await gitOut(["rev-parse", "HEAD"]);
1532
1680
  if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
1533
1681
  if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
@@ -1538,14 +1686,16 @@ export async function runCore(o) {
1538
1686
  rereview = "refactor-verify re-run against the new base: ok";
1539
1687
  }
1540
1688
  if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
1541
- if (!isSplit && (!clean || ciFixes)) {
1689
+ if (!isSplit && (!clean || sq?.renumbered || ciFixes)) {
1542
1690
  let rv = await doReview();
1691
+ if (rv.env) return await envNeedsHuman(rv.env);
1543
1692
  if (rv?.fail) {
1544
1693
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
1545
1694
  await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
1546
1695
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
1547
1696
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
1548
1697
  rv = await doReview();
1698
+ if (rv.env) return await envNeedsHuman(rv.env);
1549
1699
  }
1550
1700
  if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
1551
1701
  if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
@@ -1582,6 +1732,7 @@ export async function runCore(o) {
1582
1732
  }
1583
1733
  const rv = await doReview(); // never merge an unreviewed head
1584
1734
  if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
1735
+ if (rv.env) return await envNeedsHuman(rv.env);
1585
1736
  if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
1586
1737
  await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
1587
1738
  localHead = await gitOut(["rev-parse", "HEAD"]);