forgedock 1.29.1 → 1.31.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/bin/engine/outcome-codes.mjs +5 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +356 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +799 -127
- package/bin/lean/lean.test.mjs +1749 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +77 -10
- package/bin/lean/verify.test.mjs +74 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/lean.mjs
CHANGED
|
@@ -8,29 +8,45 @@
|
|
|
8
8
|
import { join, dirname, basename, delimiter } from "node:path";
|
|
9
9
|
import { hostname as osHostname } from "node:os";
|
|
10
10
|
import { appendEvent } from "../engine/runlog.mjs";
|
|
11
|
+
import { readSelfReview, compareSelfReview } from "./selfreview.mjs";
|
|
11
12
|
import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
12
13
|
import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
|
|
13
|
-
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
|
|
14
|
+
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
14
15
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
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";
|
|
16
17
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
17
|
-
import { namedPaths } from "./prefilter.mjs";
|
|
18
|
+
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
19
|
+
import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
18
20
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
21
|
+
import { splitGate, rationaleLine } from "./decompose.mjs";
|
|
19
22
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
20
23
|
import { repoKey } from "../runner.mjs";
|
|
21
24
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
22
|
-
import {
|
|
25
|
+
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";
|
|
23
28
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
24
29
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
30
|
+
import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
|
|
31
|
+
import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
32
|
+
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";
|
|
33
|
+
import { postMergeCommands } from "./postmerge.mjs";
|
|
34
|
+
import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
|
|
35
|
+
import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
|
|
36
|
+
import { makeTurnBudget, laneSize, wrapupGrant, budgetDigest } from "./budget.mjs";
|
|
37
|
+
import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog } from "./pitfalls.mjs";
|
|
38
|
+
import { unjustified } from "./enumerating.mjs";
|
|
27
39
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
28
|
-
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
|
|
40
|
+
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
|
|
41
|
+
import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
|
|
29
42
|
import { laneBackendId } from "./limits.mjs";
|
|
30
43
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
31
|
-
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
44
|
+
import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
|
|
32
45
|
import { priorityFromLabels } from "./sched.mjs";
|
|
33
|
-
import {
|
|
46
|
+
import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
|
|
47
|
+
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
48
|
+
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
|
|
49
|
+
import { buildResumeDigest } from "./resume.mjs";
|
|
34
50
|
|
|
35
51
|
/**
|
|
36
52
|
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
@@ -41,7 +57,12 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
41
57
|
[OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
|
|
42
58
|
[OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
|
|
43
59
|
[OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
|
|
60
|
+
[OUTCOME_CODES.BUDGET_EXCEEDED]: "an earlier run spent the implement turn budget for this issue's size; its digest is in the earlier run's detail and the branch holds its checkpointed work. Continue from the checkpoint against the frozen contract without starting over; if the remaining work cannot fit the budget, stop with a typed split-required block naming the sub-issues instead of grinding",
|
|
61
|
+
[OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
|
|
62
|
+
[OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
|
|
44
63
|
});
|
|
64
|
+
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
65
|
+
const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
|
|
45
66
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
46
67
|
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
47
68
|
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
@@ -57,6 +78,8 @@ const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CO
|
|
|
57
78
|
* over-threshold file (maintenance.hotspot_growth, forge#4195). Scope creep stays recorded-only.
|
|
58
79
|
*/
|
|
59
80
|
const SHADOW_PRE_BLOCKS = [...SHADOW_BLOCKS, OUTCOME_CODES.MAINTENANCE_HOTSPOT_GROWTH];
|
|
81
|
+
/** #4395: cap on the engine.lane_instructions file; a larger one is not slim and falls back to CLAUDE.md. */
|
|
82
|
+
export const LANE_INSTRUCTIONS_MAX_BYTES = 16384;
|
|
60
83
|
/** forge.yaml engine.review_incomplete (#4160): "needs-human" (default), or "degraded": in shadow, merge an incomplete review with a review-degraded label and a tracked follow-up. */
|
|
61
84
|
export const REVIEW_INCOMPLETE_MODES = Object.freeze(["needs-human", "degraded"]);
|
|
62
85
|
export const REVIEW_DEGRADED_LABEL = "review-degraded";
|
|
@@ -69,6 +92,24 @@ export const CI_NOT_STARTED_GRACE_MS = 10 * 60 * 1000;
|
|
|
69
92
|
/** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
|
|
70
93
|
export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
|
|
71
94
|
|
|
95
|
+
/**
|
|
96
|
+
* #4385: siblings that declare a shared file (a barrel, union type, shared fixture, index doc) would conflict after the
|
|
97
|
+
* first merges; each later sub-issue gets an edge to every earlier one it shares a file with and does not already reach
|
|
98
|
+
* through its dependsOn. Mutates dependsOn; returns the added edges [{from, to, files}]. No `files`: no edge.
|
|
99
|
+
*/
|
|
100
|
+
export function serializeShared(subs) {
|
|
101
|
+
const added = [], reach = [];
|
|
102
|
+
subs.forEach((s, j) => {
|
|
103
|
+
for (let i = j - 1; i >= 0; i--) { // nearest first: a chain, not a fan-in
|
|
104
|
+
const shared = (s.files ?? []).filter((f) => (subs[i].files ?? []).includes(f));
|
|
105
|
+
const r = new Set(s.dependsOn.flatMap((k) => [k, ...reach[k]]));
|
|
106
|
+
if (shared.length && !r.has(i)) { s.dependsOn.push(i); added.push({ from: j, to: i, files: shared }); }
|
|
107
|
+
}
|
|
108
|
+
reach[j] = [...new Set(s.dependsOn.flatMap((k) => [k, ...reach[k]]))];
|
|
109
|
+
});
|
|
110
|
+
return added;
|
|
111
|
+
}
|
|
112
|
+
|
|
72
113
|
/**
|
|
73
114
|
* Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
|
|
74
115
|
* | {kind:"human", why} | {kind:"untyped", problem}. Only `human` with an allowed reason may end as needs-human.
|
|
@@ -84,8 +125,12 @@ export function classifyBlock(result, issue) {
|
|
|
84
125
|
const subs = (Array.isArray(b.subissues) ? b.subissues : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string");
|
|
85
126
|
if (!subs.length) return { kind: "untyped", problem: "the split-required block lists no sub-issues with a title and body" };
|
|
86
127
|
if (subs.length > MAX_SUBISSUES) return { kind: "untyped", problem: `the split-required block lists ${subs.length} sub-issues (at most ${MAX_SUBISSUES})` };
|
|
87
|
-
|
|
88
|
-
|
|
128
|
+
const gate = splitGate({ ...b, subissues: subs }); // #4396: disjoint parts or over the size budget; else one lane
|
|
129
|
+
if (!gate.ok) return { kind: "untyped", problem: gate.problem };
|
|
130
|
+
const subissues = subs.map((x, i) => ({ title: x.title.trim(), body: x.body,
|
|
131
|
+
dependsOn: [...new Set((Array.isArray(x.depends_on) ? x.depends_on : []).filter((j) => Number.isInteger(j) && j >= 0 && j < i))],
|
|
132
|
+
...(Array.isArray(x.files) ? { files: [...new Set(x.files.filter((f) => typeof f === "string").map((f) => f.trim().replace(/^\.?\/+/, "")).filter(Boolean))] } : {}) }));
|
|
133
|
+
return { kind: "split", rationale: gate.rationale, subissues, serialized: serializeShared(subissues) };
|
|
89
134
|
}
|
|
90
135
|
if (b.kind === "human") {
|
|
91
136
|
return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why }
|
|
@@ -115,33 +160,47 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
115
160
|
* @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
|
|
116
161
|
* @param {string} [o.builderModel] default: CLI default
|
|
117
162
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
163
|
+
* @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
|
|
164
|
+
* @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
|
|
165
|
+
* @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
|
|
118
166
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
119
167
|
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
120
168
|
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
121
169
|
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
|
|
170
|
+
* @param {object} [o.verifier] forge.yaml review.verifier/verifier_model (#4397): {mode: off (default)|shadow|enforce, model}
|
|
122
171
|
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
123
|
-
* @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
|
|
172
|
+
* @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
|
|
173
|
+
* maxMinutes: lean.precheck.max_minutes, the round budget (#4331, else verify.precheck_total_seconds)
|
|
174
|
+
* @param {boolean} [o.closeParent] forge.yaml decomposition.close_parent (#3736): close a decomposed parent at decomposition (default false)
|
|
124
175
|
* @param {"off"|"prefer"} [o.reproTest] forge.yaml lean.repro_test (#4271): prefer (default) freezes a red-green repro_test on bug lanes; off is today's contract
|
|
125
176
|
* @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
|
|
126
177
|
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
127
178
|
* @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
|
|
128
179
|
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
180
|
+
* @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
|
|
129
181
|
* @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
|
|
130
182
|
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
183
|
+
* @param {object} [o.explore] {mode: on|off, model}: forge.yaml lean.explore / lean.explore_model (#4391, default off): the explorer subagent
|
|
131
184
|
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
132
185
|
* @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
133
186
|
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
|
|
134
187
|
* (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
|
|
135
188
|
*/
|
|
136
189
|
export async function runCore(o) {
|
|
137
|
-
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
138
|
-
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
190
|
+
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
191
|
+
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
139
192
|
const now = io.now ?? (() => Date.now());
|
|
140
193
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
194
|
+
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
141
195
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
142
196
|
const remoteVerify = vcfg.mode !== "local";
|
|
143
197
|
const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
|
|
144
198
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
199
|
+
const verifierCfg = parseVerifierConfig(o.verifier ?? {});
|
|
200
|
+
const routingCfg = parseBuilderRouting(o.builderRouting ?? {});
|
|
201
|
+
// #4394: the build's model tier, routed from the frozen contract; a failed validate round on the mid tier escalates once
|
|
202
|
+
let route = null, escalated = false;
|
|
203
|
+
const laneModel = () => (route?.tier === "mid" && !escalated ? route.model : builderModel);
|
|
145
204
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
146
205
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
147
206
|
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
|
|
@@ -150,9 +209,10 @@ export async function runCore(o) {
|
|
|
150
209
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
151
210
|
const t0 = now();
|
|
152
211
|
const usage = { cost_usd: 0, input: 0, output: 0, cache: 0 };
|
|
212
|
+
const sessionCosts = new Map(); // #4407: session id -> last reported cumulative cost
|
|
153
213
|
let sessionId = null;
|
|
154
214
|
|
|
155
|
-
const ev = (e) => appendEvent(dir, issue, e);
|
|
215
|
+
const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
|
|
156
216
|
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
157
217
|
const ghc = (args) => io.gh(args);
|
|
158
218
|
const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -160,12 +220,20 @@ export async function runCore(o) {
|
|
|
160
220
|
const ghw = (args) => retrying(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
|
|
161
221
|
const git = (args) => io.git(args, { cwd: wt });
|
|
162
222
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
223
|
+
const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
|
|
224
|
+
const impl = { cost: 0, models: new Set() }; // #4397: implement spend and reported models, for the verifier
|
|
225
|
+
// #4392: implement turn budget for the lane's size, and its turn tally; off unless given (the CLI passes readCoreLimits' defaults)
|
|
226
|
+
const tb = makeTurnBudget(turnBudgets ?? { S: 0, M: 0, L: 0 });
|
|
227
|
+
/** `trigger` (#4362): why the attempt ran, one of fixround.mjs TRIGGERS; `resume` (#4407): the session it resumed. */
|
|
228
|
+
async function attempt(phase, n, r, mode = null, trigger = null, restartOf = null, resume = null) {
|
|
229
|
+
const c = sessionCostDelta(sessionCosts, { resume, sessionId: r?.sessionId ?? null, cost: num(r?.costUsd) });
|
|
230
|
+
const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, trigger, ...(restartOf ? { restart_of: restartOf } : {}), usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
|
|
231
|
+
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 } : {}),
|
|
232
|
+
...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
|
|
233
|
+
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"); }
|
|
166
234
|
ev(rec); await emit(rec);
|
|
167
235
|
const u = r?.usage ?? {};
|
|
168
|
-
usage.cost_usd +=
|
|
236
|
+
usage.cost_usd += c.delta ?? 0;
|
|
169
237
|
usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
|
|
170
238
|
usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
171
239
|
}
|
|
@@ -174,7 +242,8 @@ export async function runCore(o) {
|
|
|
174
242
|
const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
|
|
175
243
|
return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
176
244
|
};
|
|
177
|
-
|
|
245
|
+
/** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
|
|
246
|
+
const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); return true; } catch { /* labels are best effort */ return false; } };
|
|
178
247
|
const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
|
|
179
248
|
|
|
180
249
|
// ---- lease (same FORGE:STATE lease the full path uses) ----
|
|
@@ -205,19 +274,28 @@ export async function runCore(o) {
|
|
|
205
274
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
206
275
|
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
207
276
|
const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
|
|
277
|
+
const exploreCfg = parseExploreConfig(o.explore ?? {}); // #4391
|
|
278
|
+
if (exploreCfg.mode === "on") T.decisions.push(`Explorer subagent (on): model ${exploreCfg.model}`);
|
|
208
279
|
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
280
|
+
let pitMatched = null; // #4375: pitfalls in the pack that match the issue's files; null when no pack was compiled
|
|
281
|
+
let factsSeen = []; // #4401: trusted comment bodies on the issue, so an identical FORGE:FACTS is never posted twice
|
|
209
282
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
283
|
+
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
210
284
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
211
285
|
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
212
286
|
const runId = `${issue}-${t0}`;
|
|
213
287
|
let runTmp = null;
|
|
214
288
|
let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
|
|
289
|
+
// #4395: lane profile. full (FORGEDOCK_FULL_SESSION=1): unchanged sessions; memory-off: no auto-memory; lane: also no CLAUDE.md, the lane file appended
|
|
290
|
+
let laneProfile = { name: process.env.FORGEDOCK_FULL_SESSION === "1" ? "full" : "memory-off", text: null };
|
|
215
291
|
let toolchain = null; // #4163: {env, summary} when the repo pins its toolchain (env only with a pm shim); null leaves runner env and prompts unchanged
|
|
216
292
|
// #4169: heavy steps take a host semaphore slot (io.heavy); LLM phases are not gated. `heavyEnv` puts the heavy shims on
|
|
217
293
|
// the runner PATH; `waits` ({phase: ms}) and `heavyQueueMax` ride on the lane result for the batch report.
|
|
218
294
|
const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
|
|
219
295
|
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
296
|
+
let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
|
|
220
297
|
const waits = {};
|
|
298
|
+
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
221
299
|
const addWait = (phase, ms, queued = 0) => {
|
|
222
300
|
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
223
301
|
if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
|
|
@@ -310,15 +388,19 @@ export async function runCore(o) {
|
|
|
310
388
|
return finishing;
|
|
311
389
|
}
|
|
312
390
|
async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
|
|
391
|
+
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
392
|
+
seqLease = null;
|
|
313
393
|
if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
|
|
314
394
|
try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
|
|
315
395
|
}
|
|
396
|
+
await postLaneFacts(); // #4401: before teardown removes the worktree's result.json
|
|
316
397
|
await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
|
|
317
398
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
318
399
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
319
400
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
320
|
-
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
321
|
-
context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {})
|
|
401
|
+
conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
402
|
+
context_pack: ctxCfg.mode, explore: exploreCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
403
|
+
...(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
|
|
322
404
|
ev(term);
|
|
323
405
|
await emit(term);
|
|
324
406
|
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
@@ -336,16 +418,32 @@ export async function runCore(o) {
|
|
|
336
418
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
337
419
|
}
|
|
338
420
|
if (["merged", "invalid", "needs-human"].includes(reason)) {
|
|
339
|
-
if (reason === "merged") T.close = ["issue closed (completed)", "label workflow:merged"];
|
|
421
|
+
if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
|
|
340
422
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
341
423
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
342
424
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
343
425
|
}
|
|
344
426
|
await releaseLease(reason, code);
|
|
345
427
|
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
|
|
346
|
-
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...
|
|
428
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
429
|
+
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
430
|
+
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
431
|
+
...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced) } } : {}), ...extra };
|
|
347
432
|
return done;
|
|
348
433
|
}
|
|
434
|
+
/** #4401: post the lane's FORGE:FACTS (result.json facts and validation, changed files, contract decisions); never fails a run. */
|
|
435
|
+
async function postLaneFacts() {
|
|
436
|
+
if (!base) return;
|
|
437
|
+
try {
|
|
438
|
+
const result = await readResult();
|
|
439
|
+
const rec = result ? normaliseFacts(result, { files: await stageFiles(), contract: await readContract() }) : null;
|
|
440
|
+
if (!rec) return;
|
|
441
|
+
const body = renderFactsRecord(rec);
|
|
442
|
+
if (factsSeen.some((b) => String(b ?? "").includes(body))) return;
|
|
443
|
+
await post(FACTS_MARKER, body);
|
|
444
|
+
T.decisions.push(`FORGE:FACTS: ${rec.rejected.length} rejected approach(es), ${rec.hotspots.length} hot spot(s) on ${rec.files.length} file(s)`);
|
|
445
|
+
} catch { /* best effort */ }
|
|
446
|
+
}
|
|
349
447
|
/** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
|
|
350
448
|
const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
|
|
351
449
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
@@ -425,18 +523,27 @@ export async function runCore(o) {
|
|
|
425
523
|
if (!io.host?.cores) return null;
|
|
426
524
|
try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
|
|
427
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* #4325: the lane env every engine-run command shares with the builder: shared package-manager stores (#4220), the run
|
|
528
|
+
* TMPDIR (#4104), FORGE_HOST_PARALLELISM (#4182) and the pinned toolchain (#4163); the engine's own keys win over the stores.
|
|
529
|
+
*/
|
|
530
|
+
function laneEnv() {
|
|
531
|
+
let deps = {};
|
|
532
|
+
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
533
|
+
const par = verifyParallelism();
|
|
534
|
+
return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
|
|
535
|
+
}
|
|
428
536
|
/** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
|
|
429
537
|
async function callRunner(args) {
|
|
430
538
|
let pauses = 0;
|
|
431
539
|
for (;;) {
|
|
432
540
|
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
433
541
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
434
|
-
const par = verifyParallelism(); // #4182
|
|
435
|
-
let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
|
|
436
|
-
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
437
542
|
// #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
|
|
438
543
|
const heavy = heavyEnv ? { ...heavyEnv, FORGE_LANE_PHASE: args.role === "builder" ? stage : String(args.role ?? "step"), FORGE_LANE_ISSUE: String(issue), FORGE_LANE_PRIORITY: String(lanePriority()) } : {};
|
|
439
|
-
|
|
544
|
+
const lp = laneProfile.name, profEnv = lp === "full" ? {} : { CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", ...(lp === "lane" ? { CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1" } : {}) };
|
|
545
|
+
const system = lp === "lane" && !args.resume && !args.noTools ? [args.system, laneProfile.text].filter(Boolean).join("\n\n") : args.system;
|
|
546
|
+
try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
|
|
440
547
|
catch (e) {
|
|
441
548
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
442
549
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -506,33 +613,69 @@ export async function runCore(o) {
|
|
|
506
613
|
let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
|
|
507
614
|
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
508
615
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
616
|
+
let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
|
|
617
|
+
let resumeNote = ""; // #4400: the resume digest of the last run (resume.mjs), "" on a first run or with context_pack off
|
|
618
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
|
|
619
|
+
/** #4390: builder calls stopped at the context ceiling restart fresh; planning (and its corrections) run uncapped. */
|
|
620
|
+
const UNCEILED = ["plan", "contract_correction", "block_correction"], CONTEXT_RESTARTS_MAX = 3;
|
|
621
|
+
async function builder(n, prompt, resume, live = false, restart = implementRestart, opts = {}) {
|
|
622
|
+
let timeoutRetries = 0, contextRestarts = 0, p = prompt;
|
|
623
|
+
opts = { ...opts };
|
|
624
|
+
const ceiled = contextCeilingTokens > 0 && !UNCEILED.includes(opts.trigger);
|
|
512
625
|
for (;;) {
|
|
513
|
-
|
|
626
|
+
// after CONTEXT_RESTARTS_MAX restarts the call runs uncapped: a loop guard, never a refusal of work
|
|
627
|
+
opts.contextMax = ceiled && contextRestarts < CONTEXT_RESTARTS_MAX ? contextCeilingTokens : 0;
|
|
628
|
+
const budgeted = stage === "implement" && tb.on(); // #4392: the build and its restarts spend the implement turn budget
|
|
629
|
+
if (budgeted && tb.remaining() <= 0) return await budgetWrapup(n, opts);
|
|
630
|
+
opts.turnMax = budgeted ? tb.remaining() : 0;
|
|
631
|
+
try { return await builderOnce(n, p, resume, live, opts); } catch (e) {
|
|
632
|
+
if (e?.code === "CLI_TURNS") {
|
|
633
|
+
await attempt("implement", n, { usage: e.usage ?? null, turns: e.turns ?? null, tools: toolStats(e.toolUses), costUsd: null, model: opts.model ?? builderModel ?? null }, opts.mode, opts.trigger, opts.restartOf);
|
|
634
|
+
return await budgetWrapup(n, opts);
|
|
635
|
+
}
|
|
636
|
+
if (e?.code === "CLI_CONTEXT") {
|
|
637
|
+
contextRestarts++;
|
|
638
|
+
ev({ event: "CONTEXT_CEILING", context_tokens: e.context ?? null, ceiling: contextCeilingTokens, turns: e.turns ?? null, trigger: opts.trigger ?? null, restart: contextRestarts });
|
|
639
|
+
await attempt("implement", n, { usage: e.usage ?? null, turns: e.turns ?? null, tools: toolStats(e.toolUses), costUsd: null, model: opts.model ?? laneModel() ?? null }, opts.mode, opts.trigger, opts.restartOf);
|
|
640
|
+
await checkpoint("context ceiling");
|
|
641
|
+
T.decisions.push(`Builder session reached ${e.context ?? "?"} context tokens (ceiling ${contextCeilingTokens}): checkpointed, restarted fresh from the contract, diff and last validate result`);
|
|
642
|
+
p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
514
645
|
if (!(e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
|
|
515
646
|
const idle = e.code === "CLI_IDLE";
|
|
516
647
|
const reason = idle ? "idle timeout" : "timeout";
|
|
517
|
-
ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? []
|
|
648
|
+
ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
|
|
649
|
+
...(idle ? { heavyQueue: e.heavyQueue ?? null, extended: e.extended === true } : {}) });
|
|
518
650
|
await checkpoint(reason);
|
|
519
651
|
if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
|
|
520
652
|
T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
|
|
521
653
|
T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
|
|
522
|
-
p = restart(); resume = null;
|
|
654
|
+
p = restart(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "timeout_restart";
|
|
523
655
|
}
|
|
524
656
|
}
|
|
525
657
|
}
|
|
526
658
|
|
|
527
|
-
|
|
659
|
+
/** #4405: a silent builder is busy (idle window extended) while a child is a heavy step or its heavy-queue entry is queued/running. */
|
|
660
|
+
function sessionBusy(children) {
|
|
661
|
+
const work = children.filter((c) => isHeavyProcess(c.cmd));
|
|
662
|
+
let heavyQueue = null;
|
|
663
|
+
try { heavyQueue = io.heavy?.lane?.(children.map((c) => c.pid)) ?? null; } catch { heavyQueue = null; }
|
|
664
|
+
return work.length || heavyQueue?.queued > 0 || heavyQueue?.running > 0 ? { children: work, heavyQueue } : null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** opts (#4363): {model, mode, keepSession, trigger and restartOf (#4362)}; a fresh fix session keeps the build session id for a fallback resume. */
|
|
668
|
+
async function builderOnce(n, prompt, resume, live = false, { model = null, mode = null, keepSession = false, trigger = null, restartOf = null, contextMax = 0, turnMax = 0 } = {}) {
|
|
528
669
|
onProgress({ event: "phase_enter", phase: "build" });
|
|
529
670
|
await renew();
|
|
530
671
|
const control = {};
|
|
531
672
|
const governor = startGovernor(control);
|
|
532
673
|
let spawned = null;
|
|
533
674
|
const onSpawn = (child) => { if (Number.isSafeInteger(child?.pid) && !finishing) { spawned = child.pid; ledgerDo((l) => l.register("pid", child.pid, { marker: basename(String(child.spawnfile ?? "")) || null })); } };
|
|
534
|
-
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model:
|
|
535
|
-
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }))
|
|
675
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg),
|
|
676
|
+
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}),
|
|
677
|
+
busy: sessionBusy, busyMs: Math.round(cliBusyMinutes * 60000),
|
|
678
|
+
onIdleExtend: (info) => ev({ event: "CLI_IDLE_EXTENDED", children: info.children, heavyQueue: info.heavyQueue }) }));
|
|
536
679
|
call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
|
|
537
680
|
let r;
|
|
538
681
|
call.catch(() => governor.stop());
|
|
@@ -551,11 +694,45 @@ export async function runCore(o) {
|
|
|
551
694
|
await governor.stop();
|
|
552
695
|
await renew();
|
|
553
696
|
await registerReported();
|
|
554
|
-
if (r?.sessionId) sessionId = r.sessionId;
|
|
555
|
-
await attempt("implement", n, r);
|
|
697
|
+
if (r?.sessionId && !keepSession) sessionId = r.sessionId;
|
|
698
|
+
await attempt("implement", n, r, mode, trigger, restartOf, resume);
|
|
556
699
|
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
557
700
|
return r;
|
|
558
701
|
}
|
|
702
|
+
/** #4390: the restart summary: `git diff --stat` and hunks against the lane base, and the last `forgedock check` record. */
|
|
703
|
+
async function restartFacts() {
|
|
704
|
+
const ex = [".", `:!${LEAN_DIR}`];
|
|
705
|
+
const stat = String(await git(["diff", "--stat", `${base}...HEAD`, "--", ...ex]).catch(() => "")), diff = String(await git(["diff", `${base}...HEAD`, "--", ...ex]).catch(() => ""));
|
|
706
|
+
let check = "";
|
|
707
|
+
try { check = String(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)); } catch { /* none recorded */ }
|
|
708
|
+
return { stat, diff, base, check };
|
|
709
|
+
}
|
|
710
|
+
const contextRestartNote = async () => contextRestartPrompt({ ...await restartFacts(), ceiling: contextCeilingTokens });
|
|
711
|
+
/**
|
|
712
|
+
* #4392: the implement turn budget is spent. Checkpoint, then one fresh session of `wrapupGrant` turns either finishes
|
|
713
|
+
* (its result flows on as usual) or stops with a digest; a stop or an overrun ends needs-human `budget.exceeded`.
|
|
714
|
+
*/
|
|
715
|
+
async function budgetWrapup(n, opts) {
|
|
716
|
+
const size = tb.size, budget = tb.budget(), used = tb.record().build, grant = wrapupGrant(budget);
|
|
717
|
+
tb.markExceeded();
|
|
718
|
+
ev({ event: "TURN_BUDGET_EXCEEDED", size, budget, turns: used, grant });
|
|
719
|
+
await checkpoint("turn budget");
|
|
720
|
+
T.decisions.push(`Implement turn budget spent (size ${size}, ${used}/${budget} turns): checkpointed; one wrap-up session of ${grant} turns to finish or stop with a digest`);
|
|
721
|
+
const facts = await restartFacts();
|
|
722
|
+
const wopts = { ...opts, trigger: "budget_wrapup", restartOf: opts.restartOf ?? opts.trigger ?? null, contextMax: 0, turnMax: grant };
|
|
723
|
+
let r = null, overran = false;
|
|
724
|
+
try { r = await builderOnce(n, implementRestart() + turnBudgetWrapupPrompt({ size, budget, used, grant, ...facts }), null, false, wopts); } catch (e) {
|
|
725
|
+
if (e?.code !== "CLI_TURNS") throw e;
|
|
726
|
+
overran = true;
|
|
727
|
+
await attempt("implement", n, { usage: e.usage ?? null, turns: e.turns ?? null, tools: toolStats(e.toolUses), costUsd: null, model: opts.model ?? builderModel ?? null }, opts.mode, wopts.trigger, wopts.restartOf);
|
|
728
|
+
await checkpoint("turn budget wrap-up");
|
|
729
|
+
}
|
|
730
|
+
const res = await readResult();
|
|
731
|
+
if (!overran && res?.status !== "budget-exceeded") return r;
|
|
732
|
+
ev({ event: "TURN_BUDGET_STOP", size, budget, turns: tb.record().build, overran });
|
|
733
|
+
const digest = budgetDigest({ size, budget, used: tb.record().build, digest: res?.status === "budget-exceeded" ? res.digest : null, stat: facts.stat, check: facts.check });
|
|
734
|
+
throw Object.assign(new Error(digest), { laneCode: OUTCOME_CODES.BUDGET_EXCEEDED });
|
|
735
|
+
}
|
|
559
736
|
const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
560
737
|
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
561
738
|
/** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
|
|
@@ -580,11 +757,13 @@ export async function runCore(o) {
|
|
|
580
757
|
try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
|
|
581
758
|
return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
582
759
|
}
|
|
583
|
-
|
|
584
|
-
//
|
|
585
|
-
const
|
|
760
|
+
// #4153 / #4337: a decomposed parent, open or closed, resolves to its open sub-issues; it is satisfied once they all
|
|
761
|
+
// close. Without a usable trail the parent itself is the dependency, even when closed (fail closed).
|
|
762
|
+
const decomposed = isDecomposedRecord(v) && ["OPEN", "CLOSED"].includes(String(v?.state).toUpperCase());
|
|
763
|
+
if (!decomposed && String(v?.state).toUpperCase() !== "OPEN") continue;
|
|
764
|
+
const subs = decomposed ? await decomposedSubissues(d, io) : null;
|
|
586
765
|
const recs = [];
|
|
587
|
-
if (!subs) recs.push([d, v]);
|
|
766
|
+
if (!subs) recs.push([d, { ...v, state: "OPEN" }]);
|
|
588
767
|
else for (const n of subs) {
|
|
589
768
|
try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
|
|
590
769
|
return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
@@ -628,10 +807,20 @@ export async function runCore(o) {
|
|
|
628
807
|
filed.push(n);
|
|
629
808
|
}
|
|
630
809
|
} catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
810
|
+
const why = rationaleLine(b.rationale);
|
|
811
|
+
T.decisions.push(`Builder block: split into ${refs(filed)} (${why})`);
|
|
812
|
+
for (const e of b.serialized ?? []) T.decisions.push(`Serialised sub-issue #${filed[e.from]} behind #${filed[e.to]}: both edit ${e.files.slice(0, 5).join(", ")} (#4385)`);
|
|
813
|
+
let marked = false;
|
|
814
|
+
try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\nRationale: ${why}\n\n${s}`); marked = true; } catch { /* best effort */ }
|
|
815
|
+
// #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
|
|
816
|
+
if (await label("decomposed") && marked && closeParent) {
|
|
817
|
+
try {
|
|
818
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned", "--comment",
|
|
819
|
+
`Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
|
|
820
|
+
T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
|
|
821
|
+
} catch { /* best effort: the parent stays open, as before #4337 */ }
|
|
822
|
+
}
|
|
823
|
+
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
|
|
635
824
|
}
|
|
636
825
|
return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
|
|
637
826
|
}
|
|
@@ -680,7 +869,7 @@ export async function runCore(o) {
|
|
|
680
869
|
}
|
|
681
870
|
|
|
682
871
|
if (io.ledger) {
|
|
683
|
-
try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, host: myHost }); } catch { ledger = null; }
|
|
872
|
+
try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, repo, host: myHost }); } catch { ledger = null; }
|
|
684
873
|
}
|
|
685
874
|
// A signal (engine-cli SIGTERM/SIGINT) runs the same teardown and lease release as any other exit.
|
|
686
875
|
o.onTeardown?.((signal) => finish("deferred", `interrupted by ${signal}`, { code: OUTCOME_CODES.RUN_INTERRUPTED }, OUTCOME_CODES.RUN_INTERRUPTED));
|
|
@@ -695,7 +884,14 @@ export async function runCore(o) {
|
|
|
695
884
|
try {
|
|
696
885
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
697
886
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
698
|
-
|
|
887
|
+
const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
|
|
888
|
+
if (String(iss.state).toUpperCase() === "CLOSED") {
|
|
889
|
+
// #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
|
|
890
|
+
// them). A trusted revocation reopens it and the run continues. An unreadable trail lists none (fail closed).
|
|
891
|
+
if (!isDecomposedRecord(iss)) return finish("invalid", "issue already closed");
|
|
892
|
+
if (await reopenRevokedDecomposition(issue, io)) iss.state = "OPEN";
|
|
893
|
+
else return finish("decomposed", "closed decomposed tracker; dependents wait on its sub-issues", { code: OUTCOME_CODES.DECOMPOSED, subissues: (await decomposedSubissues(issue, io)) ?? [] });
|
|
894
|
+
}
|
|
699
895
|
|
|
700
896
|
try {
|
|
701
897
|
// ---- 1. claim ----
|
|
@@ -792,15 +988,19 @@ export async function runCore(o) {
|
|
|
792
988
|
}
|
|
793
989
|
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
794
990
|
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
}
|
|
991
|
+
// ---- 2a. lane profile (#4395): the repo's lane instruction file replaces CLAUDE.md; a bad one falls back with an anomaly ----
|
|
992
|
+
const lanePath = typeof o.laneInstructions === "string" ? o.laneInstructions.trim() : "";
|
|
993
|
+
if (laneProfile.name !== "full" && lanePath) {
|
|
994
|
+
let why = null, text = "";
|
|
995
|
+
if (lanePath.startsWith("/") || lanePath.split(/[\\/]/).includes("..")) why = "not a repo-relative path";
|
|
996
|
+
else { try { text = String(await io.fs.read(`${wt}/${lanePath}`) ?? "").trim(); } catch { why = "not readable in the lane worktree"; } }
|
|
997
|
+
if (!why && !text) why = "empty";
|
|
998
|
+
if (!why && Buffer.byteLength(text, "utf8") > LANE_INSTRUCTIONS_MAX_BYTES) why = `over ${LANE_INSTRUCTIONS_MAX_BYTES} bytes`;
|
|
999
|
+
if (why) T.anomalies.push(`forge.yaml engine.lane_instructions \`${lanePath.slice(0, 200)}\` ${why}; lanes keep CLAUDE.md (auto-memory off)`);
|
|
1000
|
+
else laneProfile = { name: "lane", text };
|
|
803
1001
|
}
|
|
1002
|
+
T.decisions.push(laneProfile.name === "lane" ? `Lane profile: ${lanePath} (${Buffer.byteLength(laneProfile.text, "utf8")} bytes) instead of CLAUDE.md, auto-memory off`
|
|
1003
|
+
: laneProfile.name === "full" ? "Lane profile: full session (FORGEDOCK_FULL_SESSION=1)" : "Lane profile: auto-memory off, CLAUDE.md loaded");
|
|
804
1004
|
// ---- 2b. toolchain pins + env.setup (#4163): before the builder; zero LLM. No pins and no setup changes nothing ----
|
|
805
1005
|
try {
|
|
806
1006
|
const tc = await (io.prepareToolchain ?? prepareToolchain)({ dir: wt, override: toolchainOverride, read: io.fs.read,
|
|
@@ -813,6 +1013,16 @@ export async function runCore(o) {
|
|
|
813
1013
|
if (tc?.warning) { T.anomalies.push(tc.warning); ev({ event: "TOOLCHAIN_WARNING", warning: tc.warning }); }
|
|
814
1014
|
for (const n of tc?.notes ?? []) T.anomalies.push(n);
|
|
815
1015
|
} catch (e) { T.anomalies.push(`Toolchain detection failed (host toolchain used): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1016
|
+
// #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
|
|
1017
|
+
// After 2b (#4347): installs run with the toolchain env, so a pm only its shim provides resolves; no env is the host PATH.
|
|
1018
|
+
if (io.deps?.prepare) {
|
|
1019
|
+
let res;
|
|
1020
|
+
try { res = await io.deps.prepare({ wt, repoKey: repoKey(cwd), env: toolchain?.env ?? undefined }); } catch (e) { res = [{ status: "failed", eco: null, key: null, ms: 0, reason: String(e?.message ?? e).slice(0, 300) }]; }
|
|
1021
|
+
for (const r of Array.isArray(res) ? res : []) {
|
|
1022
|
+
ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
|
|
1023
|
+
if (r?.eco) T.decisions.push(`Dependencies (${r.eco}${r.key ? ` ${r.key}` : ""}): ${r.status}${r.reason ? ` (${r.reason})` : ""}${Number.isFinite(r.ms) ? ` in ${Math.round(r.ms / 100) / 10}s` : ""}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
816
1026
|
// ---- 2c. heavy-step shims (#4169): after the toolchain, so a heavy shim's real binary is the pinned toolchain shim ----
|
|
817
1027
|
if (heavyOn) {
|
|
818
1028
|
try {
|
|
@@ -830,7 +1040,7 @@ export async function runCore(o) {
|
|
|
830
1040
|
if (typeof envSetup === "string" && envSetup.trim()) {
|
|
831
1041
|
let r;
|
|
832
1042
|
if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
|
|
833
|
-
else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env:
|
|
1043
|
+
else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: laneEnv() })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
|
|
834
1044
|
ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
|
|
835
1045
|
if (r?.exit !== 0) {
|
|
836
1046
|
const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
|
|
@@ -848,6 +1058,10 @@ export async function runCore(o) {
|
|
|
848
1058
|
const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
849
1059
|
return touched.filter((f) => differs.has(f));
|
|
850
1060
|
};
|
|
1061
|
+
/** #4364: the last `forgedock check` record vs the current tree: skipped|stale|red|green; any read or git error is skipped. */
|
|
1062
|
+
const selfCheckNow = async () => {
|
|
1063
|
+
try { return selfCheckState(JSON.parse(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)), await worktreeFingerprint({ git, base })); } catch { return "skipped"; }
|
|
1064
|
+
};
|
|
851
1065
|
/** forge#4195: scripts/hotspots.sh facts in the worktree, or null (no io.hotspots, script failure): never fails a run. */
|
|
852
1066
|
const hotspotsOf = async (args) => {
|
|
853
1067
|
if (!io.hotspots) return null;
|
|
@@ -864,7 +1078,7 @@ export async function runCore(o) {
|
|
|
864
1078
|
if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
|
|
865
1079
|
return null;
|
|
866
1080
|
}
|
|
867
|
-
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
|
|
1081
|
+
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
|
|
868
1082
|
const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
869
1083
|
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
870
1084
|
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
@@ -877,14 +1091,12 @@ export async function runCore(o) {
|
|
|
877
1091
|
const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
|
|
878
1092
|
const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
|
|
879
1093
|
// Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
|
|
880
|
-
const trustedOf = (i) => (i.comments ?? []).filter(
|
|
881
|
-
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
882
|
-
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
883
|
-
});
|
|
1094
|
+
const trustedOf = (i) => (i.comments ?? []).filter(isTrustedIssueComment); // #4467: gh shape, the App has no [bot]
|
|
884
1095
|
// #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
|
|
885
1096
|
const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
|
|
886
1097
|
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
|
|
887
1098
|
let trustedComments = trustedOf(iss), issueEdited = false;
|
|
1099
|
+
factsSeen = trustedComments.map((c) => c?.body);
|
|
888
1100
|
let issueText = textOf(iss, trustedComments);
|
|
889
1101
|
let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
|
|
890
1102
|
ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
|
|
@@ -893,23 +1105,36 @@ export async function runCore(o) {
|
|
|
893
1105
|
* io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
|
|
894
1106
|
*/
|
|
895
1107
|
async function contextPackStep() {
|
|
1108
|
+
const pits = await lanePitfalls();
|
|
896
1109
|
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
897
1110
|
try {
|
|
898
1111
|
const headSha = String(await git(["rev-parse", base])).trim();
|
|
899
1112
|
// #4316: linked-issue states are part of the key; a failed lookup only drops them from it
|
|
900
1113
|
const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
|
|
901
|
-
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
|
|
902
|
-
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
1114
|
+
const r = await contextPackFor({ cfg: ctxCfg, issue, repo, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links, pitfalls: pits.map((x) => x.sig) },
|
|
1115
|
+
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, checkout: cwd, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now(), pitfalls: pits }) });
|
|
903
1116
|
const p = r.pack;
|
|
1117
|
+
const inPack = Array.isArray(p.pitfalls) ? p.pitfalls : [];
|
|
1118
|
+
pitMatched = inPack.filter((x) => x.match === "files").length;
|
|
1119
|
+
// #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
|
|
904
1120
|
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
905
1121
|
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
906
|
-
ev(rec); await emit(rec);
|
|
1122
|
+
ev({ ...rec, pitfalls: inPack }); await emit(rec);
|
|
907
1123
|
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
908
1124
|
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
909
1125
|
T.decisions.push(`Context pack (${ctxCfg.mode}): ${p.items} item(s), ${p.bytes} bytes${p.truncated ? `, ${p.truncated} dropped` : ""}, ${ctxCfg.mode === "on" ? "given to the builder" : "recorded only"}`);
|
|
910
1126
|
return p;
|
|
911
1127
|
} catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
|
|
912
1128
|
}
|
|
1129
|
+
/** #4375: tick the repo's lane clock (pitfall aging) and select its pitfalls for this issue; [] without io.pitfalls or on any error. */
|
|
1130
|
+
async function lanePitfalls() {
|
|
1131
|
+
if (!io.pitfalls) return [];
|
|
1132
|
+
try {
|
|
1133
|
+
await io.pitfalls.append(repo, { t: "lane", run: runId, ts: new Date(now()).toISOString() });
|
|
1134
|
+
if (!ctxCfg.sources.includes("pitfalls")) return [];
|
|
1135
|
+
return selectPitfalls(foldPitfalls(await io.pitfalls.read(repo), { maxAge: ctxCfg.pitfallLanes }), { paths: namedPaths(iss.body ?? "") });
|
|
1136
|
+
} catch { return []; }
|
|
1137
|
+
}
|
|
913
1138
|
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
914
1139
|
async function refreshIssue(stage) {
|
|
915
1140
|
let next;
|
|
@@ -919,7 +1144,7 @@ export async function runCore(o) {
|
|
|
919
1144
|
if (contractHash(issueText) === contractHash(text)) return;
|
|
920
1145
|
// #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
|
|
921
1146
|
const old = scopeHash, now = contractHash(scopeText(next, tc));
|
|
922
|
-
iss = next; trustedComments = tc; issueText = text; scopeHash = now;
|
|
1147
|
+
iss = next; trustedComments = tc; factsSeen = tc.map((c) => c?.body); issueText = text; scopeHash = now;
|
|
923
1148
|
if (old === now) return;
|
|
924
1149
|
issueEdited = true;
|
|
925
1150
|
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
@@ -930,7 +1155,7 @@ export async function runCore(o) {
|
|
|
930
1155
|
let block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
931
1156
|
if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
|
|
932
1157
|
T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
|
|
933
|
-
await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId);
|
|
1158
|
+
await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId, false, undefined, { trigger: "block_correction" });
|
|
934
1159
|
result = await readResult();
|
|
935
1160
|
block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
936
1161
|
}
|
|
@@ -953,6 +1178,40 @@ export async function runCore(o) {
|
|
|
953
1178
|
}
|
|
954
1179
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
955
1180
|
}
|
|
1181
|
+
/**
|
|
1182
|
+
* #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
|
|
1183
|
+
* env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
|
|
1184
|
+
*/
|
|
1185
|
+
async function preflightValidates(contract) {
|
|
1186
|
+
if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
|
|
1187
|
+
const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
1188
|
+
const env = laneEnv(), missing = new Set(), misses = []; // #4325: the env the validate commands run with
|
|
1189
|
+
if (io.probeCommand) {
|
|
1190
|
+
const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
|
|
1191
|
+
if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
|
|
1192
|
+
for (const p of probes) {
|
|
1193
|
+
if (p.needs && missing.has(p.needs)) continue;
|
|
1194
|
+
let r;
|
|
1195
|
+
try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
|
|
1196
|
+
if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
|
|
1197
|
+
if (p.kind === "command") missing.add(p.tool);
|
|
1198
|
+
misses.push({ ...p, source: "probe" });
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const fullEnv = { ...process.env, ...env };
|
|
1202
|
+
for (const v of Array.isArray(contract.validate) ? contract.validate : []) {
|
|
1203
|
+
if (v?.id && typeof v.command === "string") for (const name of unsetEnvVars(v.command, fullEnv)) misses.push({ ids: [v.id], tool: name, kind: "env-var", source: "env" });
|
|
1204
|
+
}
|
|
1205
|
+
if (io.preflightRun) {
|
|
1206
|
+
const skipIds = [...new Set(misses.flatMap((m) => m.ids))];
|
|
1207
|
+
const run = (a) => heavyStep("precheck", () => io.preflightRun({ ...a, cwd: wt }));
|
|
1208
|
+
const r = await runPreflight({ contract, run, git, env, skipIds, now, ...knownToContract(contract, files) });
|
|
1209
|
+
misses.push(...r.misses);
|
|
1210
|
+
if (r.skipped.length) T.anomalies.push(`Validate preflight run budget spent; ${r.skipped.join(", ")} not run on base`);
|
|
1211
|
+
if (r.reverted.length) T.anomalies.push(`Validate preflight reverted ${r.reverted.length} path(s) its runs changed: ${r.reverted.slice(0, 5).join(", ")}`);
|
|
1212
|
+
}
|
|
1213
|
+
return misses;
|
|
1214
|
+
}
|
|
956
1215
|
/**
|
|
957
1216
|
* The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
|
|
958
1217
|
* (#4265): a trusted comment that the engine did not post came after it.
|
|
@@ -1003,7 +1262,7 @@ export async function runCore(o) {
|
|
|
1003
1262
|
await renew();
|
|
1004
1263
|
const rr = await callRunner({ role: "grouper", issue, cwd: cwd ?? runTmp ?? wt, model: reviewerModel, resume: null, system: GROUPING_SYSTEM, prompt, noTools: true });
|
|
1005
1264
|
await renew();
|
|
1006
|
-
await attempt("group", 1, rr);
|
|
1265
|
+
await attempt("group", 1, rr, null, "plan");
|
|
1007
1266
|
return rr?.text ?? "";
|
|
1008
1267
|
} finally { slot = await heavyAcquire("split"); }
|
|
1009
1268
|
} });
|
|
@@ -1027,6 +1286,45 @@ export async function runCore(o) {
|
|
|
1027
1286
|
return { ...r, contract };
|
|
1028
1287
|
}
|
|
1029
1288
|
let frozen = null, hash = null, planned = false, files = [], split = null;
|
|
1289
|
+
const pendingPits = []; // #4375: rework rounds whose gate has not passed again yet
|
|
1290
|
+
async function notePitfall(trigger, text) {
|
|
1291
|
+
const pit = io.pitfalls ? pendingPitfall(trigger, text) : null;
|
|
1292
|
+
if (pit) { try { pit.before = String(await git(["rev-parse", "HEAD"])).trim(); pendingPits.push(pit); } catch { /* best effort */ } }
|
|
1293
|
+
}
|
|
1294
|
+
/** #4375: the gate of `triggers` (all when none) passed: record its pending rework rounds as pitfalls. Never fails a run. */
|
|
1295
|
+
async function gatePassed(...triggers) {
|
|
1296
|
+
for (const pit of pendingPits.filter((x) => !triggers.length || triggers.includes(x.trigger))) {
|
|
1297
|
+
pendingPits.splice(pendingPits.indexOf(pit), 1);
|
|
1298
|
+
try {
|
|
1299
|
+
const changed = String(await git(["diff", "--name-only", `${pit.before}..HEAD`])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
1300
|
+
await io.pitfalls.append(repo, pitfallRecord({ pending: pit, files: changed, run: runId, ts: new Date(now()).toISOString() }));
|
|
1301
|
+
ev({ event: "PITFALL_RECORDED", trigger: pit.trigger, sig: pit.sig });
|
|
1302
|
+
} catch { /* the ledger is a cache */ }
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** #4394: a failed validate round on the mid tier moves this fix round and every later builder call to the top model. */
|
|
1306
|
+
async function escalateOn(trigger) {
|
|
1307
|
+
if (!shouldEscalate(route, escalated, trigger)) return;
|
|
1308
|
+
escalated = true;
|
|
1309
|
+
const rec = { event: "BUILDER_ESCALATED", from: route.model, to: builderModel ?? "default", trigger };
|
|
1310
|
+
ev(rec); await emit(rec);
|
|
1311
|
+
T.decisions.push(`Builder escalated from ${route.model} to the top model after a failed ${trigger} round`);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* #4363: one fix round. Mechanical failures (and a conformance/review FAIL citing file:line) run in a fresh session on
|
|
1315
|
+
* the fix model with a narrow prompt; one resume of the build session follows when it made no commit. Else: a resume.
|
|
1316
|
+
*/
|
|
1317
|
+
async function fixRound(kind, task, failures, trigger, failureText = "") {
|
|
1318
|
+
await notePitfall(trigger, failureText); await escalateOn(trigger); // #4375: learned once the same gate passes; #4394
|
|
1319
|
+
if (!freshFix(kind, failures)) return await builder(++attemptN, task, sessionId ?? null, false, undefined, { mode: "resume", trigger });
|
|
1320
|
+
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1321
|
+
const ex = [".", `:!${LEAN_DIR}`], diff = String(await git(["diff", `${base}...HEAD`, "--", ...ex]).catch(() => "")), stat = String(await git(["diff", "--stat", `${base}...HEAD`, "--", ...ex]).catch(() => ""));
|
|
1322
|
+
const prompt = fixSessionPrompt({ task, diff, stat, base, contract: frozen, title: iss?.title ?? "", summary: issueSummary(iss?.body) });
|
|
1323
|
+
const r = await builder(++attemptN, prompt, null, false, () => prompt, { model: escalated ? null : fixModel ?? reviewerModel, mode: "fresh", keepSession: true, trigger });
|
|
1324
|
+
if (!sessionId || String(await git(["rev-parse", "HEAD"])).trim() !== before) return r;
|
|
1325
|
+
T.decisions.push(`Fresh ${kind} fix session made no commit; builder session resumed once`);
|
|
1326
|
+
return await builder(++attemptN, task, sessionId, false, undefined, { mode: "resume", trigger });
|
|
1327
|
+
}
|
|
1030
1328
|
if (isSplit) {
|
|
1031
1329
|
// ---- 3'. refactor:split (#4211): deterministic move + proof replaces the builder; no builder session exists ----
|
|
1032
1330
|
split = await splitStep();
|
|
@@ -1048,13 +1346,23 @@ export async function runCore(o) {
|
|
|
1048
1346
|
} else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
1049
1347
|
}
|
|
1050
1348
|
}
|
|
1349
|
+
if ((resumed || salvagedRef || recover) && ctxCfg.mode !== "off") {
|
|
1350
|
+
// #4400: the last run's own records as a bounded digest in the builder prompts; never in issueText (contract quotes)
|
|
1351
|
+
const d = buildResumeDigest({ state: priorState, comments: trustedComments, contract: frozen, files: resumed ? await changedFiles().catch(() => []) : [], lane });
|
|
1352
|
+
resumeNote = d.text;
|
|
1353
|
+
if (d.text) {
|
|
1354
|
+
ev({ event: "RESUME_DIGEST", bytes: Buffer.byteLength(d.text, "utf8"), files: d.files, validates: d.validates, reason: d.reason });
|
|
1355
|
+
T.decisions.push(`Resume digest given to the builder: ${d.files} file(s), ${d.validates} validate result(s)${d.reason ? `, last run ${d.reason}` : ""}`);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1051
1358
|
await label("building");
|
|
1052
1359
|
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1360
|
+
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1053
1361
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1054
1362
|
const ctxPack = frozen ? null : await contextPackStep();
|
|
1055
|
-
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1363
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1056
1364
|
if (!frozen) {
|
|
1057
|
-
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
1365
|
+
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
|
|
1058
1366
|
planned = true;
|
|
1059
1367
|
result = await readResult();
|
|
1060
1368
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
@@ -1071,15 +1379,42 @@ export async function runCore(o) {
|
|
|
1071
1379
|
}
|
|
1072
1380
|
const blocked = await endIfBlocked();
|
|
1073
1381
|
if (blocked) return blocked;
|
|
1074
|
-
|
|
1382
|
+
// #4353: the engine's scope gate is the only scope authority; a pure file-scope validate is dropped before it is checked or frozen
|
|
1383
|
+
const dropScope = (c) => {
|
|
1384
|
+
const d = dropScopeValidates(c);
|
|
1385
|
+
if (d.dropped.length) {
|
|
1386
|
+
ev({ event: "CONTRACT_SCOPE_VALIDATE_DROPPED", runs: d.dropped });
|
|
1387
|
+
const note = `Scope-check validate ${d.dropped.map((x) => x.id).join(", ")} dropped at contract freeze: the engine's scope gate (deliverables plus typed amendments) enforces scope`;
|
|
1388
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1389
|
+
}
|
|
1390
|
+
const o = tagOutOfBand(d.contract); // #4404: a requirement measured outside the lane is judged by its instrumentation
|
|
1391
|
+
if (o.tagged.length) {
|
|
1392
|
+
ev({ event: "CONTRACT_OUT_OF_BAND_TAGGED", ids: o.tagged });
|
|
1393
|
+
const note = `Requirement(s) ${o.tagged.join(", ")} tagged verify out_of_band at contract freeze (measured by a job outside the lane)`;
|
|
1394
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1395
|
+
}
|
|
1396
|
+
return o.contract;
|
|
1397
|
+
};
|
|
1398
|
+
let contract = dropScope(await readContract());
|
|
1075
1399
|
await refreshIssue("contract validation");
|
|
1076
1400
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1401
|
+
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
1402
|
+
let misses = missed;
|
|
1403
|
+
if ((!vc.ok || missed.length) && sessionId) {
|
|
1404
|
+
const problems = [...vc.problems, ...preflightProblems(missed)];
|
|
1405
|
+
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1406
|
+
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
|
|
1407
|
+
contract = dropScope(await readContract());
|
|
1081
1408
|
await refreshIssue("contract validation");
|
|
1082
1409
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1410
|
+
if (missed.length) misses = await preflightValidates(contract);
|
|
1411
|
+
}
|
|
1412
|
+
if (missed.length) {
|
|
1413
|
+
ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind, source: m.source })), found: missed.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind, source: m.source })), corrected: misses.length === 0 });
|
|
1414
|
+
if (misses.length) {
|
|
1415
|
+
T.anomalies.push(`Validate preflight: the host lacks ${misses.map((m) => m.tool).join(", ")} after the contract correction (advisory; a failed rerun ends env.tool_missing)`);
|
|
1416
|
+
T.decisions.push(`Validate preflight left ${misses.length} miss(es) after one correction; contract frozen anyway`);
|
|
1417
|
+
}
|
|
1083
1418
|
}
|
|
1084
1419
|
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
1085
1420
|
if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
|
|
@@ -1095,13 +1430,28 @@ export async function runCore(o) {
|
|
|
1095
1430
|
T.validate = "resumed (existing branch)";
|
|
1096
1431
|
}
|
|
1097
1432
|
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1433
|
+
tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
|
|
1098
1434
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1099
|
-
|
|
1435
|
+
// #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
|
|
1436
|
+
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 */ } }
|
|
1437
|
+
route = routeBuilder({ contract: frozen, riskPaths, cfg: routingCfg, topModel: builderModel }); // #4394
|
|
1438
|
+
{
|
|
1439
|
+
const rec = { event: "BUILDER_ROUTE", mode: route.mode, class: route.class, tier: route.tier, model: route.model ?? "default", risk: route.risk };
|
|
1440
|
+
ev(rec); await emit(rec);
|
|
1441
|
+
T.decisions.push(`Builder routed to the ${route.tier} tier (${route.class}; ${route.model ?? "CLI default"})`);
|
|
1442
|
+
}
|
|
1443
|
+
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);
|
|
1100
1444
|
liveKeys = ["implementation"];
|
|
1101
1445
|
stage = "implement";
|
|
1102
|
-
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
1446
|
+
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
1103
1447
|
stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
|
|
1104
1448
|
result = await readResult();
|
|
1449
|
+
{ // #4374: the builder's self-review against the shared rubric, log only
|
|
1450
|
+
const sr = readSelfReview(result);
|
|
1451
|
+
if (sr) ev(sr.state === "present" ? { event: "SELF_REVIEW", answers: sr.items.length, no: sr.no } : { event: "SELF_REVIEW_MISSING", state: sr.state });
|
|
1452
|
+
}
|
|
1453
|
+
// #4364: a builder that writes fixed without a green `forgedock check` on its final tree; recorded, never a failure
|
|
1454
|
+
if (result?.status === "fixed") { const state = await selfCheckNow(); if (state !== "green") ev({ event: "SELF_CHECK_MISSING", state }); }
|
|
1105
1455
|
{
|
|
1106
1456
|
const blocked = await endIfBlocked();
|
|
1107
1457
|
if (blocked) return blocked;
|
|
@@ -1118,30 +1468,38 @@ export async function runCore(o) {
|
|
|
1118
1468
|
// verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
|
|
1119
1469
|
let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
|
|
1120
1470
|
// #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
|
|
1121
|
-
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1122
1471
|
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1123
1472
|
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1124
|
-
// #
|
|
1125
|
-
const pickChecks = () => {
|
|
1473
|
+
// #4331: a learned command the map covers runs only when its globs match a changed file; unmapped ones always run (#4315)
|
|
1474
|
+
const pickChecks = () => planPrecheck({ testMap, files, learned: learnedCommands, maxMinutes: o.precheck?.maxMinutes, verify: vcfg });
|
|
1126
1475
|
let picked = pickChecks();
|
|
1127
1476
|
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1128
1477
|
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1129
|
-
if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
|
|
1478
|
+
if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length - picked.dropped.length} learned` : ""}${picked.dropped.length ? `; ${picked.dropped.length} mapped learned command(s) not matching the changed files left to CI` : ""}`);
|
|
1130
1479
|
if (picked.commands.length && io.runCommand) {
|
|
1480
|
+
precheckRan = true;
|
|
1131
1481
|
for (let round = 0; ; round++) {
|
|
1132
1482
|
await renew();
|
|
1133
1483
|
if (round) picked = pickChecks();
|
|
1134
1484
|
const precheckCommands = picked.commands;
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1485
|
+
// #4318/#4331: one round has a total budget (lean.precheck.max_minutes, else verify.precheck_total_seconds); the
|
|
1486
|
+
// commands run concurrently up to the host worker cap; the rest are deferred to CI, not failed. #4344: one heavy
|
|
1487
|
+
// slot per running command, and the worker cap is divided among them (FORGE_HOST_PARALLELISM = floor(N / k))
|
|
1488
|
+
const totalMs = picked.budgetMs;
|
|
1489
|
+
const parallelism = Number(verifyParallelism()) || 1;
|
|
1490
|
+
let workers = 1;
|
|
1491
|
+
const round$ = await runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: picked.perCommandMs, now,
|
|
1492
|
+
acquire: () => heavyAcquire("precheck"),
|
|
1493
|
+
run: ({ command, timeoutMs, workers: share }) => { workers = share; const env = laneEnv(); return io.runCommand({ command, cwd: wt, timeoutMs, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(share) } : env }); } });
|
|
1494
|
+
const { results, deferred } = round$;
|
|
1495
|
+
precheckMs += round$.ms; precheckDeferred += deferred.length;
|
|
1496
|
+
if (deferred.length) T.anomalies.push(`deferred-to-ci: pre-PR check budget ${totalMs / 1000} s reached; left to CI: ${deferred.map((c) => `\`${c}\``).join(", ")}`);
|
|
1497
|
+
const roundFailed = results.some((r) => r.rc !== 0);
|
|
1498
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: [...results.map(({ command, rc, ms }) => ({ command, rc, ms })), ...deferred.map((command) => ({ command, status: "deferred-to-ci" }))],
|
|
1499
|
+
ms: round$.ms, parallelism, workers, ...(picked.source !== "learned" ? { source: picked.source } : {}), ...(roundFailed ? { self_check: await selfCheckNow() } : {}) });
|
|
1142
1500
|
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1143
1501
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1144
|
-
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
1502
|
+
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); await gatePassed("precheck"); break; }
|
|
1145
1503
|
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1146
1504
|
if (isSplit) return await needsHuman(`Declared check command(s) fail on the refactor:split move (no builder edits a split); nothing merged: ${names}`, OUTCOME_CODES.CI_RED);
|
|
1147
1505
|
checkCommandsFailing = true;
|
|
@@ -1149,7 +1507,14 @@ export async function runCore(o) {
|
|
|
1149
1507
|
T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1150
1508
|
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1151
1509
|
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
1152
|
-
|
|
1510
|
+
// #4390: the builder sees each failure quiet (tail, failing ids, full-log path); the pitfall text stays the capped tail
|
|
1511
|
+
const quiet = await Promise.all(failed.map(async (f, i) => {
|
|
1512
|
+
const rel = `${CHECK_LOG_DIR}/precheck-${round + 1}-${i + 1}.log`;
|
|
1513
|
+
let logPath = null;
|
|
1514
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${rel}`, String(f.output ?? "")); logPath = rel; } catch { /* tail only */ } }
|
|
1515
|
+
return { command: f.command, rc: f.rc, output: quietFailure(f.output, { maxChars: capped[i].output.length || 500, logPath }) };
|
|
1516
|
+
}));
|
|
1517
|
+
await fixRound("precheck", precheckFailurePrompt(quiet, vcfg.mode), [], "precheck", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
|
|
1153
1518
|
await upsertStage("implementation");
|
|
1154
1519
|
result = (await readResult()) ?? result;
|
|
1155
1520
|
if (String(await git(["rev-parse", "HEAD"])).trim() === before) { T.anomalies.push(`Declared check command(s) failing and the builder made no new commit for them: ${names}`); break; }
|
|
@@ -1175,13 +1540,14 @@ export async function runCore(o) {
|
|
|
1175
1540
|
let edges = undeclared();
|
|
1176
1541
|
if (edges.length && sessionId) {
|
|
1177
1542
|
T.decisions.push(`Undeclared package edge(s) ${edges.map((e) => `${e.from}→${e.to}`).join(", ")}; builder resumed once`);
|
|
1178
|
-
await
|
|
1543
|
+
await fixRound("structure", structurePrompt(edges, vcfg.mode), [], "structure", edges.map((e) => `undeclared package edge \`${e.from}\` -> \`${e.to}\` (${e.manifest})`).join("\n"));
|
|
1179
1544
|
await upsertStage("implementation");
|
|
1180
1545
|
result = (await readResult()) ?? result;
|
|
1181
1546
|
structure = (await structureOf()) ?? structure;
|
|
1182
1547
|
edges = undeclared();
|
|
1183
1548
|
}
|
|
1184
1549
|
verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
|
|
1550
|
+
if (!edges.length) await gatePassed("structure");
|
|
1185
1551
|
if (edges.length) {
|
|
1186
1552
|
return await needsHuman(`The change adds dependencies between workspace packages that the frozen contract does not name${sessionId ? ", after one builder resume" : ""}; nothing merged:\n${edges.slice(0, 15).map((e) => `- ${e.from} → ${e.to} (${e.kind}, \`${e.manifest}\`)`).join("\n")}\n\nAdd each to the contract with a reason, or remove it.`, OUTCOME_CODES.STRUCTURE_UNDECLARED_EDGE);
|
|
1187
1553
|
}
|
|
@@ -1193,16 +1559,117 @@ export async function runCore(o) {
|
|
|
1193
1559
|
if (docs && !docs.ok && sessionId) {
|
|
1194
1560
|
ev({ event: "DOCS_STALE", round: 1 });
|
|
1195
1561
|
T.decisions.push("Docs freshness check failed; builder resumed once");
|
|
1196
|
-
await
|
|
1562
|
+
await fixRound("docs", docsPrompt(docs.output), [], "docs", String(docs.output ?? ""));
|
|
1197
1563
|
await upsertStage("implementation");
|
|
1198
1564
|
result = (await readResult()) ?? result;
|
|
1199
1565
|
docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1200
1566
|
}
|
|
1201
1567
|
verifyFacts.docs = docs;
|
|
1568
|
+
if (docs?.ok) await gatePassed("docs");
|
|
1202
1569
|
if (docs && !docs.ok) {
|
|
1203
1570
|
return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
|
|
1204
1571
|
}
|
|
1205
1572
|
}
|
|
1573
|
+
// ---- 3d. enumerating tests (#4322): new symbols that tests listing their siblings do not name; one resume, then an
|
|
1574
|
+
// anomaly (CI stays the gate). Sequence numbers the base already uses: renumbered at hand-off, still colliding = base.conflict.
|
|
1575
|
+
if (io.enumeratingMissing && !isSplit) {
|
|
1576
|
+
const missingNow = async () => unjustified((await io.enumeratingMissing({ cwd: wt, base }).catch(() => null)) ?? [], result?.not_members);
|
|
1577
|
+
let missing = await missingNow();
|
|
1578
|
+
if (missing.length && sessionId) {
|
|
1579
|
+
ev({ event: "ENUMERATING_MISSING", count: missing.length });
|
|
1580
|
+
T.decisions.push(`Enumerating test(s) miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}; builder resumed once`);
|
|
1581
|
+
await fixRound("enumerating", missingEntriesPrompt(missing, vcfg.mode), [], "enumerating", missing.map((m) => `enumerating test ${m.test} misses new member \`${m.symbol}\``).join("\n"));
|
|
1582
|
+
await upsertStage("implementation");
|
|
1583
|
+
result = (await readResult()) ?? result;
|
|
1584
|
+
missing = await missingNow();
|
|
1585
|
+
}
|
|
1586
|
+
if (!missing.length) await gatePassed("enumerating");
|
|
1587
|
+
if (missing.length) T.anomalies.push(`Enumerating test(s) still miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}`);
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* #4322: null (nothing to do), {done} (terminal), or {renumbered: true, deterministic?}. Fetches the lane, so the check
|
|
1591
|
+
* sees its tip. #4386: the engine renumbers first (no builder, no session); the builder only when that is unsupported.
|
|
1592
|
+
* `laneRef`: the pre-merge lane head after a base-sync took the base side of sequence-only conflicts.
|
|
1593
|
+
*/
|
|
1594
|
+
async function sequenceHandoff(trigger, { laneRef = null } = {}) {
|
|
1595
|
+
if (!io.sequenceCheck || isSplit) return null;
|
|
1596
|
+
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
1597
|
+
const collisions = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1598
|
+
if (!collisions.length) return null;
|
|
1599
|
+
const before = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1600
|
+
ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
|
|
1601
|
+
const taken = collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ");
|
|
1602
|
+
if (io.sequenceRenumber) {
|
|
1603
|
+
const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
|
|
1604
|
+
if (det.ok && det.moves?.length) {
|
|
1605
|
+
ev({ event: "SEQUENCE_RENUMBERED", trigger, mode: "deterministic", moves: det.moves });
|
|
1606
|
+
T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; renumbered deterministically (no builder): ${det.moves.map((m) => `${m.from} → ${m.to}`).join(", ")}`);
|
|
1607
|
+
const v = (await io.sequenceVerify?.({ cwd: wt, base, before, moves: det.moves }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
|
|
1608
|
+
if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the deterministic renumber (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
|
|
1609
|
+
await gatePassed("sequence");
|
|
1610
|
+
files = await changedFiles();
|
|
1611
|
+
return { renumbered: true, deterministic: true };
|
|
1612
|
+
}
|
|
1613
|
+
const why = det.unsupported ?? "no movable sequence file";
|
|
1614
|
+
ev({ event: "SEQUENCE_RENUMBER_FALLBACK", trigger, reason: why });
|
|
1615
|
+
T.decisions.push(`Deterministic renumber not possible (${why}); builder renumbers`);
|
|
1616
|
+
}
|
|
1617
|
+
T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; builder renumbers`);
|
|
1618
|
+
await fixRound("sequence", sequencePrompt(collisions, lane, vcfg.mode), [], "sequence", collisions.map((c) => `sequence number collision: ${c.lane} already taken by ${(c.base ?? []).join(", ")}`).join("\n"));
|
|
1619
|
+
result = (await readResult()) ?? result;
|
|
1620
|
+
const v = (await io.sequenceVerify?.({ cwd: wt, base, before }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
|
|
1621
|
+
if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the builder renumbered (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
|
|
1622
|
+
await gatePassed("sequence");
|
|
1623
|
+
files = await changedFiles();
|
|
1624
|
+
return { renumbered: true };
|
|
1625
|
+
}
|
|
1626
|
+
{ const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
|
|
1627
|
+
/** #4406: same-app duplicate exports (verify.duplicate_exports): one "rename or reuse" round, then an anomaly. True when a fix round ran. */
|
|
1628
|
+
async function duplicateGate(trigger) {
|
|
1629
|
+
if (!io.duplicateExports || isSplit || vcfg.duplicateExports === false) return false;
|
|
1630
|
+
const found = async () => (await io.duplicateExports({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1631
|
+
let dups = await found();
|
|
1632
|
+
if (dups.length && sessionId) {
|
|
1633
|
+
const text = dups.map((d) => `duplicate export \`${d.symbol}\`: ${d.path} and ${(d.others ?? []).join(", ")}`).join("\n");
|
|
1634
|
+
ev({ event: "DUPLICATE_EXPORTS", trigger, count: dups.length });
|
|
1635
|
+
T.decisions.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path} vs ${(d.others ?? []).join(", ")}`).join("; ")}; builder resumed once`);
|
|
1636
|
+
await fixRound("duplicate", duplicateExportsPrompt(dups, vcfg.mode), [], "duplicate", text);
|
|
1637
|
+
result = (await readResult()) ?? result;
|
|
1638
|
+
files = await changedFiles();
|
|
1639
|
+
dups = await found();
|
|
1640
|
+
if (dups.length) T.anomalies.push(`Duplicate export(s) remain (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path} vs ${(d.others ?? []).join(", ")}`).join("; ")}`);
|
|
1641
|
+
else await gatePassed("duplicate");
|
|
1642
|
+
return true;
|
|
1643
|
+
}
|
|
1644
|
+
if (dups.length) T.anomalies.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path}`).join("; ")}`);
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
await duplicateGate("hand-off");
|
|
1648
|
+
/** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
|
|
1649
|
+
async function postMergeGate(trigger) {
|
|
1650
|
+
const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
|
|
1651
|
+
let fixed = false, results = [];
|
|
1652
|
+
for (let round = 0; commands.length; round++) {
|
|
1653
|
+
await renew();
|
|
1654
|
+
results = [];
|
|
1655
|
+
for (const command of commands) {
|
|
1656
|
+
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) }; }
|
|
1657
|
+
results.push({ command, rc: Number(x?.rc ?? 1), output: String(x?.output ?? "") });
|
|
1658
|
+
}
|
|
1659
|
+
ev({ event: "POST_MERGE_CHECKS", trigger, round, results: results.map(({ command, rc }) => ({ command, rc })) });
|
|
1660
|
+
const failed = results.filter((r) => r.rc !== 0), names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1661
|
+
if (!failed.length) { if (round) await gatePassed("post_merge"); break; }
|
|
1662
|
+
if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Post-merge check(s) still fail after merging ${base} (${trigger}): ${names}`); break; }
|
|
1663
|
+
T.decisions.push(`Post-merge check(s) failed after merging ${base} (${trigger}): ${names}; builder resumed before the push (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1664
|
+
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1665
|
+
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
1666
|
+
await fixRound("post_merge", postMergeFailurePrompt(capped, vcfg.mode), [], "post_merge", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
|
|
1667
|
+
result = (await readResult()) ?? result;
|
|
1668
|
+
fixed = true;
|
|
1669
|
+
if ((String(await git(["rev-parse", "HEAD"])).trim()) === before) { T.anomalies.push(`Post-merge check(s) failing and the builder made no new commit for them: ${names}`); break; }
|
|
1670
|
+
}
|
|
1671
|
+
return { fixed, ran: results.map(({ command, rc }) => `${command}: ${rc === 0 ? "pass" : `fail (exit ${rc})`}`).join("; ") };
|
|
1672
|
+
}
|
|
1206
1673
|
const structureLine = structureNote(structure);
|
|
1207
1674
|
|
|
1208
1675
|
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
@@ -1210,6 +1677,7 @@ export async function runCore(o) {
|
|
|
1210
1677
|
// cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
|
|
1211
1678
|
let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
|
|
1212
1679
|
const ciSeenRuns = [];
|
|
1680
|
+
let ciGateNames; // #4387: checks that gate CI verification, read once per lane
|
|
1213
1681
|
/** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
|
|
1214
1682
|
async function ciVerify(trigger) {
|
|
1215
1683
|
let fixes = 0;
|
|
@@ -1217,15 +1685,18 @@ export async function runCore(o) {
|
|
|
1217
1685
|
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1218
1686
|
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
1219
1687
|
files = await changedFiles();
|
|
1220
|
-
|
|
1688
|
+
if (ciGateNames === undefined) ciGateNames = vcfg.workflow ? null : await ciGate({ io, lane, mergeWait, verify: vcfg });
|
|
1689
|
+
const verifyArgs = { io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns, gate: ciGateNames,
|
|
1221
1690
|
push: () => gitPush(["push", "-u", "origin", branch]),
|
|
1222
|
-
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } }
|
|
1691
|
+
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } };
|
|
1692
|
+
const v = await runCiVerify(verifyArgs);
|
|
1223
1693
|
if (v.runId != null) ciSeenRuns.push(v.runId);
|
|
1224
1694
|
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) };
|
|
1225
1695
|
ev(rec); await emit({ ...rec, phase: "verify" });
|
|
1226
1696
|
const row = (res) => { T.verify = [res, `mode ${vcfg.mode}, ${ciRounds} fix round(s)${v.runUrl ? `, ${v.runUrl}` : ""}`]; };
|
|
1227
1697
|
if (v.result === "pass" || v.result === "skipped") {
|
|
1228
1698
|
ciVerifiedHead = head;
|
|
1699
|
+
if (v.result === "pass") await gatePassed("ci_red", "merge_ci");
|
|
1229
1700
|
row(v.result === "pass" ? `CI green at ${head.slice(0, 12)}` : "skipped (docs-only change)");
|
|
1230
1701
|
if (v.result === "skipped") T.decisions.push(`CI verification skipped: ${v.detail}`);
|
|
1231
1702
|
return { fixes };
|
|
@@ -1240,7 +1711,7 @@ export async function runCore(o) {
|
|
|
1240
1711
|
ciFellBack = true;
|
|
1241
1712
|
row("unavailable: local fallback");
|
|
1242
1713
|
T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
|
|
1243
|
-
await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null);
|
|
1714
|
+
await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null, false, undefined, { trigger: "ci_unavailable" });
|
|
1244
1715
|
await upsertStage("implementation");
|
|
1245
1716
|
result = (await readResult()) ?? result;
|
|
1246
1717
|
return { fixes: fixes + 1 };
|
|
@@ -1254,11 +1725,22 @@ export async function runCore(o) {
|
|
|
1254
1725
|
ciRounds++; fixes++;
|
|
1255
1726
|
T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1256
1727
|
ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
|
|
1257
|
-
await
|
|
1728
|
+
await fixRound("ci", ciFailurePrompt(v.failures, vcfg.mode), [], "ci_red", failureLog(v.failures));
|
|
1258
1729
|
await upsertStage("implementation");
|
|
1259
1730
|
result = (await readResult()) ?? result;
|
|
1260
1731
|
if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
|
|
1261
|
-
|
|
1732
|
+
// #4387: "no commit" says the failure is not the change's: rerun the failed jobs once to tell a flake from a real failure.
|
|
1733
|
+
const rr = isSplit ? { ok: false, runs: [], jobs: [], detail: "refactor:split" } : await rerunFailedOnce({ io, v });
|
|
1734
|
+
const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(v) }) : null;
|
|
1735
|
+
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 });
|
|
1736
|
+
if (v2?.result === "pass") {
|
|
1737
|
+
ciVerifiedHead = head; await gatePassed("ci_red", "merge_ci"); row(`CI green at ${head.slice(0, 12)} after a rerun of the failed jobs`);
|
|
1738
|
+
T.decisions.push(`CI verification red (${jobs}), no fix commit; green after one rerun of the failed jobs (flake)`);
|
|
1739
|
+
return { fixes };
|
|
1740
|
+
}
|
|
1741
|
+
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`) };
|
|
1742
|
+
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";
|
|
1743
|
+
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) };
|
|
1262
1744
|
}
|
|
1263
1745
|
}
|
|
1264
1746
|
}
|
|
@@ -1269,8 +1751,8 @@ export async function runCore(o) {
|
|
|
1269
1751
|
// plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
|
|
1270
1752
|
// A review without usable rows ends needs-human (or, with engine.review_incomplete: degraded, merges flagged).
|
|
1271
1753
|
await label("in-review");
|
|
1272
|
-
|
|
1273
|
-
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false;
|
|
1754
|
+
let shadow = conformance !== "enforce";
|
|
1755
|
+
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
|
|
1274
1756
|
const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
|
|
1275
1757
|
const triggerSkipNote = "## Conformance Review\n**Skipped by `review.trigger: enforce`:** no risk path, package edge or hotspot in the diff, and every deterministic gate passed; no reviewer ran (#4273).";
|
|
1276
1758
|
async function amend() {
|
|
@@ -1292,7 +1774,12 @@ export async function runCore(o) {
|
|
|
1292
1774
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1293
1775
|
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
|
|
1294
1776
|
let d = null, first = null;
|
|
1295
|
-
for (let t = 0; t <
|
|
1777
|
+
for (let t = 0; t < 3; t++) {
|
|
1778
|
+
// #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
|
|
1779
|
+
if (t === 2) {
|
|
1780
|
+
if (d.state !== "review.incomplete" || !evidencePathOnly(d.failures.map((f) => f.detail))) break;
|
|
1781
|
+
T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
|
|
1782
|
+
}
|
|
1296
1783
|
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
1297
1784
|
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
1298
1785
|
await renew();
|
|
@@ -1300,33 +1787,112 @@ export async function runCore(o) {
|
|
|
1300
1787
|
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
1301
1788
|
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
|
|
1302
1789
|
await renew();
|
|
1303
|
-
await attempt("review", ++reviewN, rr);
|
|
1790
|
+
await attempt("review", ++reviewN, rr, null, "review");
|
|
1304
1791
|
const parsed = parseReview(rr?.text);
|
|
1305
1792
|
review = repair ? mergeReviewRows(first, parsed) : parsed;
|
|
1306
1793
|
first = review;
|
|
1307
1794
|
d = decide(review, ctx);
|
|
1308
1795
|
if (d.review) review = d.review;
|
|
1309
1796
|
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
1797
|
+
const cmp = selfReviewCompared ? null : compareSelfReview(readSelfReview(result), d); // #4374: once, on the first judgement
|
|
1798
|
+
if (cmp) { selfReviewCompared = true; ev({ event: cmp.agreed ? "SELF_REVIEW_AGREED" : "SELF_REVIEW_DISAGREED", reviewer: cmp.reviewer, self_no: cmp.self_no, failures: d.failures.map((x) => x.code) }); }
|
|
1310
1799
|
if (!d.state.startsWith("review.")) return d;
|
|
1311
1800
|
}
|
|
1312
1801
|
return d;
|
|
1313
1802
|
}
|
|
1803
|
+
/**
|
|
1804
|
+
* #4397: one independent adversarial verifier call per head: fresh context, read-only, never the builder's model, result
|
|
1805
|
+
* or notes. Returns the failures to enforce ([] in shadow, on an empty diff or on a verifier error, which is an anomaly).
|
|
1806
|
+
*/
|
|
1807
|
+
const verifiedHeads = new Map();
|
|
1808
|
+
let verifierRun = null, verifyN = 0;
|
|
1809
|
+
async function runVerifier() {
|
|
1810
|
+
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1811
|
+
if (verifiedHeads.has(head)) return verifiedHeads.get(head);
|
|
1812
|
+
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
1813
|
+
const run = { mode: verifierCfg.mode, head, findings: [], model: null, swapped: false, cost_usd: 0 };
|
|
1814
|
+
if (!diff.trim()) run.skipped = "empty_diff";
|
|
1815
|
+
else {
|
|
1816
|
+
const bytes = Buffer.byteLength(diff, "utf-8");
|
|
1817
|
+
const stat = bytes > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
1818
|
+
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1819
|
+
const callers = await callersOf({ symbols: changedSymbols(diff), gitGrep: git, ignore: IGNORE });
|
|
1820
|
+
Object.assign(run, pickVerifierModel({ configured: verifierCfg.model, builderModel, builderReported: [...impl.models, ...(route?.tier === "mid" ? [route.model] : [])] }));
|
|
1821
|
+
const { maxMs, maxTurns } = verifierBudget(bytes);
|
|
1822
|
+
try {
|
|
1823
|
+
await renew();
|
|
1824
|
+
const rr = await callRunner({ role: "verifier", issue, cwd: wt, model: run.model, resume: null, system: VERIFIER_SYSTEM, readOnly: true, maxMs, maxTurns,
|
|
1825
|
+
prompt: verifierPrompt({ issue: iss, comments: trustedComments, contract: effective, diff, stat, base, files, callers }) });
|
|
1826
|
+
await renew();
|
|
1827
|
+
await attempt("verify", ++verifyN, rr, null, "verifier");
|
|
1828
|
+
run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
|
|
1829
|
+
run.cost_usd = num(rr?.costUsd) ?? 0;
|
|
1830
|
+
} catch (e) {
|
|
1831
|
+
if (e?.deferUsageLimit) throw e;
|
|
1832
|
+
run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
|
|
1833
|
+
T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
const failures = verifierCfg.mode === "enforce" ? findingsToFailures(run.findings) : [];
|
|
1837
|
+
ev({ event: "VERIFIER", mode: run.mode, head, model: run.model, swapped: run.swapped, findings: run.findings.length, cost_usd: run.cost_usd, implement_cost_usd: impl.cost,
|
|
1838
|
+
ratio: impl.cost > 0 ? Math.round((run.cost_usd / impl.cost) * 1000) / 1000 : null, ...(run.skipped ? { skipped: run.skipped } : {}), ...(run.error ? { error: run.error } : {}) });
|
|
1839
|
+
if (run.findings.length) T.decisions.push(`Independent verifier (${run.mode}) found ${run.findings.length} defect(s): ${run.findings.slice(0, 3).map((f) => `${f.file}:${f.line}`).join(", ")}`);
|
|
1840
|
+
verifiedHeads.set(head, failures);
|
|
1841
|
+
verifierRun = run;
|
|
1842
|
+
return failures;
|
|
1843
|
+
}
|
|
1844
|
+
const verifierSection = () => { const n = renderVerifierNote(verifierRun); return n ? `\n\n${n}` : ""; };
|
|
1845
|
+
/** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
|
|
1846
|
+
async function rerunFailedValidates(contract) {
|
|
1847
|
+
if (!io.runCommand) return null;
|
|
1848
|
+
const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
|
|
1849
|
+
const env = laneEnv();
|
|
1850
|
+
for (const v of contract.validate ?? []) {
|
|
1851
|
+
const r = runs.find((x) => x?.id === v.id);
|
|
1852
|
+
if (!r || r.exit === undefined || r.exit === 0) continue;
|
|
1853
|
+
let x;
|
|
1854
|
+
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) }; }
|
|
1855
|
+
out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
|
|
1856
|
+
}
|
|
1857
|
+
if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
1858
|
+
return out;
|
|
1859
|
+
}
|
|
1314
1860
|
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
1315
1861
|
async function gate() {
|
|
1316
1862
|
await refreshIssue("conformance");
|
|
1317
1863
|
result = (await readResult()) ?? result;
|
|
1318
1864
|
files = await changedFiles();
|
|
1865
|
+
gateRan = true;
|
|
1866
|
+
// #4398: a diff touching a declared review.risk_paths glob makes this lane enforce from here on (sticky)
|
|
1867
|
+
const hits = shadow ? riskPathHits(files, riskPaths) : [];
|
|
1868
|
+
if (hits.length) {
|
|
1869
|
+
shadow = false; riskEnforced = hits;
|
|
1870
|
+
ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
|
|
1871
|
+
T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
|
|
1872
|
+
}
|
|
1319
1873
|
const amendProblems = await amend();
|
|
1320
1874
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1321
1875
|
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1322
|
-
const
|
|
1876
|
+
const validateRuns = await rerunFailedValidates(effective);
|
|
1877
|
+
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
1878
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
|
|
1879
|
+
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
1880
|
+
if (pc.superseded?.length) {
|
|
1881
|
+
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
1882
|
+
const note = `Scope-check validate ${pc.superseded.map((x) => x.id).join(", ")} superseded by the scope gate (amended: ${[...new Set(pc.superseded.flatMap((x) => x.files))].join(", ")})`;
|
|
1883
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1884
|
+
}
|
|
1323
1885
|
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1324
|
-
|
|
1886
|
+
// #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
|
|
1887
|
+
// never a conformance failure, never a builder resume
|
|
1888
|
+
const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
|
|
1889
|
+
const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems];
|
|
1325
1890
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
1326
1891
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
1327
1892
|
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
1328
1893
|
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
|
|
1329
1894
|
if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
|
|
1895
|
+
if (envFails.length) return { state: "environment", by: "precheck", failures: envFails };
|
|
1330
1896
|
// #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
|
|
1331
1897
|
if (triggerMode !== "off") {
|
|
1332
1898
|
let t = null;
|
|
@@ -1339,14 +1905,19 @@ export async function runCore(o) {
|
|
|
1339
1905
|
}
|
|
1340
1906
|
triggerSkipped = false;
|
|
1341
1907
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1908
|
+
let d = await reviewOnce();
|
|
1909
|
+
// #4397: the verifier runs only on a change every gate and the reviewer passed; in enforce its findings block
|
|
1910
|
+
if (verifierCfg.mode !== "off" && d.state === "conforms" && !pre.length) {
|
|
1911
|
+
const vf = await runVerifier();
|
|
1912
|
+
if (vf.length) d = { state: "nonconforming", failures: vf, by: "verifier" };
|
|
1913
|
+
}
|
|
1914
|
+
if (!shadow) return { ...d, by: d.by ?? "review" };
|
|
1344
1915
|
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
1345
1916
|
const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
|
|
1346
1917
|
const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
|
|
1347
1918
|
if (d.state.startsWith("review.")) return { ...d, by: "review", would, wouldFail, reviewState: d.state };
|
|
1348
1919
|
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
1349
|
-
return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
1920
|
+
return { state: blocking.length ? "nonconforming" : "conforms", by: d.by ?? "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
1350
1921
|
}
|
|
1351
1922
|
const verifyReported = new Set();
|
|
1352
1923
|
/**
|
|
@@ -1371,9 +1942,21 @@ export async function runCore(o) {
|
|
|
1371
1942
|
}
|
|
1372
1943
|
ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
|
|
1373
1944
|
}
|
|
1374
|
-
/**
|
|
1945
|
+
/** #4323: a validate command the host could not run ends needs-human env.tool_missing (recoverable), never a builder resume. */
|
|
1946
|
+
// #4334: a repro test the harness could not run on head (and nothing else) ends env.repro_unrunnable (recoverable)
|
|
1947
|
+
async function envNeedsHuman(failures) {
|
|
1948
|
+
if (failures.every((f) => f.code === OUTCOME_CODES.ENV_REPRO_UNRUNNABLE)) {
|
|
1949
|
+
T.anomalies.push("Environment error: the repro harness could not run the repro test on head");
|
|
1950
|
+
return await needsHuman(`Environment error (not a conformance failure): the repro harness could not run the repro test on head; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nRun the repro command through the repo's own toolchain and installed dependencies, then rerun.`, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE);
|
|
1951
|
+
}
|
|
1952
|
+
const tools = [...new Set(failures.filter((f) => f.tool).map((f) => f.tool))].join(", ");
|
|
1953
|
+
T.anomalies.push(`Environment error: validate command(s) could not run, the host is missing ${tools}`);
|
|
1954
|
+
return await needsHuman(`Environment error (not a conformance failure): the host is missing ${tools}, so validate command(s) could not run; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nInstall it on the host or run the check through the repo's own toolchain, then rerun.`, OUTCOME_CODES.ENV_TOOL_MISSING);
|
|
1955
|
+
}
|
|
1956
|
+
/** Shape the base-sync path expects: {unusable: state} when no usable review, {env: failures} for a missing host tool, else {fail, verdict, reason, failures}. */
|
|
1375
1957
|
const doReview = async () => {
|
|
1376
1958
|
const d = await gate();
|
|
1959
|
+
if (d.state === "environment") return { env: d.failures };
|
|
1377
1960
|
if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
|
|
1378
1961
|
return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
|
|
1379
1962
|
};
|
|
@@ -1388,9 +1971,10 @@ export async function runCore(o) {
|
|
|
1388
1971
|
const outcome = await gate();
|
|
1389
1972
|
verdict = outcome;
|
|
1390
1973
|
for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
|
|
1391
|
-
if (outcome.state === "conforms") break;
|
|
1974
|
+
if (outcome.state === "conforms") { await gatePassed("conformance"); break; }
|
|
1392
1975
|
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1393
1976
|
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1977
|
+
if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
|
|
1394
1978
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1395
1979
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1396
1980
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -1404,7 +1988,7 @@ export async function runCore(o) {
|
|
|
1404
1988
|
T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")} (${explained.slice(0, 5).map((f) => String(f.detail).slice(0, 160)).join("; ")}); builder resumed once`);
|
|
1405
1989
|
// A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
|
|
1406
1990
|
if (outcome.failures.some((f) => f?.kind === "validate_failed")) { try { await io.git(["fetch", "origin", lane], { cwd: wt }); } catch { /* offline: the resume uses the local ref */ } }
|
|
1407
|
-
await
|
|
1991
|
+
await fixRound("conformance", conformanceResumePrompt(outcome.failures, vcfg.mode, lane), outcome.failures, "conformance", failureLog(outcome.failures));
|
|
1408
1992
|
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
1409
1993
|
}
|
|
1410
1994
|
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
@@ -1427,14 +2011,24 @@ export async function runCore(o) {
|
|
|
1427
2011
|
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1428
2012
|
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1429
2013
|
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1430
|
-
await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, degraded: degradedReq?.tracked ?? (degraded ? "untracked" : null) }));
|
|
2014
|
+
await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, degraded: degradedReq?.tracked ?? (degraded ? "untracked" : null) }) + verifierSection());
|
|
1431
2015
|
} else {
|
|
1432
2016
|
T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1433
|
-
await post("FORGE:REVIEW", renderReview(review, effective));
|
|
2017
|
+
await post("FORGE:REVIEW", renderReview(review, effective) + verifierSection());
|
|
1434
2018
|
}
|
|
1435
2019
|
}
|
|
1436
2020
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
1437
|
-
const closing = closePlan(effective);
|
|
2021
|
+
const closing = closePlan(effective, { title: iss.title, review });
|
|
2022
|
+
if (closing.reason === "partial") { // #4398: an investigation whose question is not answered carries it forward
|
|
2023
|
+
const untracked = closing.open.filter((r) => !r.tracked);
|
|
2024
|
+
if (untracked.length) {
|
|
2025
|
+
const carried = untracked.map((r) => ({ ...r, disposition: "defer", why: "investigation question not answered" }));
|
|
2026
|
+
await track(carried, "the investigation's question is not answered");
|
|
2027
|
+
for (const [i, r] of untracked.entries()) r.tracked = carried[i].tracked;
|
|
2028
|
+
}
|
|
2029
|
+
for (const t of closing.open.map((r) => r.tracked).filter(Boolean)) if (!closing.tracked.includes(t)) closing.tracked.push(t);
|
|
2030
|
+
T.decisions.push(`Investigation not answered (${closing.open.map((r) => r.id).join(", ")}): closing as partial, carried forward in ${closing.tracked.join(", ")}`);
|
|
2031
|
+
}
|
|
1438
2032
|
if (degradedReq?.tracked && !closing.tracked.includes(degradedReq.tracked)) closing.tracked.push(degradedReq.tracked);
|
|
1439
2033
|
|
|
1440
2034
|
// ---- 5. push + PR ----
|
|
@@ -1471,7 +2065,8 @@ export async function runCore(o) {
|
|
|
1471
2065
|
T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
|
|
1472
2066
|
|
|
1473
2067
|
// ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
|
|
1474
|
-
|
|
2068
|
+
// #4386: the bound counts syncs against one base head; a sync overtaken by a new base head made progress.
|
|
2069
|
+
let syncs = 0, sameBaseSyncs = 0, lastSyncBase = null, mergeRetries = 0;
|
|
1475
2070
|
const MAX_SYNCS = 2;
|
|
1476
2071
|
const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
|
|
1477
2072
|
/** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
|
|
@@ -1506,11 +2101,12 @@ export async function runCore(o) {
|
|
|
1506
2101
|
const syncRow = [];
|
|
1507
2102
|
/** Returns null to continue with the merge flow, or a terminal result. */
|
|
1508
2103
|
async function baseSync(trigger, soft = false) {
|
|
1509
|
-
if (syncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
1510
|
-
syncs++;
|
|
1511
2104
|
await renew();
|
|
1512
2105
|
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
1513
|
-
const
|
|
2106
|
+
const baseFull = await gitOut(["rev-parse", base]), baseSha = baseFull.slice(0, 12);
|
|
2107
|
+
if (baseFull !== lastSyncBase) { lastSyncBase = baseFull; sameBaseSyncs = 0; }
|
|
2108
|
+
if (sameBaseSyncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs against ${baseSha || base} (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
2109
|
+
sameBaseSyncs++; syncs++;
|
|
1514
2110
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1515
2111
|
let clean = true, files = [];
|
|
1516
2112
|
try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
|
|
@@ -1518,13 +2114,21 @@ export async function runCore(o) {
|
|
|
1518
2114
|
await gitOut(["merge", "--abort"]);
|
|
1519
2115
|
return await needsHuman(`PR #${pr} (refactor:split) conflicts with ${lane} in ${files.join(", ") || "unknown files"}; a split is never resolved by an LLM. Close the PR and rerun on the new base.`, OUTCOME_CODES.BASE_CONFLICT);
|
|
1520
2116
|
}
|
|
1521
|
-
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
|
|
2117
|
+
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)", detResolved = false;
|
|
2118
|
+
// #4386: conflicts only in sequence files (numbered, journal, snapshot, their index) resolve without a builder
|
|
2119
|
+
const rs = !clean && io.sequenceResolve ? ((await io.sequenceResolve({ cwd: wt, files }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? {}) : null;
|
|
2120
|
+
if (rs?.unsupported) T.decisions.push(`Base-sync ${syncs}: no deterministic resolution (${rs.unsupported})`);
|
|
1522
2121
|
if (clean) {
|
|
1523
2122
|
how = "clean merge of the base into the branch";
|
|
2123
|
+
} else if (rs?.ok) {
|
|
2124
|
+
detResolved = true;
|
|
2125
|
+
T.decisions.push(`Base-sync ${syncs}: conflicts only in sequence files (${files.join(", ")}); base side taken, lane files renumbered by the engine (no builder)`);
|
|
2126
|
+
how = `deterministic: base side taken for ${files.length} sequence file(s)`;
|
|
2127
|
+
tests = "not required (deterministic sequence resolution)";
|
|
1524
2128
|
} else {
|
|
1525
2129
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
1526
|
-
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge
|
|
1527
|
-
await builder(++attemptN, prompt, sessionId ?? null);
|
|
2130
|
+
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge.${sequenceSyncNote(files)} Do not push.`;
|
|
2131
|
+
await builder(++attemptN, prompt, sessionId ?? null, false, undefined, { trigger: "recovery" }); // #4362: base-sync conflict
|
|
1528
2132
|
if (await unresolved()) {
|
|
1529
2133
|
await gitOut(["merge", "--abort"]);
|
|
1530
2134
|
return await needsHuman(`Base-sync could not resolve the conflicts in ${files.join(", ")} (conflict markers or an unfinished merge remain).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
@@ -1533,8 +2137,19 @@ export async function runCore(o) {
|
|
|
1533
2137
|
const r2 = await readResult();
|
|
1534
2138
|
tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
|
|
1535
2139
|
}
|
|
2140
|
+
const sq = await sequenceHandoff(`base-sync ${syncs}`, detResolved ? { laneRef: before } : {}); // #4322: a clean text merge can still bring a taken number
|
|
2141
|
+
if (sq?.done) return sq.done;
|
|
2142
|
+
if (sq?.renumbered) { how += sq.deterministic ? "; lane files renumbered deterministically" : "; builder renumbered sequence-numbered files"; }
|
|
2143
|
+
let pm = { fixed: false, ran: "" }, dupFixed = false;
|
|
2144
|
+
if ((await gitOut(["rev-parse", "HEAD"])) !== before) { // #4406: the merged tree, before the push
|
|
2145
|
+
pm = await postMergeGate(`base-sync ${syncs}`);
|
|
2146
|
+
if (pm.ran) tests = clean ? pm.ran : `${tests}; post-merge: ${pm.ran}`;
|
|
2147
|
+
if (pm.fixed) how += "; builder fixed post-merge check failures";
|
|
2148
|
+
dupFixed = await duplicateGate(`base-sync ${syncs}`);
|
|
2149
|
+
if (dupFixed) how += "; builder resolved duplicate exports";
|
|
2150
|
+
}
|
|
1536
2151
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
1537
|
-
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
2152
|
+
if (soft && clean && after === before) { syncs--; sameBaseSyncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
1538
2153
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
1539
2154
|
let ciFixes = 0;
|
|
1540
2155
|
if (isSplit && after !== before) { // #4211: the merged head must still be a pure move against the new base
|
|
@@ -1543,17 +2158,22 @@ export async function runCore(o) {
|
|
|
1543
2158
|
rereview = "refactor-verify re-run against the new base: ok";
|
|
1544
2159
|
}
|
|
1545
2160
|
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1546
|
-
|
|
2161
|
+
const engineOnly = !ciFixes && !pm.fixed && !dupFixed && (clean || detResolved) && (!sq?.renumbered || sq.deterministic); // #4386: no builder touched the delta
|
|
2162
|
+
if (!isSplit && engineOnly && (detResolved || sq?.deterministic)) rereview = "not required (deterministic renumber + base merge)";
|
|
2163
|
+
else if (!isSplit && (!clean || sq?.renumbered || ciFixes || pm.fixed || dupFixed)) {
|
|
1547
2164
|
let rv = await doReview();
|
|
2165
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1548
2166
|
if (rv?.fail) {
|
|
1549
2167
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
1550
|
-
await
|
|
2168
|
+
await fixRound("review", conformanceResumePrompt(rv.failures, vcfg.mode), rv.failures, "review", failureLog(rv.failures));
|
|
1551
2169
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
1552
2170
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
1553
2171
|
rv = await doReview();
|
|
2172
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1554
2173
|
}
|
|
1555
2174
|
if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
|
|
1556
2175
|
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
2176
|
+
await gatePassed("review");
|
|
1557
2177
|
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
1558
2178
|
}
|
|
1559
2179
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
@@ -1579,7 +2199,7 @@ export async function runCore(o) {
|
|
|
1579
2199
|
T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1580
2200
|
ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
|
|
1581
2201
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1582
|
-
await
|
|
2202
|
+
await fixRound("ci", ciFailurePrompt(failures, vcfg.mode), [], "merge_ci", failureLog(failures));
|
|
1583
2203
|
await upsertStage("implementation");
|
|
1584
2204
|
result = (await readResult()) ?? result;
|
|
1585
2205
|
if ((await gitOut(["rev-parse", "HEAD"])) === before) {
|
|
@@ -1587,6 +2207,7 @@ export async function runCore(o) {
|
|
|
1587
2207
|
}
|
|
1588
2208
|
const rv = await doReview(); // never merge an unreviewed head
|
|
1589
2209
|
if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
2210
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1590
2211
|
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1591
2212
|
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1592
2213
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
@@ -1635,6 +2256,30 @@ export async function runCore(o) {
|
|
|
1635
2256
|
await emit(rec);
|
|
1636
2257
|
}
|
|
1637
2258
|
|
|
2259
|
+
/**
|
|
2260
|
+
* #4403 e2e merge gate (e2e.mjs): a lane touching e2e specs or with [type:e2e] criteria runs the repo's e2e (label or
|
|
2261
|
+
* dispatch) on the reviewed head before merge. Returns null to merge, {again} after a fix round, or {done}.
|
|
2262
|
+
*/
|
|
2263
|
+
let e2eHead = null;
|
|
2264
|
+
const e2eRuns = [];
|
|
2265
|
+
async function e2eGate() {
|
|
2266
|
+
const cur = await changedFiles(), want = e2eWanted({ cfg: vcfg.e2e, files: cur, contract: effective });
|
|
2267
|
+
if (!want.run) { if (want.reason === NO_TRIGGER && want.specs.length) T.decisions.push(`e2e not run: ${want.specs.length} e2e spec(s) changed but ${NO_TRIGGER}`); e2eHead = localHead; return null; }
|
|
2268
|
+
const v = await runE2eGate({ io: { ...io, addPrLabel: (n, l) => ghw(["pr", "edit", String(n), "-R", repo, "--add-label", l]) }, pr, branch, head: localHead, cfg: vcfg.e2e, specs: want.specs,
|
|
2269
|
+
changedFiles: cur, ciMaxMinutes, now, sleep, random, exclude: e2eRuns, onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "merge", detail: `e2e pending on PR #${pr}` }); } });
|
|
2270
|
+
if (v.runId != null) e2eRuns.push(v.runId);
|
|
2271
|
+
const own = v.result === "fail" ? (vcfg.e2e.blocking === "all" ? v.failures : ownFailures(v.failures, want.specs)) : [];
|
|
2272
|
+
const rec = { event: "E2E_VERIFY", pr, head: localHead, reason: want.reason, result: v.result, specs: want.specs, failures: v.failures.map((f) => f.job), own: own.map((f) => f.job), blocking: vcfg.e2e.blocking };
|
|
2273
|
+
ev(rec); await emit({ ...rec, phase: "merge" });
|
|
2274
|
+
T.decisions.push(`e2e (${want.reason}): ${v.result} at ${localHead.slice(0, 12)}${v.detail ? ` (${v.detail})` : ""}`);
|
|
2275
|
+
if (v.result === "timeout") return { done: await finish("ci-pending", `PR #${pr} e2e still pending after ${ciMaxMinutes} min`, { pr }) };
|
|
2276
|
+
if (v.result === "unavailable") return { done: await needsHuman(`e2e verification unavailable on PR #${pr} (${v.detail}); nothing merged.`) };
|
|
2277
|
+
if (own.length) { const r = await mergeFixRound(own.map((f) => ({ name: f.job, url: f.url }))); return r ? { done: r } : { again: true }; }
|
|
2278
|
+
if (v.result === "fail") T.anomalies.push(`Pre-existing e2e failure(s) reported, not blocking: ${v.failures.map((f) => f.job).join(", ")}`);
|
|
2279
|
+
e2eHead = localHead;
|
|
2280
|
+
return null;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
1638
2283
|
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
1639
2284
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
1640
2285
|
for (;;) {
|
|
@@ -1696,11 +2341,34 @@ export async function runCore(o) {
|
|
|
1696
2341
|
return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`, OUTCOME_CODES.MERGE_HEAD_MISMATCH);
|
|
1697
2342
|
}
|
|
1698
2343
|
}
|
|
2344
|
+
if (e2eHead !== localHead) { const g = await e2eGate(); if (g?.done) return g.done; if (g?.again) continue; }
|
|
1699
2345
|
if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
|
|
2346
|
+
// #4386: late binding. A lane with numbered files holds the host sequence lease from here through the merge and
|
|
2347
|
+
// re-checks its numbers against the fetched lane; a taken number is renumbered by the engine and only CI is waited on.
|
|
2348
|
+
if (io.sequenceCheck && io.sequenceLease && !isSplit) {
|
|
2349
|
+
if (!seqLease) {
|
|
2350
|
+
const mine = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
2351
|
+
if (mine.length) {
|
|
2352
|
+
seqLease = await io.sequenceLease.acquire({ repo, lane, dirs: [...new Set(mine.map((f) => dirname(f.path)))], issue, maxWaitS: ciMaxMinutes * 60 }).catch(() => null);
|
|
2353
|
+
if (seqLease) T.decisions.push(seqLease.timedOut ? `Sequence lease timed out after ${Math.round((seqLease.waitMs ?? 0) / 1000)}s (fail-open)` : `Sequence lease held (waited ${Math.round((seqLease.waitMs ?? 0) / 1000)}s)`);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
if (seqLease) {
|
|
2357
|
+
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
2358
|
+
const late = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
2359
|
+
if (late.length) {
|
|
2360
|
+
ev({ event: "SEQUENCE_LATE_BIND", pr, files: late.map((c) => c.lane) });
|
|
2361
|
+
const r = await baseSync("merge gate: sequence number taken"); if (r) return r;
|
|
2362
|
+
continue; // wait for the required checks of the renumbered head
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
1700
2366
|
T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
|
|
1701
2367
|
laneGuard(resumed?.base ?? lane, "merge");
|
|
1702
2368
|
try {
|
|
1703
2369
|
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
|
2370
|
+
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
2371
|
+
seqLease = null;
|
|
1704
2372
|
break;
|
|
1705
2373
|
} catch (e) {
|
|
1706
2374
|
const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
|
|
@@ -1733,8 +2401,11 @@ export async function runCore(o) {
|
|
|
1733
2401
|
}
|
|
1734
2402
|
try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
|
|
1735
2403
|
await deleteMergedBranch();
|
|
1736
|
-
|
|
1737
|
-
|
|
2404
|
+
const partial = closing.reason === "partial";
|
|
2405
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
|
|
2406
|
+
if (partial) {
|
|
2407
|
+
try { await post("FORGE:TRACKED", `## Closed as partial\nThe investigation's stated question is not answered in this change (PR #${pr}); carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
2408
|
+
} else if (closing.tracked.length) {
|
|
1738
2409
|
try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|
|
1739
2410
|
}
|
|
1740
2411
|
if (isSplit && split.rest) { // #4211: the rest of a large split is the next PR of the chain, planned already (no grouping call)
|
|
@@ -1747,7 +2418,8 @@ export async function runCore(o) {
|
|
|
1747
2418
|
}
|
|
1748
2419
|
await label("merged");
|
|
1749
2420
|
// forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
|
|
1750
|
-
|
|
2421
|
+
await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
|
|
2422
|
+
return await finish("merged", `PR #${pr}`, { pr, ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
1751
2423
|
} catch (e) {
|
|
1752
2424
|
if (e?.deferUsageLimit) {
|
|
1753
2425
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|