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.
- package/README.md +78 -324
- package/bin/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +181 -26
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +44 -5
- package/bin/lean/batch.mjs +66 -3
- package/bin/lean/batch.test.mjs +176 -1
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +30 -9
- package/bin/lean/conformance.test.mjs +51 -0
- package/bin/lean/effects.mjs +14 -3
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +16 -0
- package/bin/lean/lean.mjs +147 -36
- package/bin/lean/lean.test.mjs +319 -8
- package/bin/lean/orchestrate.mjs +130 -13
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +58 -8
- package/bin/lean/precheck.test.mjs +77 -3
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +36 -6
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +34 -5
- package/bin/lean/schedd.test.mjs +84 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +61 -2
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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:
|
|
1796
|
-
await fixRound("ci", ciFailurePrompt(
|
|
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:
|
|
1801
|
-
const rr =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|