forgedock 1.34.0 → 1.36.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 (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
package/bin/lean/lean.mjs CHANGED
@@ -16,40 +16,43 @@ import { createHash } from "node:crypto";
16
16
  import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
17
17
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
18
18
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
19
+ import { checksAt } from "./postdeploy-check.mjs";
19
20
  import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
20
- import { resolveLane, isDefaultLane } from "./lane.mjs";
21
+ import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
21
22
  import { splitGate, rationaleLine } from "./decompose.mjs";
22
23
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
23
24
  import { repoKey } from "../runner.mjs";
24
25
  import { prepareToolchain } from "./toolchain.mjs";
25
26
  import { toolStats } from "./toolstats.mjs";
26
- import { readTestMap, readTestMapAtRef, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
27
- import { runChecks, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
27
+ import { readTestMap, readTestMapAtRef, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
28
+ import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
28
29
  import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
29
30
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
30
31
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
31
32
  import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
32
33
  import { hostCapabilityBlock } from "./execution.mjs";
33
- 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, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt } from "./prompts.mjs";
34
+ 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, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
34
35
  import { postMergeCommands } from "./postmerge.mjs";
35
36
  import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
36
37
  import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
37
- import { makeTurnBudget, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
38
+ import { makeTurnBudget, TURN_BUDGET_DEFAULTS, scaledExtra, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
38
39
  import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog } from "./pitfalls.mjs";
39
40
  import { unjustified } from "./enumerating.mjs";
40
41
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
41
- import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
42
+ import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
42
43
  import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
43
44
  import { laneBackendId } from "./limits.mjs";
44
45
  import { SPLIT_LABEL } from "./refactor.mjs";
45
46
  import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
46
47
  import { priorityFromLabels } from "./sched.mjs";
48
+ import { assignArm } from "../experiment.mjs";
47
49
  import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
48
50
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
49
51
  import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
50
52
  import { isExternalKill } from "./hostkill.mjs";
51
53
  import { buildResumeDigest } from "./resume.mjs";
52
54
  import { lightEligibility } from "./light.mjs";
55
+ import { untrackToolState, unstageToolState } from "./toolstate.mjs";
53
56
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
54
57
 
55
58
  /**
@@ -168,6 +171,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
168
171
  * @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
169
172
  * @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
170
173
  * @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
174
+ * @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
171
175
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
172
176
  * @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
173
177
  * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
@@ -219,6 +223,7 @@ export async function runCore(o) {
219
223
  let sessionId = null;
220
224
 
221
225
  const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
226
+ const expTag = o.experiment ? { experiment: o.experiment.id, arm: assignArm(o.experiment, issue) } : {}; // #4629: same arm for the whole run
222
227
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
223
228
  let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
224
229
  const ghc = (args) => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache);
@@ -236,7 +241,7 @@ export async function runCore(o) {
236
241
  const c = sessionCostDelta(sessionCosts, { resume, sessionId: r?.sessionId ?? null, cost: num(r?.costUsd) });
237
242
  const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, trigger, ...(restartOf ? { restart_of: restartOf } : {}), usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
238
243
  cost_usd: num(r?.costUsd), cost_usd_delta: c.delta, cost_usd_session: c.session, model: r?.model ?? null, turns: num(r?.turns), ...(mode ? { mode } : {}),
239
- ...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
244
+ ...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name, ...expTag }; // #4410, #4629
240
245
  if (phase === "implement") { builderTriggers.push({ trigger, restart_of: restartOf }); impl.cost += c.delta ?? 0; if (r?.model && r.model !== "default") impl.models.add(r.model); tb.note(num(r?.turns), stage === "implement"); }
241
246
  ev(rec); await emit(rec);
242
247
  modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
@@ -415,7 +420,7 @@ export async function runCore(o) {
415
420
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
416
421
  const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
417
422
  conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
418
- context_pack: ctxCfg.mode, ...ctxAb, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
423
+ context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
419
424
  ...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
420
425
  ev(term);
421
426
  await emit(term);
@@ -577,6 +582,7 @@ export async function runCore(o) {
577
582
 
578
583
  /** git push with transient retry (#4114): 4 attempts; permission/protected-branch rejections fail at once. */
579
584
  const gitPush = async (args) => {
585
+ if (!args.includes("--delete")) await untrackToolState({ git, base, ev }); // #4590: never push .forge-lean/ or tool caches this branch added
580
586
  try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
581
587
  catch (e) {
582
588
  if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
@@ -589,7 +595,7 @@ export async function runCore(o) {
589
595
  async function checkpoint(reason) {
590
596
  try {
591
597
  const dirty = String(await git(["status", "--porcelain"])).trim();
592
- if (dirty) { await git(["add", "-A"]); await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); }
598
+ if (dirty) { await git(["add", "-A"]); if (await unstageToolState(git)) await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); } // #4590: tool state is never staged
593
599
  const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
594
600
  if (ahead) await gitPush(["push", "-u", "origin", branch]);
595
601
  ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
@@ -633,7 +639,7 @@ export async function runCore(o) {
633
639
  let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
634
640
  let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
635
641
  let resumeNote = ""; // #4400: the resume digest of the last run (resume.mjs), "" on a first run or with context_pack off
636
- const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
642
+ const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + invariantsBuildRule(vcfg.invariants) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
637
643
  /** #4390: builder calls stopped at the context ceiling restart fresh; planning (and its corrections) run uncapped. */
638
644
  const UNCEILED = ["plan", "contract_correction", "block_correction"], CONTEXT_RESTARTS_MAX = 3;
639
645
  async function builder(n, prompt, resume, live = false, restart = implementRestart, opts = {}) {
@@ -907,6 +913,7 @@ export async function runCore(o) {
907
913
  }
908
914
  ghCache = io.ghCache?.(runTmp) ?? null;
909
915
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
916
+ await emit({ event: "RUN_START", run: `core_${issue}_${t0}`, lane, path: "core" }); // #4630: lifecycle event stream
910
917
  try {
911
918
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
912
919
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
@@ -943,6 +950,17 @@ export async function runCore(o) {
943
950
  if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
944
951
  } catch { /* no resume info: start fresh */ }
945
952
  // An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
953
+ // #4593: a shipped milestone lane (fully merged into staging) cannot take new work; follow the open PR's live base.
954
+ if (resumed?.base && resumed.base !== lane && !isDefaultLane(resumed.base, defaults)) {
955
+ const staging = branches.staging || DEFAULT_STAGING_BRANCH;
956
+ let baseOnOrigin = false;
957
+ try { baseOnOrigin = !!String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/${resumed.base}`], { cwd })).trim(); } catch { /* fail closed */ }
958
+ if (baseOnOrigin && await isShippedLane({ lane, staging, io, cwd }) && !(await isShippedLane({ lane: resumed.base, staging, io, cwd }))) {
959
+ T.decisions.push(`Lane ${lane} has shipped (merged into ${staging}); following open PR #${resumed.pr} base ${resumed.base}`);
960
+ ev({ event: "LANE_FOLLOWED_PR_BASE", from: lane, to: resumed.base, pr: resumed.pr });
961
+ lane = resumed.base; base = `origin/${lane}`;
962
+ }
963
+ }
946
964
  if (resumed?.base && resumed.base !== lane) {
947
965
  return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
948
966
  isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
@@ -1393,15 +1411,15 @@ export async function runCore(o) {
1393
1411
  enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
1394
1412
  facts = await factsPath([wt, cwd], io.fs?.exists);
1395
1413
  let ctxPack = frozen || lightTry ? null : await contextPackStep();
1396
- const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1414
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + invariantsPlanNote(vcfg.invariants) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1397
1415
  let lightDone = false, lightCommits = false;
1398
1416
  if (lightTry) {
1399
1417
  liveKeys = ["implementation"]; stage = "implement";
1400
1418
  tb.setSize(recoverySize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4505
1401
1419
  const lr = await runLightStep({ title: iss.title, issueText, riskPaths, predictedFiles: namedPaths(iss.body ?? ""), cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
1402
- build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
1420
+ build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + invariantsBuildRule(vcfg.invariants), null, true, undefined, { trigger: "build" }),
1403
1421
  readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
1404
- runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
1422
+ runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: mergeChecks(vcfg.invariants ?? [], cmds).commands, parallelism: Number(verifyParallelism()) || 1, now, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
1405
1423
  run: ({ command, timeoutMs, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, timeoutMs, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
1406
1424
  fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
1407
1425
  apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
@@ -1412,6 +1430,7 @@ export async function runCore(o) {
1412
1430
  if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
1413
1431
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
1414
1432
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
1433
+ await emit({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
1415
1434
  ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
1416
1435
  T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
1417
1436
  try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
@@ -1493,22 +1512,26 @@ export async function runCore(o) {
1493
1512
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
1494
1513
  const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
1495
1514
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
1515
+ await emit({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
1496
1516
  T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
1497
1517
  } else {
1498
1518
  T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
1499
1519
  }
1500
1520
  T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
1501
- tb.setSize(recoverySize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4392, #4505: next size on a budget recovery
1521
+ { // #4392, #4505: next size on a budget recovery; #4587: a big L contract and an L recovery get extra turns
1522
+ const sz = recoverySize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover);
1523
+ tb.setSize(sz, scaledExtra({ size: sz, contract: frozen, recover, base: (turnBudgets ?? {}).L ?? TURN_BUDGET_DEFAULTS.L }));
1524
+ }
1502
1525
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1503
1526
  // #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
1504
- if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
1527
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, invariants: vcfg.invariants ?? [], maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
1505
1528
  route = routeBuilder({ contract: frozen, riskPaths, cfg: routingCfg, topModel: builderModel }); // #4394
1506
1529
  {
1507
1530
  const rec = { event: "BUILDER_ROUTE", mode: route.mode, class: route.class, tier: route.tier, model: route.model ?? "default", risk: route.risk };
1508
1531
  ev(rec); await emit(rec);
1509
1532
  T.decisions.push(`Builder routed to the ${route.tier} tier (${route.class}; ${route.model ?? "CLI default"})`);
1510
1533
  }
1511
- const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
1534
+ const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + invariantsBuildRule(vcfg.invariants) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
1512
1535
  liveKeys = ["implementation"];
1513
1536
  stage = "implement";
1514
1537
  if (!lightDone) await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
@@ -1713,6 +1736,46 @@ export async function runCore(o) {
1713
1736
  return false;
1714
1737
  }
1715
1738
  await duplicateGate("hand-off");
1739
+ /** #4591: lane-added migrations the repo's generator did not produce: one builder round (stage "sequence"), then an anomaly. */
1740
+ async function migrationGate(trigger) {
1741
+ if (!io.migrationGenerator || isSplit) return;
1742
+ const found = async () => (await io.migrationGenerator({ cwd: wt, base }).catch(() => null)) ?? [];
1743
+ let bad = await found();
1744
+ if (!bad.length) return;
1745
+ ev({ event: "MIGRATION_HAND_NAMED", trigger, count: bad.length });
1746
+ if (sessionId) {
1747
+ T.decisions.push(`Hand-named migration(s) (${trigger}): ${bad.slice(0, 5).join("; ")}; builder resumed once`);
1748
+ await fixRound("sequence", migrationGeneratorPrompt(bad, vcfg.mode), [], "sequence", bad.join("\n"));
1749
+ result = (await readResult()) ?? result;
1750
+ files = await changedFiles();
1751
+ bad = await found();
1752
+ }
1753
+ if (bad.length) T.anomalies.push(`Hand-named migration(s) remain (${trigger}): ${bad.slice(0, 5).join("; ")}`);
1754
+ else await gatePassed("sequence");
1755
+ }
1756
+ await migrationGate("hand-off");
1757
+ /** #4596: keys added to a non-en locale catalog as English copies: one translate round, then an anomaly. True when a fix round ran. */
1758
+ async function i18nGate(trigger) {
1759
+ if (!io.englishCopies || isSplit) return false;
1760
+ const found = async () => (await io.englishCopies({ cwd: wt, base }).catch(() => null)) ?? [];
1761
+ let cp = await found();
1762
+ const brief = () => cp.slice(0, 5).map((d) => `${d.path}:${d.key}`).join("; ");
1763
+ if (cp.length && sessionId) {
1764
+ ev({ event: "I18N_ENGLISH_COPIES", trigger, count: cp.length });
1765
+ T.decisions.push(`English copies in locale catalogs (${trigger}): ${brief()}; builder resumed once`);
1766
+ await fixRound("duplicate", i18nCopiesPrompt(cp, vcfg.mode), [], "duplicate", cp.map((d) => `english copy \`${d.key}\` in ${d.path}`).join("\n"));
1767
+ result = (await readResult()) ?? result;
1768
+ files = await changedFiles();
1769
+ const before = cp;
1770
+ cp = await found();
1771
+ if (cp.length) T.anomalies.push(`English copies remain in locale catalogs (${trigger}): ${brief()}`);
1772
+ else await gatePassed("i18n");
1773
+ return before.length > 0;
1774
+ }
1775
+ if (cp.length) T.anomalies.push(`English copies in locale catalogs (${trigger}): ${brief()}`);
1776
+ return false;
1777
+ }
1778
+ await i18nGate("hand-off");
1716
1779
  /** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
1717
1780
  async function postMergeGate(trigger) {
1718
1781
  const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
@@ -1721,7 +1784,7 @@ export async function runCore(o) {
1721
1784
  await renew();
1722
1785
  results = [];
1723
1786
  for (const command of commands) {
1724
- let x; try { x = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env: laneEnv() })); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
1787
+ let x; try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command, cwd: wt, timeoutMs, env: laneEnv() }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
1725
1788
  results.push({ command, rc: Number(x?.rc ?? 1), output: String(x?.output ?? "") });
1726
1789
  }
1727
1790
  ev({ event: "POST_MERGE_CHECKS", trigger, round, results: results.map(({ command, rc }) => ({ command, rc })) });
@@ -1745,11 +1808,13 @@ export async function runCore(o) {
1745
1808
  // cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
1746
1809
  let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
1747
1810
  const ciSeenRuns = [];
1811
+ const ciReruns = new Map(); // #4586: head -> {rr} of the one rerun of its failed jobs, spent before any fix round
1748
1812
  let ciGateNames; // #4387: checks that gate CI verification, read once per lane
1749
1813
  /** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
1750
1814
  async function ciVerify(trigger) {
1751
1815
  let fixes = 0;
1752
1816
  for (;;) {
1817
+ await untrackToolState({ git, base, ev }); // #4621: gitPush's untrack commit would move HEAD after `head` was read; do it first so head is the pushed tip
1753
1818
  const head = String(await git(["rev-parse", "HEAD"])).trim();
1754
1819
  if (head === ciVerifiedHead || ciFellBack) return { fixes };
1755
1820
  files = await changedFiles();
@@ -1757,7 +1822,7 @@ export async function runCore(o) {
1757
1822
  const verifyArgs = { io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns, gate: ciGateNames,
1758
1823
  push: () => gitPush(["push", "-u", "origin", branch]),
1759
1824
  onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } };
1760
- const v = await runCiVerify(verifyArgs);
1825
+ let v = await runCiVerify(verifyArgs);
1761
1826
  if (v.runId != null) ciSeenRuns.push(v.runId);
1762
1827
  const rec = { event: "CI_VERIFY", trigger, mode: vcfg.mode, round: ciRounds, result: v.result, skipped: v.result === "skipped", run_url: v.runUrl ?? null, failures: v.failures.map((f) => f.job) };
1763
1828
  ev(rec); await emit({ ...rec, phase: "verify" });
@@ -1785,28 +1850,44 @@ export async function runCore(o) {
1785
1850
  return { fixes: fixes + 1 };
1786
1851
  }
1787
1852
  // fail
1788
- const jobs = v.failures.map((f) => f.job).join(", ") || "unknown job";
1853
+ // #4586: `verify.flaky` failures are reported, never block; the rest are rerun once before any round is counted.
1854
+ let sp = splitFlaky(v.failures, vcfg.flaky);
1855
+ const greenNow = (note) => { ciVerifiedHead = head; row(`CI green at ${head.slice(0, 12)}${note}`); return gatePassed("ci_red", "merge_ci"); };
1856
+ const reportFlaky = () => { if (!sp.flaky.length) return; const n = sp.flaky.map((f) => f.job); T.anomalies.push(`Known-flaky CI failure(s) reported, not blocking (verify.flaky): ${n.join(", ")}`); ev({ event: "CI_FLAKY_REPORTED", trigger, head, jobs: n }); };
1857
+ reportFlaky();
1858
+ if (!sp.own.length) { await greenNow(" (only verify.flaky failures)"); return { fixes }; }
1859
+ if (!isSplit && !ciReruns.has(head)) {
1860
+ const own = new Set(sp.own.map((f) => f.job)), vOwn = { ...v, failures: sp.own, failedChecks: v.failedChecks?.filter((c) => own.has(c.name)) };
1861
+ const rr = await rerunFailedOnce({ io, v: vOwn });
1862
+ ciReruns.set(head, { rr });
1863
+ const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(vOwn) }) : null;
1864
+ if (rr.detail !== "no rerun seam") ev({ event: "CI_FLAKE_RERUN", trigger, head, runs: rr.runs, jobs: rr.jobs, result: v2?.result ?? "rerun-failed", detail: v2?.detail ?? rr.detail });
1865
+ if (v2?.result === "pass") {
1866
+ await greenNow(" after a rerun of the failed jobs");
1867
+ T.decisions.push(`CI verification red (${sp.own.map((f) => f.job).join(", ")}); green after one rerun of the failed jobs (flake), no fix round spent`);
1868
+ return { fixes };
1869
+ }
1870
+ if (v2?.result === "timeout") return { done: await finish("ci-pending", `CI rerun of the failed jobs on ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
1871
+ if (v2?.result === "fail") {
1872
+ v = v2; if (v.runId != null) ciSeenRuns.push(v.runId);
1873
+ sp = splitFlaky(v.failures, vcfg.flaky); reportFlaky();
1874
+ if (!sp.own.length) { await greenNow(" (only verify.flaky failures)"); return { fixes }; }
1875
+ }
1876
+ }
1877
+ const jobs = sp.own.map((f) => f.job).join(", ") || "unknown job";
1789
1878
  row(`CI red (${jobs})`);
1790
1879
  if (ciRounds >= vcfg.maxRounds || !sessionId) {
1791
- return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${v.failures.slice(0, 10).map((f) => `- ${f.job}${f.url ? ` (${f.url})` : ""}`).join("\n")}`, OUTCOME_CODES.CI_RED) };
1880
+ return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${sp.own.slice(0, 10).map((f) => `- ${f.job}${f.url ? ` (${f.url})` : ""}`).join("\n")}`, OUTCOME_CODES.CI_RED) };
1792
1881
  }
1793
1882
  ciRounds++; fixes++;
1794
1883
  T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
1795
- ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
1796
- await fixRound("ci", ciFailurePrompt(v.failures, vcfg.mode), [], "ci_red", failureLog(v.failures));
1884
+ ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: sp.own.map((f) => f.job) });
1885
+ await fixRound("ci", ciFailurePrompt(sp.own, vcfg.mode), [], "ci_red", failureLog(sp.own));
1797
1886
  await upsertStage("implementation");
1798
1887
  result = (await readResult()) ?? result;
1799
1888
  if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
1800
- // #4387: "no commit" says the failure is not the change's: rerun the failed jobs once to tell a flake from a real failure.
1801
- const rr = isSplit ? { ok: false, runs: [], jobs: [], detail: "refactor:split" } : await rerunFailedOnce({ io, v });
1802
- const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(v) }) : null;
1803
- if (rr.detail !== "no rerun seam") ev({ event: "CI_FLAKE_RERUN", trigger, head, runs: rr.runs, jobs: rr.jobs, result: v2?.result ?? "rerun-failed", detail: v2?.detail ?? rr.detail });
1804
- if (v2?.result === "pass") {
1805
- ciVerifiedHead = head; await gatePassed("ci_red", "merge_ci"); row(`CI green at ${head.slice(0, 12)} after a rerun of the failed jobs`);
1806
- T.decisions.push(`CI verification red (${jobs}), no fix commit; green after one rerun of the failed jobs (flake)`);
1807
- return { fixes };
1808
- }
1809
- if (v2?.result === "timeout") return { done: await finish("ci-pending", `CI rerun of the failed jobs on ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
1889
+ // #4387/#4586: no commit says the failure is not the change's; the failed jobs were already rerun once on this head (before the round).
1890
+ const rr = ciReruns.get(head)?.rr ?? { ok: false, detail: isSplit ? "refactor:split" : "no rerun seam" };
1810
1891
  const how = !rr.ok ? (rr.detail === "no rerun seam" ? "" : ` (rerun of the failed jobs not possible: ${rr.detail})`) : " and stayed red after one rerun of the failed jobs";
1811
1892
  return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it${how}; nothing merged.`, OUTCOME_CODES.CI_RED) };
1812
1893
  }
@@ -1921,7 +2002,7 @@ export async function runCore(o) {
1921
2002
  const r = runs.find((x) => x?.id === v.id);
1922
2003
  if (!r || r.exit === undefined || r.exit === 0) continue;
1923
2004
  let x;
1924
- 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) }; }
2005
+ try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
1925
2006
  out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
1926
2007
  }
1927
2008
  if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
@@ -1946,7 +2027,9 @@ export async function runCore(o) {
1946
2027
  const hotspots = await hotspotsOf({ base, paths: files }); // #4502
1947
2028
  const validateRuns = await rerunFailedValidates(effective);
1948
2029
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
1949
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
2030
+ // #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
2031
+ const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
2032
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds });
1950
2033
  // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
1951
2034
  if (pc.superseded?.length) {
1952
2035
  ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
@@ -2118,6 +2201,7 @@ export async function runCore(o) {
2118
2201
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
2119
2202
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
2120
2203
  ev({ event: "PR_OPENED", pr });
2204
+ await emit({ event: "PR_OPENED", pr });
2121
2205
  if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
2122
2206
  try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
2123
2207
  catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
@@ -2197,10 +2281,20 @@ export async function runCore(o) {
2197
2281
  }
2198
2282
  let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)", detResolved = false;
2199
2283
  // #4386: conflicts only in sequence files (numbered, journal, snapshot, their index) resolve without a builder
2200
- const rs = !clean && io.sequenceResolve ? ((await io.sequenceResolve({ cwd: wt, files }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? {}) : null;
2284
+ // #4588: union / JSON-map / generated files resolve first; only the files left go to the sequence resolver
2285
+ const bm = o.baseMerge, cr = !clean && io.conflictResolve && bm && Object.values(bm).some((v) => v?.length) ? await io.conflictResolve({ cwd: wt, files, cfg: bm }).catch(() => null) : null;
2286
+ let seqFiles = cr?.resolved?.length ? cr.remaining : files, crDone = false;
2287
+ if (cr?.resolved?.length && !seqFiles.length) { try { await git(["commit", "--no-edit"]); crDone = true; } catch { /* the builder finishes the merge */ } }
2288
+ if (cr?.note) T.decisions.push(`Base-sync ${syncs}: ${cr.note}`);
2289
+ const rs = !clean && !crDone && seqFiles.length && io.sequenceResolve ? ((await io.sequenceResolve({ cwd: wt, files: seqFiles }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? {}) : null;
2201
2290
  if (rs?.unsupported) T.decisions.push(`Base-sync ${syncs}: no deterministic resolution (${rs.unsupported})`);
2202
2291
  if (clean) {
2203
2292
  how = "clean merge of the base into the branch";
2293
+ } else if (crDone) {
2294
+ detResolved = true;
2295
+ T.decisions.push(`Base-sync ${syncs}: conflicts only in union / JSON-map / generated files (${files.join(", ")}); resolved by the engine (no builder)`);
2296
+ how = `deterministic: ${files.length} union / JSON-map / generated file(s) resolved`;
2297
+ tests = "not required (deterministic base-merge resolution)";
2204
2298
  } else if (rs?.ok) {
2205
2299
  detResolved = true;
2206
2300
  T.decisions.push(`Base-sync ${syncs}: conflicts only in sequence files (${files.join(", ")}); base side taken, lane files renumbered by the engine (no builder)`);
@@ -2228,6 +2322,7 @@ export async function runCore(o) {
2228
2322
  if (pm.fixed) how += "; builder fixed post-merge check failures";
2229
2323
  dupFixed = await duplicateGate(`base-sync ${syncs}`);
2230
2324
  if (dupFixed) how += "; builder resolved duplicate exports";
2325
+ if (await i18nGate(`base-sync ${syncs}`)) how += "; builder translated English-copy keys";
2231
2326
  }
2232
2327
  const after = await gitOut(["rev-parse", "HEAD"]);
2233
2328
  if (soft && clean && after === before) { syncs--; sameBaseSyncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
@@ -2273,7 +2368,21 @@ export async function runCore(o) {
2273
2368
  * Returns null to wait again, or a terminal result (rounds exhausted, no session, no new commit, review failure).
2274
2369
  */
2275
2370
  async function mergeFixRound(checks) {
2371
+ // #4586: the failed runs are rerun once per head before a round is counted; the red check is waited out until the rerun reports (bounded).
2372
+ const rkey = `merge:${localHead}`, rerun = ciReruns.get(rkey);
2373
+ if (!rerun && !isSplit) {
2374
+ const rr = await rerunFailedOnce({ io, v: { failedChecks: checks, failures: checks.map((c) => ({ job: c.name })) } });
2375
+ ciReruns.set(rkey, { rr, urls: new Map(checks.map((c) => [c.name, c.url ?? null])), waits: 0 });
2376
+ ev({ event: "CI_FLAKE_RERUN", trigger: "merge", head: localHead, runs: rr.runs, jobs: rr.jobs, result: rr.ok ? "rerun-started" : "rerun-failed", detail: rr.detail });
2377
+ if (rr.ok) { await sleep(20000); return null; }
2378
+ } else if (rerun?.rr.ok && rerun.waits < 20 && checks.every((c) => (c.url ?? null) === rerun.urls.get(c.name))) {
2379
+ rerun.waits++; await sleep(20000); return null; // still the attempt that was rerun
2380
+ }
2381
+ const flakyNames = checks.filter((c) => vcfg.flaky?.includes(c.name)).map((c) => c.name);
2382
+ if (flakyNames.length) { T.anomalies.push(`Known-flaky required check(s) reported (verify.flaky): ${flakyNames.join(", ")}`); ev({ event: "CI_FLAKY_REPORTED", trigger: "merge", head: localHead, jobs: flakyNames }); }
2383
+ checks = checks.filter((c) => !flakyNames.includes(c.name));
2276
2384
  const jobs = checks.map((c) => c.name);
2385
+ if (!checks.length) return await needsHuman(`Required check(s) ${flakyNames.join(", ")} (verify.flaky) still failed on PR #${pr} after one rerun; a required check blocks the merge whatever verify.flaky says; nothing merged.`, OUTCOME_CODES.CI_RED);
2277
2386
  if (ciRounds >= vcfg.maxRounds || !sessionId) {
2278
2387
  return await needsHuman(`Required check(s) failed on PR #${pr}: ${jobs.join(", ")}${ciRounds ? ` (after ${ciRounds} CI fix round(s))` : ""}`, OUTCOME_CODES.CI_RED);
2279
2388
  }
@@ -2357,7 +2466,9 @@ export async function runCore(o) {
2357
2466
  T.decisions.push(`e2e (${want.reason}): ${v.result} at ${localHead.slice(0, 12)}${v.detail ? ` (${v.detail})` : ""}`);
2358
2467
  if (v.result === "timeout") return { done: await finish("ci-pending", `PR #${pr} e2e still pending after ${ciMaxMinutes} min`, { pr }) };
2359
2468
  if (v.result === "unavailable") return { done: await needsHuman(`e2e verification unavailable on PR #${pr} (${v.detail}); nothing merged.`) };
2360
- if (own.length) { const r = await mergeFixRound(own.map((f) => ({ name: f.job, url: f.url }))); return r ? { done: r } : { again: true }; }
2469
+ const ownReal = own.filter((f) => !vcfg.flaky?.includes(f.job));
2470
+ if (own.length > ownReal.length) { T.anomalies.push(`Known-flaky e2e failure(s) reported, not blocking (verify.flaky): ${own.filter((f) => !ownReal.includes(f)).map((f) => f.job).join(", ")}`); }
2471
+ if (ownReal.length) { const r = await mergeFixRound(ownReal.map((f) => ({ name: f.job, url: f.url }))); return r ? { done: r } : { again: true }; }
2361
2472
  if (v.result === "fail") T.anomalies.push(`Pre-existing e2e failure(s) reported, not blocking: ${v.failures.map((f) => f.job).join(", ")}`);
2362
2473
  e2eHead = localHead;
2363
2474
  return null;