forgedock 1.30.0 → 1.32.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 +3 -2
- 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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -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 +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -15
- 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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- 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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- 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 +72 -10
- package/bin/lean/verify.test.mjs +68 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- 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 +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -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/trusted-comments.test.sh +1 -1
package/bin/lean/lean.mjs
CHANGED
|
@@ -8,32 +8,47 @@
|
|
|
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
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, repoTracks, parseReview, decide, mergeReviewRows, evidencePathOnly, 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 { preflightProbes, preflightProblems } from "./preflight.mjs";
|
|
18
|
+
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
19
|
+
import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
19
20
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
21
|
+
import { splitGate, rationaleLine } from "./decompose.mjs";
|
|
20
22
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
21
23
|
import { repoKey } from "../runner.mjs";
|
|
22
24
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
23
|
-
import {
|
|
24
|
-
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";
|
|
25
28
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
26
29
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
27
|
-
import {
|
|
28
|
-
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";
|
|
29
38
|
import { unjustified } from "./enumerating.mjs";
|
|
30
39
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
31
|
-
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";
|
|
32
42
|
import { laneBackendId } from "./limits.mjs";
|
|
33
43
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
34
|
-
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
44
|
+
import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
|
|
35
45
|
import { priorityFromLabels } from "./sched.mjs";
|
|
36
|
-
import {
|
|
46
|
+
import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
|
|
47
|
+
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
48
|
+
import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
|
|
49
|
+
import { buildResumeDigest } from "./resume.mjs";
|
|
50
|
+
import { lightEligibility } from "./light.mjs";
|
|
51
|
+
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
54
|
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
@@ -44,6 +59,7 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
44
59
|
[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",
|
|
45
60
|
[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",
|
|
46
61
|
[OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
|
|
62
|
+
[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",
|
|
47
63
|
[OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
|
|
48
64
|
[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",
|
|
49
65
|
});
|
|
@@ -64,6 +80,8 @@ const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CO
|
|
|
64
80
|
* over-threshold file (maintenance.hotspot_growth, forge#4195). Scope creep stays recorded-only.
|
|
65
81
|
*/
|
|
66
82
|
const SHADOW_PRE_BLOCKS = [...SHADOW_BLOCKS, OUTCOME_CODES.MAINTENANCE_HOTSPOT_GROWTH];
|
|
83
|
+
/** #4395: cap on the engine.lane_instructions file; a larger one is not slim and falls back to CLAUDE.md. */
|
|
84
|
+
export const LANE_INSTRUCTIONS_MAX_BYTES = 16384;
|
|
67
85
|
/** 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. */
|
|
68
86
|
export const REVIEW_INCOMPLETE_MODES = Object.freeze(["needs-human", "degraded"]);
|
|
69
87
|
export const REVIEW_DEGRADED_LABEL = "review-degraded";
|
|
@@ -76,6 +94,24 @@ export const CI_NOT_STARTED_GRACE_MS = 10 * 60 * 1000;
|
|
|
76
94
|
/** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
|
|
77
95
|
export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
|
|
78
96
|
|
|
97
|
+
/**
|
|
98
|
+
* #4385: siblings that declare a shared file (a barrel, union type, shared fixture, index doc) would conflict after the
|
|
99
|
+
* first merges; each later sub-issue gets an edge to every earlier one it shares a file with and does not already reach
|
|
100
|
+
* through its dependsOn. Mutates dependsOn; returns the added edges [{from, to, files}]. No `files`: no edge.
|
|
101
|
+
*/
|
|
102
|
+
export function serializeShared(subs) {
|
|
103
|
+
const added = [], reach = [];
|
|
104
|
+
subs.forEach((s, j) => {
|
|
105
|
+
for (let i = j - 1; i >= 0; i--) { // nearest first: a chain, not a fan-in
|
|
106
|
+
const shared = (s.files ?? []).filter((f) => (subs[i].files ?? []).includes(f));
|
|
107
|
+
const r = new Set(s.dependsOn.flatMap((k) => [k, ...reach[k]]));
|
|
108
|
+
if (shared.length && !r.has(i)) { s.dependsOn.push(i); added.push({ from: j, to: i, files: shared }); }
|
|
109
|
+
}
|
|
110
|
+
reach[j] = [...new Set(s.dependsOn.flatMap((k) => [k, ...reach[k]]))];
|
|
111
|
+
});
|
|
112
|
+
return added;
|
|
113
|
+
}
|
|
114
|
+
|
|
79
115
|
/**
|
|
80
116
|
* Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
|
|
81
117
|
* | {kind:"human", why} | {kind:"untyped", problem}. Only `human` with an allowed reason may end as needs-human.
|
|
@@ -91,8 +127,12 @@ export function classifyBlock(result, issue) {
|
|
|
91
127
|
const subs = (Array.isArray(b.subissues) ? b.subissues : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string");
|
|
92
128
|
if (!subs.length) return { kind: "untyped", problem: "the split-required block lists no sub-issues with a title and body" };
|
|
93
129
|
if (subs.length > MAX_SUBISSUES) return { kind: "untyped", problem: `the split-required block lists ${subs.length} sub-issues (at most ${MAX_SUBISSUES})` };
|
|
94
|
-
|
|
95
|
-
|
|
130
|
+
const gate = splitGate({ ...b, subissues: subs }); // #4396: disjoint parts or over the size budget; else one lane
|
|
131
|
+
if (!gate.ok) return { kind: "untyped", problem: gate.problem };
|
|
132
|
+
const subissues = subs.map((x, i) => ({ title: x.title.trim(), body: x.body,
|
|
133
|
+
dependsOn: [...new Set((Array.isArray(x.depends_on) ? x.depends_on : []).filter((j) => Number.isInteger(j) && j >= 0 && j < i))],
|
|
134
|
+
...(Array.isArray(x.files) ? { files: [...new Set(x.files.filter((f) => typeof f === "string").map((f) => f.trim().replace(/^\.?\/+/, "")).filter(Boolean))] } : {}) }));
|
|
135
|
+
return { kind: "split", rationale: gate.rationale, subissues, serialized: serializeShared(subissues) };
|
|
96
136
|
}
|
|
97
137
|
if (b.kind === "human") {
|
|
98
138
|
return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why }
|
|
@@ -122,10 +162,14 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
122
162
|
* @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
|
|
123
163
|
* @param {string} [o.builderModel] default: CLI default
|
|
124
164
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
165
|
+
* @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
|
|
166
|
+
* @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
|
|
167
|
+
* @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
|
|
125
168
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
126
169
|
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
127
170
|
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
128
171
|
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
|
|
172
|
+
* @param {object} [o.verifier] forge.yaml review.verifier/verifier_model (#4397): {mode: off (default)|shadow|enforce, model}
|
|
129
173
|
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
130
174
|
* @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
|
|
131
175
|
* maxMinutes: lean.precheck.max_minutes, the round budget (#4331, else verify.precheck_total_seconds)
|
|
@@ -135,22 +179,30 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
135
179
|
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
136
180
|
* @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
|
|
137
181
|
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
182
|
+
* @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
|
|
138
183
|
* @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)
|
|
139
184
|
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
185
|
+
* @param {object} [o.explore] {mode: on|off, model}: forge.yaml lean.explore / lean.explore_model (#4391, default off): the explorer subagent
|
|
140
186
|
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
141
187
|
* @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
142
188
|
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
|
|
143
189
|
* (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
|
|
144
190
|
*/
|
|
145
191
|
export async function runCore(o) {
|
|
146
|
-
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
147
|
-
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
192
|
+
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
193
|
+
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;
|
|
148
194
|
const now = io.now ?? (() => Date.now());
|
|
149
195
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
196
|
+
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
150
197
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
151
198
|
const remoteVerify = vcfg.mode !== "local";
|
|
152
199
|
const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
|
|
153
200
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
201
|
+
const verifierCfg = parseVerifierConfig(o.verifier ?? {});
|
|
202
|
+
const routingCfg = parseBuilderRouting(o.builderRouting ?? {});
|
|
203
|
+
// #4394: the build's model tier, routed from the frozen contract; a failed validate round on the mid tier escalates once
|
|
204
|
+
let route = null, escalated = false;
|
|
205
|
+
const laneModel = () => (route?.tier === "mid" && !escalated ? route.model : builderModel);
|
|
154
206
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
155
207
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
156
208
|
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
|
|
@@ -159,9 +211,10 @@ export async function runCore(o) {
|
|
|
159
211
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
160
212
|
const t0 = now();
|
|
161
213
|
const usage = { cost_usd: 0, input: 0, output: 0, cache: 0 };
|
|
214
|
+
const sessionCosts = new Map(); // #4407: session id -> last reported cumulative cost
|
|
162
215
|
let sessionId = null;
|
|
163
216
|
|
|
164
|
-
const ev = (e) => appendEvent(dir, issue, e);
|
|
217
|
+
const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
|
|
165
218
|
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
166
219
|
const ghc = (args) => io.gh(args);
|
|
167
220
|
const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -169,12 +222,21 @@ export async function runCore(o) {
|
|
|
169
222
|
const ghw = (args) => retrying(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
|
|
170
223
|
const git = (args) => io.git(args, { cwd: wt });
|
|
171
224
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
225
|
+
const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
|
|
226
|
+
const impl = { cost: 0, models: new Set() }; // #4397: implement spend and reported models, for the verifier
|
|
227
|
+
// #4392: implement turn budget for the lane's size, and its turn tally; off unless given (the CLI passes readCoreLimits' defaults)
|
|
228
|
+
const tb = makeTurnBudget(turnBudgets ?? { S: 0, M: 0, L: 0 });
|
|
229
|
+
/** `trigger` (#4362): why the attempt ran, one of fixround.mjs TRIGGERS; `resume` (#4407): the session it resumed. */
|
|
230
|
+
async function attempt(phase, n, r, mode = null, trigger = null, restartOf = null, resume = null) {
|
|
231
|
+
const c = sessionCostDelta(sessionCosts, { resume, sessionId: r?.sessionId ?? null, cost: num(r?.costUsd) });
|
|
232
|
+
const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, trigger, ...(restartOf ? { restart_of: restartOf } : {}), usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
|
|
233
|
+
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 } : {}),
|
|
234
|
+
...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
|
|
235
|
+
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"); }
|
|
175
236
|
ev(rec); await emit(rec);
|
|
237
|
+
modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
|
|
176
238
|
const u = r?.usage ?? {};
|
|
177
|
-
usage.cost_usd +=
|
|
239
|
+
usage.cost_usd += c.delta ?? 0;
|
|
178
240
|
usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
|
|
179
241
|
usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
180
242
|
}
|
|
@@ -214,19 +276,33 @@ export async function runCore(o) {
|
|
|
214
276
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
215
277
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
216
278
|
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
217
|
-
const
|
|
279
|
+
const ctxBase = parseContextPackConfig(o.contextPack ?? {});
|
|
280
|
+
const ctxArm = contextArm({ mode: ctxBase.mode, repo, issue }); // #4444: ab picks a stable on|shadow arm per issue
|
|
281
|
+
const ctxCfg = { ...ctxBase, mode: ctxArm.mode };
|
|
282
|
+
const ctxAb = ctxArm.ab ? { context_ab: true } : {};
|
|
283
|
+
if (ctxArm.ab) T.decisions.push(`Context pack A/B (#4444): arm ${ctxArm.mode}`);
|
|
284
|
+
const exploreCfg = parseExploreConfig(o.explore ?? {}); // #4391
|
|
285
|
+
if (exploreCfg.mode === "on") T.decisions.push(`Explorer subagent (on): model ${exploreCfg.model}`);
|
|
218
286
|
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
287
|
+
let pitMatched = null; // #4375: pitfalls in the pack that match the issue's files; null when no pack was compiled
|
|
288
|
+
let factsSeen = []; // #4401: trusted comment bodies on the issue, so an identical FORGE:FACTS is never posted twice
|
|
219
289
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
290
|
+
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
220
291
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
292
|
+
let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (skips structure/hotspot gates); summed session time
|
|
293
|
+
let lightVerdict = null, lightUpgrade = null; // #4453: the eligibility verdict and the upgrade reason codes, on the run result for the bench
|
|
221
294
|
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
222
295
|
const runId = `${issue}-${t0}`;
|
|
223
296
|
let runTmp = null;
|
|
224
297
|
let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
|
|
298
|
+
// #4395: lane profile. full (FORGEDOCK_FULL_SESSION=1): unchanged sessions; memory-off: no auto-memory; lane: also no CLAUDE.md, the lane file appended
|
|
299
|
+
let laneProfile = { name: process.env.FORGEDOCK_FULL_SESSION === "1" ? "full" : "memory-off", text: null };
|
|
225
300
|
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
|
|
226
301
|
// #4169: heavy steps take a host semaphore slot (io.heavy); LLM phases are not gated. `heavyEnv` puts the heavy shims on
|
|
227
302
|
// the runner PATH; `waits` ({phase: ms}) and `heavyQueueMax` ride on the lane result for the batch report.
|
|
228
303
|
const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
|
|
229
304
|
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
305
|
+
let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
|
|
230
306
|
const waits = {};
|
|
231
307
|
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
232
308
|
const addWait = (phase, ms, queued = 0) => {
|
|
@@ -321,15 +397,19 @@ export async function runCore(o) {
|
|
|
321
397
|
return finishing;
|
|
322
398
|
}
|
|
323
399
|
async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
|
|
400
|
+
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
401
|
+
seqLease = null;
|
|
324
402
|
if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
|
|
325
403
|
try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
|
|
326
404
|
}
|
|
405
|
+
await postLaneFacts(); // #4401: before teardown removes the worktree's result.json
|
|
327
406
|
await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
|
|
328
407
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
329
408
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
330
409
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
331
|
-
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
332
|
-
context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {})
|
|
410
|
+
conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
411
|
+
context_pack: ctxCfg.mode, ...ctxAb, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
412
|
+
...(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
|
|
333
413
|
ev(term);
|
|
334
414
|
await emit(term);
|
|
335
415
|
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
@@ -347,16 +427,33 @@ export async function runCore(o) {
|
|
|
347
427
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
348
428
|
}
|
|
349
429
|
if (["merged", "invalid", "needs-human"].includes(reason)) {
|
|
350
|
-
if (reason === "merged") T.close = ["issue closed (completed)", "label workflow:merged"];
|
|
430
|
+
if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
|
|
351
431
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
352
432
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
353
433
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
354
434
|
}
|
|
355
435
|
await releaseLease(reason, code);
|
|
356
|
-
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
|
|
357
|
-
...(
|
|
436
|
+
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, model_ms: modelMs,
|
|
437
|
+
light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
438
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
439
|
+
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
440
|
+
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
441
|
+
...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced) } } : {}), ...extra };
|
|
358
442
|
return done;
|
|
359
443
|
}
|
|
444
|
+
/** #4401: post the lane's FORGE:FACTS (result.json facts and validation, changed files, contract decisions); never fails a run. */
|
|
445
|
+
async function postLaneFacts() {
|
|
446
|
+
if (!base) return;
|
|
447
|
+
try {
|
|
448
|
+
const result = await readResult();
|
|
449
|
+
const rec = result ? normaliseFacts(result, { files: await stageFiles(), contract: await readContract() }) : null;
|
|
450
|
+
if (!rec) return;
|
|
451
|
+
const body = renderFactsRecord(rec);
|
|
452
|
+
if (factsSeen.some((b) => String(b ?? "").includes(body))) return;
|
|
453
|
+
await post(FACTS_MARKER, body);
|
|
454
|
+
T.decisions.push(`FORGE:FACTS: ${rec.rejected.length} rejected approach(es), ${rec.hotspots.length} hot spot(s) on ${rec.files.length} file(s)`);
|
|
455
|
+
} catch { /* best effort */ }
|
|
456
|
+
}
|
|
360
457
|
/** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
|
|
361
458
|
const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
|
|
362
459
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
@@ -454,7 +551,9 @@ export async function runCore(o) {
|
|
|
454
551
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
455
552
|
// #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
|
|
456
553
|
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()) } : {};
|
|
457
|
-
|
|
554
|
+
const lp = laneProfile.name, profEnv = lp === "full" ? {} : { CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", ...(lp === "lane" ? { CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1" } : {}) };
|
|
555
|
+
const system = lp === "lane" && !args.resume && !args.noTools ? [args.system, laneProfile.text].filter(Boolean).join("\n\n") : args.system;
|
|
556
|
+
try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
|
|
458
557
|
catch (e) {
|
|
459
558
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
460
559
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -525,33 +624,68 @@ export async function runCore(o) {
|
|
|
525
624
|
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
526
625
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
527
626
|
let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
627
|
+
let resumeNote = ""; // #4400: the resume digest of the last run (resume.mjs), "" on a first run or with context_pack off
|
|
628
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
|
|
629
|
+
/** #4390: builder calls stopped at the context ceiling restart fresh; planning (and its corrections) run uncapped. */
|
|
630
|
+
const UNCEILED = ["plan", "contract_correction", "block_correction"], CONTEXT_RESTARTS_MAX = 3;
|
|
631
|
+
async function builder(n, prompt, resume, live = false, restart = implementRestart, opts = {}) {
|
|
632
|
+
let timeoutRetries = 0, contextRestarts = 0, p = prompt;
|
|
633
|
+
opts = { ...opts };
|
|
634
|
+
const ceiled = contextCeilingTokens > 0 && !UNCEILED.includes(opts.trigger);
|
|
531
635
|
for (;;) {
|
|
532
|
-
|
|
636
|
+
// after CONTEXT_RESTARTS_MAX restarts the call runs uncapped: a loop guard, never a refusal of work
|
|
637
|
+
opts.contextMax = ceiled && contextRestarts < CONTEXT_RESTARTS_MAX ? contextCeilingTokens : 0;
|
|
638
|
+
const budgeted = stage === "implement" && tb.on(); // #4392: the build and its restarts spend the implement turn budget
|
|
639
|
+
if (budgeted && tb.remaining() <= 0) return await budgetWrapup(n, opts);
|
|
640
|
+
opts.turnMax = budgeted ? tb.remaining() : 0;
|
|
641
|
+
try { return await builderOnce(n, p, resume, live, opts); } catch (e) {
|
|
642
|
+
if (e?.code === "CLI_TURNS") {
|
|
643
|
+
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);
|
|
644
|
+
return await budgetWrapup(n, opts);
|
|
645
|
+
}
|
|
646
|
+
if (e?.code === "CLI_CONTEXT") {
|
|
647
|
+
contextRestarts++;
|
|
648
|
+
ev({ event: "CONTEXT_CEILING", context_tokens: e.context ?? null, ceiling: contextCeilingTokens, turns: e.turns ?? null, trigger: opts.trigger ?? null, restart: contextRestarts });
|
|
649
|
+
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);
|
|
650
|
+
await checkpoint("context ceiling");
|
|
651
|
+
T.decisions.push(`Builder session reached ${e.context ?? "?"} context tokens (ceiling ${contextCeilingTokens}): checkpointed, restarted fresh from the contract, diff and last validate result`);
|
|
652
|
+
p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
533
655
|
if (!(e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
|
|
534
656
|
const idle = e.code === "CLI_IDLE";
|
|
535
657
|
const reason = idle ? "idle timeout" : "timeout";
|
|
536
|
-
ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? []
|
|
658
|
+
ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
|
|
659
|
+
...(idle ? { heavyQueue: e.heavyQueue ?? null, extended: e.extended === true } : {}) });
|
|
537
660
|
await checkpoint(reason);
|
|
538
661
|
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; }
|
|
539
662
|
T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
|
|
540
663
|
T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
|
|
541
|
-
p = restart(); resume = null;
|
|
664
|
+
p = restart(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "timeout_restart";
|
|
542
665
|
}
|
|
543
666
|
}
|
|
544
667
|
}
|
|
545
668
|
|
|
546
|
-
|
|
669
|
+
/** #4405: a silent builder is busy (idle window extended) while a child is a heavy step or its heavy-queue entry is queued/running. */
|
|
670
|
+
function sessionBusy(children) {
|
|
671
|
+
const work = children.filter((c) => isHeavyProcess(c.cmd));
|
|
672
|
+
let heavyQueue = null;
|
|
673
|
+
try { heavyQueue = io.heavy?.lane?.(children.map((c) => c.pid)) ?? null; } catch { heavyQueue = null; }
|
|
674
|
+
return work.length || heavyQueue?.queued > 0 || heavyQueue?.running > 0 ? { children: work, heavyQueue } : null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** opts (#4363): {model, mode, keepSession, trigger and restartOf (#4362)}; a fresh fix session keeps the build session id for a fallback resume. */
|
|
678
|
+
async function builderOnce(n, prompt, resume, live = false, { model = null, mode = null, keepSession = false, trigger = null, restartOf = null, contextMax = 0, turnMax = 0 } = {}) {
|
|
547
679
|
onProgress({ event: "phase_enter", phase: "build" });
|
|
548
680
|
await renew();
|
|
549
681
|
const control = {};
|
|
550
682
|
const governor = startGovernor(control);
|
|
551
683
|
let spawned = null;
|
|
552
684
|
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 })); } };
|
|
553
|
-
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model:
|
|
554
|
-
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }))
|
|
685
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg), rereadNudge: true,
|
|
686
|
+
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}),
|
|
687
|
+
busy: sessionBusy, busyMs: Math.round(cliBusyMinutes * 60000),
|
|
688
|
+
onIdleExtend: (info) => ev({ event: "CLI_IDLE_EXTENDED", children: info.children, heavyQueue: info.heavyQueue }) }));
|
|
555
689
|
call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
|
|
556
690
|
let r;
|
|
557
691
|
call.catch(() => governor.stop());
|
|
@@ -570,11 +704,45 @@ export async function runCore(o) {
|
|
|
570
704
|
await governor.stop();
|
|
571
705
|
await renew();
|
|
572
706
|
await registerReported();
|
|
573
|
-
if (r?.sessionId) sessionId = r.sessionId;
|
|
574
|
-
await attempt("implement", n, r);
|
|
707
|
+
if (r?.sessionId && !keepSession) sessionId = r.sessionId;
|
|
708
|
+
await attempt("implement", n, r, mode, trigger, restartOf, resume);
|
|
575
709
|
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
576
710
|
return r;
|
|
577
711
|
}
|
|
712
|
+
/** #4390: the restart summary: `git diff --stat` and hunks against the lane base, and the last `forgedock check` record. */
|
|
713
|
+
async function restartFacts() {
|
|
714
|
+
const ex = [".", `:!${LEAN_DIR}`];
|
|
715
|
+
const stat = String(await git(["diff", "--stat", `${base}...HEAD`, "--", ...ex]).catch(() => "")), diff = String(await git(["diff", `${base}...HEAD`, "--", ...ex]).catch(() => ""));
|
|
716
|
+
let check = "";
|
|
717
|
+
try { check = String(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)); } catch { /* none recorded */ }
|
|
718
|
+
return { stat, diff, base, check };
|
|
719
|
+
}
|
|
720
|
+
const contextRestartNote = async () => contextRestartPrompt({ ...await restartFacts(), ceiling: contextCeilingTokens });
|
|
721
|
+
/**
|
|
722
|
+
* #4392: the implement turn budget is spent. Checkpoint, then one fresh session of `wrapupGrant` turns either finishes
|
|
723
|
+
* (its result flows on as usual) or stops with a digest; a stop or an overrun ends needs-human `budget.exceeded`.
|
|
724
|
+
*/
|
|
725
|
+
async function budgetWrapup(n, opts) {
|
|
726
|
+
const size = tb.size, budget = tb.budget(), used = tb.record().build, grant = wrapupGrant(budget);
|
|
727
|
+
tb.markExceeded();
|
|
728
|
+
ev({ event: "TURN_BUDGET_EXCEEDED", size, budget, turns: used, grant });
|
|
729
|
+
await checkpoint("turn budget");
|
|
730
|
+
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`);
|
|
731
|
+
const facts = await restartFacts();
|
|
732
|
+
const wopts = { ...opts, trigger: "budget_wrapup", restartOf: opts.restartOf ?? opts.trigger ?? null, contextMax: 0, turnMax: grant };
|
|
733
|
+
let r = null, overran = false;
|
|
734
|
+
try { r = await builderOnce(n, implementRestart() + turnBudgetWrapupPrompt({ size, budget, used, grant, ...facts }), null, false, wopts); } catch (e) {
|
|
735
|
+
if (e?.code !== "CLI_TURNS") throw e;
|
|
736
|
+
overran = true;
|
|
737
|
+
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);
|
|
738
|
+
await checkpoint("turn budget wrap-up");
|
|
739
|
+
}
|
|
740
|
+
const res = await readResult();
|
|
741
|
+
if (!overran && res?.status !== "budget-exceeded") return r;
|
|
742
|
+
ev({ event: "TURN_BUDGET_STOP", size, budget, turns: tb.record().build, overran });
|
|
743
|
+
const digest = budgetDigest({ size, budget, used: tb.record().build, digest: res?.status === "budget-exceeded" ? res.digest : null, stat: facts.stat, check: facts.check });
|
|
744
|
+
throw Object.assign(new Error(digest), { laneCode: OUTCOME_CODES.BUDGET_EXCEEDED });
|
|
745
|
+
}
|
|
578
746
|
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; } };
|
|
579
747
|
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; } };
|
|
580
748
|
/** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
|
|
@@ -649,9 +817,11 @@ export async function runCore(o) {
|
|
|
649
817
|
filed.push(n);
|
|
650
818
|
}
|
|
651
819
|
} catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
|
|
652
|
-
|
|
820
|
+
const why = rationaleLine(b.rationale);
|
|
821
|
+
T.decisions.push(`Builder block: split into ${refs(filed)} (${why})`);
|
|
822
|
+
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)`);
|
|
653
823
|
let marked = false;
|
|
654
|
-
try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); marked = true; } catch { /* best effort */ }
|
|
824
|
+
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 */ }
|
|
655
825
|
// #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
|
|
656
826
|
if (await label("decomposed") && marked && closeParent) {
|
|
657
827
|
try {
|
|
@@ -660,7 +830,7 @@ export async function runCore(o) {
|
|
|
660
830
|
T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
|
|
661
831
|
} catch { /* best effort: the parent stays open, as before #4337 */ }
|
|
662
832
|
}
|
|
663
|
-
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
|
|
833
|
+
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
|
|
664
834
|
}
|
|
665
835
|
return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
|
|
666
836
|
}
|
|
@@ -709,7 +879,7 @@ export async function runCore(o) {
|
|
|
709
879
|
}
|
|
710
880
|
|
|
711
881
|
if (io.ledger) {
|
|
712
|
-
try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, host: myHost }); } catch { ledger = null; }
|
|
882
|
+
try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, repo, host: myHost }); } catch { ledger = null; }
|
|
713
883
|
}
|
|
714
884
|
// A signal (engine-cli SIGTERM/SIGINT) runs the same teardown and lease release as any other exit.
|
|
715
885
|
o.onTeardown?.((signal) => finish("deferred", `interrupted by ${signal}`, { code: OUTCOME_CODES.RUN_INTERRUPTED }, OUTCOME_CODES.RUN_INTERRUPTED));
|
|
@@ -724,6 +894,7 @@ export async function runCore(o) {
|
|
|
724
894
|
try {
|
|
725
895
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
726
896
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
897
|
+
const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
|
|
727
898
|
if (String(iss.state).toUpperCase() === "CLOSED") {
|
|
728
899
|
// #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
|
|
729
900
|
// them). A trusted revocation reopens it and the run continues. An unreadable trail lists none (fail closed).
|
|
@@ -827,15 +998,19 @@ export async function runCore(o) {
|
|
|
827
998
|
}
|
|
828
999
|
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
829
1000
|
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
}
|
|
1001
|
+
// ---- 2a. lane profile (#4395): the repo's lane instruction file replaces CLAUDE.md; a bad one falls back with an anomaly ----
|
|
1002
|
+
const lanePath = typeof o.laneInstructions === "string" ? o.laneInstructions.trim() : "";
|
|
1003
|
+
if (laneProfile.name !== "full" && lanePath) {
|
|
1004
|
+
let why = null, text = "";
|
|
1005
|
+
if (lanePath.startsWith("/") || lanePath.split(/[\\/]/).includes("..")) why = "not a repo-relative path";
|
|
1006
|
+
else { try { text = String(await io.fs.read(`${wt}/${lanePath}`) ?? "").trim(); } catch { why = "not readable in the lane worktree"; } }
|
|
1007
|
+
if (!why && !text) why = "empty";
|
|
1008
|
+
if (!why && Buffer.byteLength(text, "utf8") > LANE_INSTRUCTIONS_MAX_BYTES) why = `over ${LANE_INSTRUCTIONS_MAX_BYTES} bytes`;
|
|
1009
|
+
if (why) T.anomalies.push(`forge.yaml engine.lane_instructions \`${lanePath.slice(0, 200)}\` ${why}; lanes keep CLAUDE.md (auto-memory off)`);
|
|
1010
|
+
else laneProfile = { name: "lane", text };
|
|
838
1011
|
}
|
|
1012
|
+
T.decisions.push(laneProfile.name === "lane" ? `Lane profile: ${lanePath} (${Buffer.byteLength(laneProfile.text, "utf8")} bytes) instead of CLAUDE.md, auto-memory off`
|
|
1013
|
+
: laneProfile.name === "full" ? "Lane profile: full session (FORGEDOCK_FULL_SESSION=1)" : "Lane profile: auto-memory off, CLAUDE.md loaded");
|
|
839
1014
|
// ---- 2b. toolchain pins + env.setup (#4163): before the builder; zero LLM. No pins and no setup changes nothing ----
|
|
840
1015
|
try {
|
|
841
1016
|
const tc = await (io.prepareToolchain ?? prepareToolchain)({ dir: wt, override: toolchainOverride, read: io.fs.read,
|
|
@@ -848,6 +1023,16 @@ export async function runCore(o) {
|
|
|
848
1023
|
if (tc?.warning) { T.anomalies.push(tc.warning); ev({ event: "TOOLCHAIN_WARNING", warning: tc.warning }); }
|
|
849
1024
|
for (const n of tc?.notes ?? []) T.anomalies.push(n);
|
|
850
1025
|
} catch (e) { T.anomalies.push(`Toolchain detection failed (host toolchain used): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1026
|
+
// #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
|
|
1027
|
+
// After 2b (#4347): installs run with the toolchain env, so a pm only its shim provides resolves; no env is the host PATH.
|
|
1028
|
+
if (io.deps?.prepare) {
|
|
1029
|
+
let res;
|
|
1030
|
+
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) }]; }
|
|
1031
|
+
for (const r of Array.isArray(res) ? res : []) {
|
|
1032
|
+
ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
|
|
1033
|
+
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` : ""}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
851
1036
|
// ---- 2c. heavy-step shims (#4169): after the toolchain, so a heavy shim's real binary is the pinned toolchain shim ----
|
|
852
1037
|
if (heavyOn) {
|
|
853
1038
|
try {
|
|
@@ -883,6 +1068,10 @@ export async function runCore(o) {
|
|
|
883
1068
|
const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
884
1069
|
return touched.filter((f) => differs.has(f));
|
|
885
1070
|
};
|
|
1071
|
+
/** #4364: the last `forgedock check` record vs the current tree: skipped|stale|red|green; any read or git error is skipped. */
|
|
1072
|
+
const selfCheckNow = async () => {
|
|
1073
|
+
try { return selfCheckState(JSON.parse(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)), await worktreeFingerprint({ git, base })); } catch { return "skipped"; }
|
|
1074
|
+
};
|
|
886
1075
|
/** forge#4195: scripts/hotspots.sh facts in the worktree, or null (no io.hotspots, script failure): never fails a run. */
|
|
887
1076
|
const hotspotsOf = async (args) => {
|
|
888
1077
|
if (!io.hotspots) return null;
|
|
@@ -912,14 +1101,12 @@ export async function runCore(o) {
|
|
|
912
1101
|
const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
|
|
913
1102
|
const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
|
|
914
1103
|
// Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
|
|
915
|
-
const trustedOf = (i) => (i.comments ?? []).filter(
|
|
916
|
-
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
917
|
-
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
918
|
-
});
|
|
1104
|
+
const trustedOf = (i) => (i.comments ?? []).filter(isTrustedIssueComment); // #4467: gh shape, the App has no [bot]
|
|
919
1105
|
// #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
|
|
920
1106
|
const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
|
|
921
1107
|
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
|
|
922
1108
|
let trustedComments = trustedOf(iss), issueEdited = false;
|
|
1109
|
+
factsSeen = trustedComments.map((c) => c?.body);
|
|
923
1110
|
let issueText = textOf(iss, trustedComments);
|
|
924
1111
|
let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
|
|
925
1112
|
ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
|
|
@@ -928,23 +1115,36 @@ export async function runCore(o) {
|
|
|
928
1115
|
* io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
|
|
929
1116
|
*/
|
|
930
1117
|
async function contextPackStep() {
|
|
1118
|
+
const pits = await lanePitfalls();
|
|
931
1119
|
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
932
1120
|
try {
|
|
933
1121
|
const headSha = String(await git(["rev-parse", base])).trim();
|
|
934
1122
|
// #4316: linked-issue states are part of the key; a failed lookup only drops them from it
|
|
935
1123
|
const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
|
|
936
|
-
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
|
|
937
|
-
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
1124
|
+
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) },
|
|
1125
|
+
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, checkout: cwd, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now(), pitfalls: pits }) });
|
|
938
1126
|
const p = r.pack;
|
|
939
|
-
const
|
|
1127
|
+
const inPack = Array.isArray(p.pitfalls) ? p.pitfalls : [];
|
|
1128
|
+
pitMatched = inPack.filter((x) => x.match === "files").length;
|
|
1129
|
+
// #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
|
|
1130
|
+
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, ...ctxAb, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
940
1131
|
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
941
|
-
ev(rec); await emit(rec);
|
|
1132
|
+
ev({ ...rec, pitfalls: inPack }); await emit(rec);
|
|
942
1133
|
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
943
1134
|
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
944
1135
|
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"}`);
|
|
945
1136
|
return p;
|
|
946
1137
|
} catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
|
|
947
1138
|
}
|
|
1139
|
+
/** #4375: tick the repo's lane clock (pitfall aging) and select its pitfalls for this issue; [] without io.pitfalls or on any error. */
|
|
1140
|
+
async function lanePitfalls() {
|
|
1141
|
+
if (!io.pitfalls) return [];
|
|
1142
|
+
try {
|
|
1143
|
+
await io.pitfalls.append(repo, { t: "lane", run: runId, ts: new Date(now()).toISOString() });
|
|
1144
|
+
if (!ctxCfg.sources.includes("pitfalls")) return [];
|
|
1145
|
+
return selectPitfalls(foldPitfalls(await io.pitfalls.read(repo), { maxAge: ctxCfg.pitfallLanes }), { paths: namedPaths(iss.body ?? "") });
|
|
1146
|
+
} catch { return []; }
|
|
1147
|
+
}
|
|
948
1148
|
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
949
1149
|
async function refreshIssue(stage) {
|
|
950
1150
|
let next;
|
|
@@ -954,7 +1154,7 @@ export async function runCore(o) {
|
|
|
954
1154
|
if (contractHash(issueText) === contractHash(text)) return;
|
|
955
1155
|
// #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
|
|
956
1156
|
const old = scopeHash, now = contractHash(scopeText(next, tc));
|
|
957
|
-
iss = next; trustedComments = tc; issueText = text; scopeHash = now;
|
|
1157
|
+
iss = next; trustedComments = tc; factsSeen = tc.map((c) => c?.body); issueText = text; scopeHash = now;
|
|
958
1158
|
if (old === now) return;
|
|
959
1159
|
issueEdited = true;
|
|
960
1160
|
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
@@ -965,7 +1165,7 @@ export async function runCore(o) {
|
|
|
965
1165
|
let block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
966
1166
|
if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
|
|
967
1167
|
T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
|
|
968
|
-
await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId);
|
|
1168
|
+
await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId, false, undefined, { trigger: "block_correction" });
|
|
969
1169
|
result = await readResult();
|
|
970
1170
|
block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
971
1171
|
}
|
|
@@ -988,20 +1188,37 @@ export async function runCore(o) {
|
|
|
988
1188
|
}
|
|
989
1189
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
990
1190
|
}
|
|
991
|
-
/**
|
|
1191
|
+
/**
|
|
1192
|
+
* #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
|
|
1193
|
+
* env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
|
|
1194
|
+
*/
|
|
992
1195
|
async function preflightValidates(contract) {
|
|
993
|
-
if (!io.probeCommand || !contract || typeof contract !== "object") return [];
|
|
1196
|
+
if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
|
|
994
1197
|
const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
995
|
-
const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
|
|
996
|
-
if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
|
|
997
1198
|
const env = laneEnv(), missing = new Set(), misses = []; // #4325: the env the validate commands run with
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1199
|
+
if (io.probeCommand) {
|
|
1200
|
+
const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
|
|
1201
|
+
if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
|
|
1202
|
+
for (const p of probes) {
|
|
1203
|
+
if (p.needs && missing.has(p.needs)) continue;
|
|
1204
|
+
let r;
|
|
1205
|
+
try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
|
|
1206
|
+
if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
|
|
1207
|
+
if (p.kind === "command") missing.add(p.tool);
|
|
1208
|
+
misses.push({ ...p, source: "probe" });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const fullEnv = { ...process.env, ...env };
|
|
1212
|
+
for (const v of Array.isArray(contract.validate) ? contract.validate : []) {
|
|
1213
|
+
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" });
|
|
1214
|
+
}
|
|
1215
|
+
if (io.preflightRun) {
|
|
1216
|
+
const skipIds = [...new Set(misses.flatMap((m) => m.ids))];
|
|
1217
|
+
const run = (a) => heavyStep("precheck", () => io.preflightRun({ ...a, cwd: wt }));
|
|
1218
|
+
const r = await runPreflight({ contract, run, git, env, skipIds, now, ...knownToContract(contract, files) });
|
|
1219
|
+
misses.push(...r.misses);
|
|
1220
|
+
if (r.skipped.length) T.anomalies.push(`Validate preflight run budget spent; ${r.skipped.join(", ")} not run on base`);
|
|
1221
|
+
if (r.reverted.length) T.anomalies.push(`Validate preflight reverted ${r.reverted.length} path(s) its runs changed: ${r.reverted.slice(0, 5).join(", ")}`);
|
|
1005
1222
|
}
|
|
1006
1223
|
return misses;
|
|
1007
1224
|
}
|
|
@@ -1055,7 +1272,7 @@ export async function runCore(o) {
|
|
|
1055
1272
|
await renew();
|
|
1056
1273
|
const rr = await callRunner({ role: "grouper", issue, cwd: cwd ?? runTmp ?? wt, model: reviewerModel, resume: null, system: GROUPING_SYSTEM, prompt, noTools: true });
|
|
1057
1274
|
await renew();
|
|
1058
|
-
await attempt("group", 1, rr);
|
|
1275
|
+
await attempt("group", 1, rr, null, "plan");
|
|
1059
1276
|
return rr?.text ?? "";
|
|
1060
1277
|
} finally { slot = await heavyAcquire("split"); }
|
|
1061
1278
|
} });
|
|
@@ -1079,6 +1296,45 @@ export async function runCore(o) {
|
|
|
1079
1296
|
return { ...r, contract };
|
|
1080
1297
|
}
|
|
1081
1298
|
let frozen = null, hash = null, planned = false, files = [], split = null;
|
|
1299
|
+
const pendingPits = []; // #4375: rework rounds whose gate has not passed again yet
|
|
1300
|
+
async function notePitfall(trigger, text) {
|
|
1301
|
+
const pit = io.pitfalls ? pendingPitfall(trigger, text) : null;
|
|
1302
|
+
if (pit) { try { pit.before = String(await git(["rev-parse", "HEAD"])).trim(); pendingPits.push(pit); } catch { /* best effort */ } }
|
|
1303
|
+
}
|
|
1304
|
+
/** #4375: the gate of `triggers` (all when none) passed: record its pending rework rounds as pitfalls. Never fails a run. */
|
|
1305
|
+
async function gatePassed(...triggers) {
|
|
1306
|
+
for (const pit of pendingPits.filter((x) => !triggers.length || triggers.includes(x.trigger))) {
|
|
1307
|
+
pendingPits.splice(pendingPits.indexOf(pit), 1);
|
|
1308
|
+
try {
|
|
1309
|
+
const changed = String(await git(["diff", "--name-only", `${pit.before}..HEAD`])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
1310
|
+
await io.pitfalls.append(repo, pitfallRecord({ pending: pit, files: changed, run: runId, ts: new Date(now()).toISOString() }));
|
|
1311
|
+
ev({ event: "PITFALL_RECORDED", trigger: pit.trigger, sig: pit.sig });
|
|
1312
|
+
} catch { /* the ledger is a cache */ }
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
/** #4394: a failed validate round on the mid tier moves this fix round and every later builder call to the top model. */
|
|
1316
|
+
async function escalateOn(trigger) {
|
|
1317
|
+
if (!shouldEscalate(route, escalated, trigger)) return;
|
|
1318
|
+
escalated = true;
|
|
1319
|
+
const rec = { event: "BUILDER_ESCALATED", from: route.model, to: builderModel ?? "default", trigger };
|
|
1320
|
+
ev(rec); await emit(rec);
|
|
1321
|
+
T.decisions.push(`Builder escalated from ${route.model} to the top model after a failed ${trigger} round`);
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* #4363: one fix round. Mechanical failures (and a conformance/review FAIL citing file:line) run in a fresh session on
|
|
1325
|
+
* the fix model with a narrow prompt; one resume of the build session follows when it made no commit. Else: a resume.
|
|
1326
|
+
*/
|
|
1327
|
+
async function fixRound(kind, task, failures, trigger, failureText = "") {
|
|
1328
|
+
await notePitfall(trigger, failureText); await escalateOn(trigger); // #4375: learned once the same gate passes; #4394
|
|
1329
|
+
if (!freshFix(kind, failures)) return await builder(++attemptN, task, sessionId ?? null, false, undefined, { mode: "resume", trigger });
|
|
1330
|
+
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1331
|
+
const ex = [".", `:!${LEAN_DIR}`], diff = String(await git(["diff", `${base}...HEAD`, "--", ...ex]).catch(() => "")), stat = String(await git(["diff", "--stat", `${base}...HEAD`, "--", ...ex]).catch(() => ""));
|
|
1332
|
+
const prompt = fixSessionPrompt({ task, diff, stat, base, contract: frozen, title: iss?.title ?? "", summary: issueSummary(iss?.body) });
|
|
1333
|
+
const r = await builder(++attemptN, prompt, null, false, () => prompt, { model: escalated ? null : fixModel ?? reviewerModel, mode: "fresh", keepSession: true, trigger });
|
|
1334
|
+
if (!sessionId || String(await git(["rev-parse", "HEAD"])).trim() !== before) return r;
|
|
1335
|
+
T.decisions.push(`Fresh ${kind} fix session made no commit; builder session resumed once`);
|
|
1336
|
+
return await builder(++attemptN, task, sessionId, false, undefined, { mode: "resume", trigger });
|
|
1337
|
+
}
|
|
1082
1338
|
if (isSplit) {
|
|
1083
1339
|
// ---- 3'. refactor:split (#4211): deterministic move + proof replaces the builder; no builder session exists ----
|
|
1084
1340
|
split = await splitStep();
|
|
@@ -1100,14 +1356,65 @@ export async function runCore(o) {
|
|
|
1100
1356
|
} else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
1101
1357
|
}
|
|
1102
1358
|
}
|
|
1359
|
+
if ((resumed || salvagedRef || recover) && ctxCfg.mode !== "off") {
|
|
1360
|
+
// #4400: the last run's own records as a bounded digest in the builder prompts; never in issueText (contract quotes)
|
|
1361
|
+
const d = buildResumeDigest({ state: priorState, comments: trustedComments, contract: frozen, files: resumed ? await changedFiles().catch(() => []) : [], lane });
|
|
1362
|
+
resumeNote = d.text;
|
|
1363
|
+
if (d.text) {
|
|
1364
|
+
ev({ event: "RESUME_DIGEST", bytes: Buffer.byteLength(d.text, "utf8"), files: d.files, validates: d.validates, reason: d.reason });
|
|
1365
|
+
T.decisions.push(`Resume digest given to the builder: ${d.files} file(s), ${d.validates} validate result(s)${d.reason ? `, last run ${d.reason}` : ""}`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1103
1368
|
await label("building");
|
|
1104
|
-
|
|
1369
|
+
// #4450: the light lane (lean.light mode on, eligible, fresh lane): one builder session, derived contract, or an upgrade
|
|
1370
|
+
const elig = lightEligibility({ title: iss.title, body: iss.body ?? "", labels: iss.labels ?? [], predictedFiles: namedPaths(iss.body ?? ""), riskPaths, cfg: o.light ?? {} });
|
|
1371
|
+
ev({ event: "LIGHT_ELIGIBILITY", mode: elig.mode, verdict: elig.verdict, light: elig.light, reasons: elig.reasons });
|
|
1372
|
+
lightVerdict = elig.verdict;
|
|
1373
|
+
if (elig.mode !== "off") T.decisions.push(`Light lane ${elig.mode}: ${elig.verdict}${elig.reasons.length ? ` (${elig.reasons.map((x) => x.code).join(", ")})` : ""}`);
|
|
1374
|
+
// #4451: a light try skips the hotspots and the context pack; an upgrade computes them before the plan call
|
|
1375
|
+
const lightTry = elig.light && !frozen && !resumed && !salvagedRef && !recover;
|
|
1376
|
+
if (!lightTry) hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1105
1377
|
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1106
1378
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1107
|
-
|
|
1108
|
-
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1379
|
+
let ctxPack = frozen || lightTry ? null : await contextPackStep();
|
|
1380
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1381
|
+
let lightDone = false, lightCommits = false;
|
|
1382
|
+
if (lightTry) {
|
|
1383
|
+
liveKeys = ["implementation"]; stage = "implement";
|
|
1384
|
+
tb.setSize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }));
|
|
1385
|
+
const lr = await runLightStep({ title: iss.title, issueText, riskPaths, cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
|
|
1386
|
+
build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
|
|
1387
|
+
readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
|
|
1388
|
+
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1389
|
+
run: ({ command, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
|
|
1390
|
+
fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
|
|
1391
|
+
apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
|
|
1392
|
+
stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
|
|
1393
|
+
if (!lr.upgrade) {
|
|
1394
|
+
lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
|
|
1395
|
+
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1396
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
|
|
1397
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
1398
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
|
|
1399
|
+
ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
|
|
1400
|
+
T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
|
|
1401
|
+
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
|
|
1402
|
+
} else {
|
|
1403
|
+
lightCommits = lr.files.length > 0;
|
|
1404
|
+
ev({ event: "LIGHT_UPGRADE", reasons: lr.reasons, cost_usd: usage.cost_usd });
|
|
1405
|
+
lightUpgrade = lr.reasons.map((x) => x.code);
|
|
1406
|
+
T.decisions.push(`Light lane upgraded to the full lane: ${lr.reasons.map((x) => x.code).join(", ")}`);
|
|
1407
|
+
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd })); } catch { /* #4452: the receipt is best effort */ }
|
|
1408
|
+
const d = buildResumeDigest({ state: null, comments: trustedComments, contract: null, files: lr.files, lane });
|
|
1409
|
+
resumeNote = d.text + upgradeNote(lr.reasons);
|
|
1410
|
+
result = null; // the light result.json must not read as the plan's verdict
|
|
1411
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${RESULT_FILE}`, "{}\n"); } catch { /* the plan overwrites it */ } }
|
|
1412
|
+
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") }); // #4451: the full lane keeps its plan facts
|
|
1413
|
+
ctxPack = await contextPackStep();
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1109
1416
|
if (!frozen) {
|
|
1110
|
-
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
1417
|
+
await builder(++attemptN, planPrompt() + (hasCommits || lightCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
|
|
1111
1418
|
planned = true;
|
|
1112
1419
|
result = await readResult();
|
|
1113
1420
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
@@ -1124,7 +1431,23 @@ export async function runCore(o) {
|
|
|
1124
1431
|
}
|
|
1125
1432
|
const blocked = await endIfBlocked();
|
|
1126
1433
|
if (blocked) return blocked;
|
|
1127
|
-
|
|
1434
|
+
// #4353: the engine's scope gate is the only scope authority; a pure file-scope validate is dropped before it is checked or frozen
|
|
1435
|
+
const dropScope = (c) => {
|
|
1436
|
+
const d = dropScopeValidates(c);
|
|
1437
|
+
if (d.dropped.length) {
|
|
1438
|
+
ev({ event: "CONTRACT_SCOPE_VALIDATE_DROPPED", runs: d.dropped });
|
|
1439
|
+
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`;
|
|
1440
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1441
|
+
}
|
|
1442
|
+
const o = tagOutOfBand(d.contract); // #4404: a requirement measured outside the lane is judged by its instrumentation
|
|
1443
|
+
if (o.tagged.length) {
|
|
1444
|
+
ev({ event: "CONTRACT_OUT_OF_BAND_TAGGED", ids: o.tagged });
|
|
1445
|
+
const note = `Requirement(s) ${o.tagged.join(", ")} tagged verify out_of_band at contract freeze (measured by a job outside the lane)`;
|
|
1446
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1447
|
+
}
|
|
1448
|
+
return o.contract;
|
|
1449
|
+
};
|
|
1450
|
+
let contract = dropScope(await readContract());
|
|
1128
1451
|
await refreshIssue("contract validation");
|
|
1129
1452
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1130
1453
|
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
@@ -1132,14 +1455,14 @@ export async function runCore(o) {
|
|
|
1132
1455
|
if ((!vc.ok || missed.length) && sessionId) {
|
|
1133
1456
|
const problems = [...vc.problems, ...preflightProblems(missed)];
|
|
1134
1457
|
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1135
|
-
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt);
|
|
1136
|
-
contract = await readContract();
|
|
1458
|
+
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
|
|
1459
|
+
contract = dropScope(await readContract());
|
|
1137
1460
|
await refreshIssue("contract validation");
|
|
1138
1461
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1139
1462
|
if (missed.length) misses = await preflightValidates(contract);
|
|
1140
1463
|
}
|
|
1141
1464
|
if (missed.length) {
|
|
1142
|
-
ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind })), corrected: misses.length === 0 });
|
|
1465
|
+
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 });
|
|
1143
1466
|
if (misses.length) {
|
|
1144
1467
|
T.anomalies.push(`Validate preflight: the host lacks ${misses.map((m) => m.tool).join(", ")} after the contract correction (advisory; a failed rerun ends env.tool_missing)`);
|
|
1145
1468
|
T.decisions.push(`Validate preflight left ${misses.length} miss(es) after one correction; contract frozen anyway`);
|
|
@@ -1156,16 +1479,31 @@ export async function runCore(o) {
|
|
|
1156
1479
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
1157
1480
|
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
1158
1481
|
} else {
|
|
1159
|
-
T.validate = "resumed (existing branch)";
|
|
1482
|
+
T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
|
|
1160
1483
|
}
|
|
1161
|
-
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1484
|
+
T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
|
|
1485
|
+
tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
|
|
1162
1486
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1163
|
-
|
|
1487
|
+
// #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
|
|
1488
|
+
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 */ } }
|
|
1489
|
+
route = routeBuilder({ contract: frozen, riskPaths, cfg: routingCfg, topModel: builderModel }); // #4394
|
|
1490
|
+
{
|
|
1491
|
+
const rec = { event: "BUILDER_ROUTE", mode: route.mode, class: route.class, tier: route.tier, model: route.model ?? "default", risk: route.risk };
|
|
1492
|
+
ev(rec); await emit(rec);
|
|
1493
|
+
T.decisions.push(`Builder routed to the ${route.tier} tier (${route.class}; ${route.model ?? "CLI default"})`);
|
|
1494
|
+
}
|
|
1495
|
+
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);
|
|
1164
1496
|
liveKeys = ["implementation"];
|
|
1165
1497
|
stage = "implement";
|
|
1166
|
-
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
1498
|
+
if (!lightDone) await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
|
|
1167
1499
|
stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
|
|
1168
1500
|
result = await readResult();
|
|
1501
|
+
{ // #4374: the builder's self-review against the shared rubric, log only
|
|
1502
|
+
const sr = readSelfReview(result);
|
|
1503
|
+
if (sr) ev(sr.state === "present" ? { event: "SELF_REVIEW", answers: sr.items.length, no: sr.no } : { event: "SELF_REVIEW_MISSING", state: sr.state });
|
|
1504
|
+
}
|
|
1505
|
+
// #4364: a builder that writes fixed without a green `forgedock check` on its final tree; recorded, never a failure
|
|
1506
|
+
if (result?.status === "fixed") { const state = await selfCheckNow(); if (state !== "green") ev({ event: "SELF_CHECK_MISSING", state }); }
|
|
1169
1507
|
{
|
|
1170
1508
|
const blocked = await endIfBlocked();
|
|
1171
1509
|
if (blocked) return blocked;
|
|
@@ -1182,11 +1520,10 @@ export async function runCore(o) {
|
|
|
1182
1520
|
// verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
|
|
1183
1521
|
let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
|
|
1184
1522
|
// #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
|
|
1185
|
-
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1186
1523
|
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1187
1524
|
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1188
1525
|
// #4331: a learned command the map covers runs only when its globs match a changed file; unmapped ones always run (#4315)
|
|
1189
|
-
const pickChecks = () => {
|
|
1526
|
+
const pickChecks = () => planPrecheck({ testMap, files, learned: learnedCommands, maxMinutes: o.precheck?.maxMinutes, verify: vcfg });
|
|
1190
1527
|
let picked = pickChecks();
|
|
1191
1528
|
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1192
1529
|
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
@@ -1198,19 +1535,23 @@ export async function runCore(o) {
|
|
|
1198
1535
|
if (round) picked = pickChecks();
|
|
1199
1536
|
const precheckCommands = picked.commands;
|
|
1200
1537
|
// #4318/#4331: one round has a total budget (lean.precheck.max_minutes, else verify.precheck_total_seconds); the
|
|
1201
|
-
// commands run concurrently up to the host worker cap
|
|
1202
|
-
|
|
1538
|
+
// commands run concurrently up to the host worker cap; the rest are deferred to CI, not failed. #4344: one heavy
|
|
1539
|
+
// slot per running command, and the worker cap is divided among them (FORGE_HOST_PARALLELISM = floor(N / k))
|
|
1540
|
+
const totalMs = picked.budgetMs;
|
|
1203
1541
|
const parallelism = Number(verifyParallelism()) || 1;
|
|
1204
|
-
|
|
1205
|
-
|
|
1542
|
+
let workers = 1;
|
|
1543
|
+
const round$ = await runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: picked.perCommandMs, now,
|
|
1544
|
+
acquire: () => heavyAcquire("precheck"),
|
|
1545
|
+
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 }); } });
|
|
1206
1546
|
const { results, deferred } = round$;
|
|
1207
1547
|
precheckMs += round$.ms; precheckDeferred += deferred.length;
|
|
1208
1548
|
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(", ")}`);
|
|
1549
|
+
const roundFailed = results.some((r) => r.rc !== 0);
|
|
1209
1550
|
ev({ event: "PRECHECK_COMMANDS", round, results: [...results.map(({ command, rc, ms }) => ({ command, rc, ms })), ...deferred.map((command) => ({ command, status: "deferred-to-ci" }))],
|
|
1210
|
-
ms: round$.ms, parallelism, ...(picked.source !== "learned" ? { source: picked.source } : {}) });
|
|
1551
|
+
ms: round$.ms, parallelism, workers, ...(picked.source !== "learned" ? { source: picked.source } : {}), ...(roundFailed ? { self_check: await selfCheckNow() } : {}) });
|
|
1211
1552
|
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1212
1553
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1213
|
-
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
1554
|
+
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); await gatePassed("precheck"); break; }
|
|
1214
1555
|
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1215
1556
|
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);
|
|
1216
1557
|
checkCommandsFailing = true;
|
|
@@ -1218,7 +1559,14 @@ export async function runCore(o) {
|
|
|
1218
1559
|
T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1219
1560
|
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1220
1561
|
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
1221
|
-
|
|
1562
|
+
// #4390: the builder sees each failure quiet (tail, failing ids, full-log path); the pitfall text stays the capped tail
|
|
1563
|
+
const quiet = await Promise.all(failed.map(async (f, i) => {
|
|
1564
|
+
const rel = `${CHECK_LOG_DIR}/precheck-${round + 1}-${i + 1}.log`;
|
|
1565
|
+
let logPath = null;
|
|
1566
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${rel}`, String(f.output ?? "")); logPath = rel; } catch { /* tail only */ } }
|
|
1567
|
+
return { command: f.command, rc: f.rc, output: quietFailure(f.output, { maxChars: capped[i].output.length || 500, logPath }) };
|
|
1568
|
+
}));
|
|
1569
|
+
await fixRound("precheck", precheckFailurePrompt(quiet, vcfg.mode), [], "precheck", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
|
|
1222
1570
|
await upsertStage("implementation");
|
|
1223
1571
|
result = (await readResult()) ?? result;
|
|
1224
1572
|
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; }
|
|
@@ -1234,7 +1582,7 @@ export async function runCore(o) {
|
|
|
1234
1582
|
const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
|
|
1235
1583
|
return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
|
|
1236
1584
|
};
|
|
1237
|
-
let structure = await structureOf();
|
|
1585
|
+
let structure = light ? null : await structureOf(); // #4451: no structure delta on a light lane
|
|
1238
1586
|
if (structure) {
|
|
1239
1587
|
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
1240
1588
|
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
@@ -1244,13 +1592,14 @@ export async function runCore(o) {
|
|
|
1244
1592
|
let edges = undeclared();
|
|
1245
1593
|
if (edges.length && sessionId) {
|
|
1246
1594
|
T.decisions.push(`Undeclared package edge(s) ${edges.map((e) => `${e.from}→${e.to}`).join(", ")}; builder resumed once`);
|
|
1247
|
-
await
|
|
1595
|
+
await fixRound("structure", structurePrompt(edges, vcfg.mode), [], "structure", edges.map((e) => `undeclared package edge \`${e.from}\` -> \`${e.to}\` (${e.manifest})`).join("\n"));
|
|
1248
1596
|
await upsertStage("implementation");
|
|
1249
1597
|
result = (await readResult()) ?? result;
|
|
1250
1598
|
structure = (await structureOf()) ?? structure;
|
|
1251
1599
|
edges = undeclared();
|
|
1252
1600
|
}
|
|
1253
1601
|
verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
|
|
1602
|
+
if (!edges.length) await gatePassed("structure");
|
|
1254
1603
|
if (edges.length) {
|
|
1255
1604
|
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);
|
|
1256
1605
|
}
|
|
@@ -1262,12 +1611,13 @@ export async function runCore(o) {
|
|
|
1262
1611
|
if (docs && !docs.ok && sessionId) {
|
|
1263
1612
|
ev({ event: "DOCS_STALE", round: 1 });
|
|
1264
1613
|
T.decisions.push("Docs freshness check failed; builder resumed once");
|
|
1265
|
-
await
|
|
1614
|
+
await fixRound("docs", docsPrompt(docs.output), [], "docs", String(docs.output ?? ""));
|
|
1266
1615
|
await upsertStage("implementation");
|
|
1267
1616
|
result = (await readResult()) ?? result;
|
|
1268
1617
|
docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1269
1618
|
}
|
|
1270
1619
|
verifyFacts.docs = docs;
|
|
1620
|
+
if (docs?.ok) await gatePassed("docs");
|
|
1271
1621
|
if (docs && !docs.ok) {
|
|
1272
1622
|
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);
|
|
1273
1623
|
}
|
|
@@ -1280,30 +1630,98 @@ export async function runCore(o) {
|
|
|
1280
1630
|
if (missing.length && sessionId) {
|
|
1281
1631
|
ev({ event: "ENUMERATING_MISSING", count: missing.length });
|
|
1282
1632
|
T.decisions.push(`Enumerating test(s) miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}; builder resumed once`);
|
|
1283
|
-
await
|
|
1633
|
+
await fixRound("enumerating", missingEntriesPrompt(missing, vcfg.mode), [], "enumerating", missing.map((m) => `enumerating test ${m.test} misses new member \`${m.symbol}\``).join("\n"));
|
|
1284
1634
|
await upsertStage("implementation");
|
|
1285
1635
|
result = (await readResult()) ?? result;
|
|
1286
1636
|
missing = await missingNow();
|
|
1287
1637
|
}
|
|
1638
|
+
if (!missing.length) await gatePassed("enumerating");
|
|
1288
1639
|
if (missing.length) T.anomalies.push(`Enumerating test(s) still miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}`);
|
|
1289
1640
|
}
|
|
1290
|
-
/**
|
|
1291
|
-
|
|
1641
|
+
/**
|
|
1642
|
+
* #4322: null (nothing to do), {done} (terminal), or {renumbered: true, deterministic?}. Fetches the lane, so the check
|
|
1643
|
+
* sees its tip. #4386: the engine renumbers first (no builder, no session); the builder only when that is unsupported.
|
|
1644
|
+
* `laneRef`: the pre-merge lane head after a base-sync took the base side of sequence-only conflicts.
|
|
1645
|
+
*/
|
|
1646
|
+
async function sequenceHandoff(trigger, { laneRef = null } = {}) {
|
|
1292
1647
|
if (!io.sequenceCheck || isSplit) return null;
|
|
1293
1648
|
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
1294
1649
|
const collisions = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1295
1650
|
if (!collisions.length) return null;
|
|
1296
1651
|
const before = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1297
1652
|
ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
|
|
1298
|
-
|
|
1299
|
-
|
|
1653
|
+
const taken = collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ");
|
|
1654
|
+
if (io.sequenceRenumber) {
|
|
1655
|
+
const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
|
|
1656
|
+
if (det.ok && det.moves?.length) {
|
|
1657
|
+
ev({ event: "SEQUENCE_RENUMBERED", trigger, mode: "deterministic", moves: det.moves });
|
|
1658
|
+
T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; renumbered deterministically (no builder): ${det.moves.map((m) => `${m.from} → ${m.to}`).join(", ")}`);
|
|
1659
|
+
const v = (await io.sequenceVerify?.({ cwd: wt, base, before, moves: det.moves }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
|
|
1660
|
+
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) };
|
|
1661
|
+
await gatePassed("sequence");
|
|
1662
|
+
files = await changedFiles();
|
|
1663
|
+
return { renumbered: true, deterministic: true };
|
|
1664
|
+
}
|
|
1665
|
+
const why = det.unsupported ?? "no movable sequence file";
|
|
1666
|
+
ev({ event: "SEQUENCE_RENUMBER_FALLBACK", trigger, reason: why });
|
|
1667
|
+
T.decisions.push(`Deterministic renumber not possible (${why}); builder renumbers`);
|
|
1668
|
+
}
|
|
1669
|
+
T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; builder renumbers`);
|
|
1670
|
+
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"));
|
|
1300
1671
|
result = (await readResult()) ?? result;
|
|
1301
1672
|
const v = (await io.sequenceVerify?.({ cwd: wt, base, before }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
|
|
1302
1673
|
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) };
|
|
1674
|
+
await gatePassed("sequence");
|
|
1303
1675
|
files = await changedFiles();
|
|
1304
1676
|
return { renumbered: true };
|
|
1305
1677
|
}
|
|
1306
1678
|
{ const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
|
|
1679
|
+
/** #4406: same-app duplicate exports (verify.duplicate_exports): one "rename or reuse" round, then an anomaly. True when a fix round ran. */
|
|
1680
|
+
async function duplicateGate(trigger) {
|
|
1681
|
+
if (!io.duplicateExports || isSplit || vcfg.duplicateExports === false) return false;
|
|
1682
|
+
const found = async () => (await io.duplicateExports({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1683
|
+
let dups = await found();
|
|
1684
|
+
if (dups.length && sessionId) {
|
|
1685
|
+
const text = dups.map((d) => `duplicate export \`${d.symbol}\`: ${d.path} and ${(d.others ?? []).join(", ")}`).join("\n");
|
|
1686
|
+
ev({ event: "DUPLICATE_EXPORTS", trigger, count: dups.length });
|
|
1687
|
+
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`);
|
|
1688
|
+
await fixRound("duplicate", duplicateExportsPrompt(dups, vcfg.mode), [], "duplicate", text);
|
|
1689
|
+
result = (await readResult()) ?? result;
|
|
1690
|
+
files = await changedFiles();
|
|
1691
|
+
dups = await found();
|
|
1692
|
+
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("; ")}`);
|
|
1693
|
+
else await gatePassed("duplicate");
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
if (dups.length) T.anomalies.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path}`).join("; ")}`);
|
|
1697
|
+
return false;
|
|
1698
|
+
}
|
|
1699
|
+
await duplicateGate("hand-off");
|
|
1700
|
+
/** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
|
|
1701
|
+
async function postMergeGate(trigger) {
|
|
1702
|
+
const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
|
|
1703
|
+
let fixed = false, results = [];
|
|
1704
|
+
for (let round = 0; commands.length; round++) {
|
|
1705
|
+
await renew();
|
|
1706
|
+
results = [];
|
|
1707
|
+
for (const command of commands) {
|
|
1708
|
+
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) }; }
|
|
1709
|
+
results.push({ command, rc: Number(x?.rc ?? 1), output: String(x?.output ?? "") });
|
|
1710
|
+
}
|
|
1711
|
+
ev({ event: "POST_MERGE_CHECKS", trigger, round, results: results.map(({ command, rc }) => ({ command, rc })) });
|
|
1712
|
+
const failed = results.filter((r) => r.rc !== 0), names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1713
|
+
if (!failed.length) { if (round) await gatePassed("post_merge"); break; }
|
|
1714
|
+
if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Post-merge check(s) still fail after merging ${base} (${trigger}): ${names}`); break; }
|
|
1715
|
+
T.decisions.push(`Post-merge check(s) failed after merging ${base} (${trigger}): ${names}; builder resumed before the push (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1716
|
+
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1717
|
+
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
1718
|
+
await fixRound("post_merge", postMergeFailurePrompt(capped, vcfg.mode), [], "post_merge", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
|
|
1719
|
+
result = (await readResult()) ?? result;
|
|
1720
|
+
fixed = true;
|
|
1721
|
+
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; }
|
|
1722
|
+
}
|
|
1723
|
+
return { fixed, ran: results.map(({ command, rc }) => `${command}: ${rc === 0 ? "pass" : `fail (exit ${rc})`}`).join("; ") };
|
|
1724
|
+
}
|
|
1307
1725
|
const structureLine = structureNote(structure);
|
|
1308
1726
|
|
|
1309
1727
|
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
@@ -1311,6 +1729,7 @@ export async function runCore(o) {
|
|
|
1311
1729
|
// cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
|
|
1312
1730
|
let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
|
|
1313
1731
|
const ciSeenRuns = [];
|
|
1732
|
+
let ciGateNames; // #4387: checks that gate CI verification, read once per lane
|
|
1314
1733
|
/** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
|
|
1315
1734
|
async function ciVerify(trigger) {
|
|
1316
1735
|
let fixes = 0;
|
|
@@ -1318,15 +1737,18 @@ export async function runCore(o) {
|
|
|
1318
1737
|
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1319
1738
|
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
1320
1739
|
files = await changedFiles();
|
|
1321
|
-
|
|
1740
|
+
if (ciGateNames === undefined) ciGateNames = vcfg.workflow ? null : await ciGate({ io, lane, mergeWait, verify: vcfg });
|
|
1741
|
+
const verifyArgs = { io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns, gate: ciGateNames,
|
|
1322
1742
|
push: () => gitPush(["push", "-u", "origin", branch]),
|
|
1323
|
-
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } }
|
|
1743
|
+
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } };
|
|
1744
|
+
const v = await runCiVerify(verifyArgs);
|
|
1324
1745
|
if (v.runId != null) ciSeenRuns.push(v.runId);
|
|
1325
1746
|
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) };
|
|
1326
1747
|
ev(rec); await emit({ ...rec, phase: "verify" });
|
|
1327
1748
|
const row = (res) => { T.verify = [res, `mode ${vcfg.mode}, ${ciRounds} fix round(s)${v.runUrl ? `, ${v.runUrl}` : ""}`]; };
|
|
1328
1749
|
if (v.result === "pass" || v.result === "skipped") {
|
|
1329
1750
|
ciVerifiedHead = head;
|
|
1751
|
+
if (v.result === "pass") await gatePassed("ci_red", "merge_ci");
|
|
1330
1752
|
row(v.result === "pass" ? `CI green at ${head.slice(0, 12)}` : "skipped (docs-only change)");
|
|
1331
1753
|
if (v.result === "skipped") T.decisions.push(`CI verification skipped: ${v.detail}`);
|
|
1332
1754
|
return { fixes };
|
|
@@ -1341,7 +1763,7 @@ export async function runCore(o) {
|
|
|
1341
1763
|
ciFellBack = true;
|
|
1342
1764
|
row("unavailable: local fallback");
|
|
1343
1765
|
T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
|
|
1344
|
-
await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null);
|
|
1766
|
+
await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null, false, undefined, { trigger: "ci_unavailable" });
|
|
1345
1767
|
await upsertStage("implementation");
|
|
1346
1768
|
result = (await readResult()) ?? result;
|
|
1347
1769
|
return { fixes: fixes + 1 };
|
|
@@ -1355,11 +1777,22 @@ export async function runCore(o) {
|
|
|
1355
1777
|
ciRounds++; fixes++;
|
|
1356
1778
|
T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1357
1779
|
ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
|
|
1358
|
-
await
|
|
1780
|
+
await fixRound("ci", ciFailurePrompt(v.failures, vcfg.mode), [], "ci_red", failureLog(v.failures));
|
|
1359
1781
|
await upsertStage("implementation");
|
|
1360
1782
|
result = (await readResult()) ?? result;
|
|
1361
1783
|
if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
|
|
1362
|
-
|
|
1784
|
+
// #4387: "no commit" says the failure is not the change's: rerun the failed jobs once to tell a flake from a real failure.
|
|
1785
|
+
const rr = isSplit ? { ok: false, runs: [], jobs: [], detail: "refactor:split" } : await rerunFailedOnce({ io, v });
|
|
1786
|
+
const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(v) }) : null;
|
|
1787
|
+
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 });
|
|
1788
|
+
if (v2?.result === "pass") {
|
|
1789
|
+
ciVerifiedHead = head; await gatePassed("ci_red", "merge_ci"); row(`CI green at ${head.slice(0, 12)} after a rerun of the failed jobs`);
|
|
1790
|
+
T.decisions.push(`CI verification red (${jobs}), no fix commit; green after one rerun of the failed jobs (flake)`);
|
|
1791
|
+
return { fixes };
|
|
1792
|
+
}
|
|
1793
|
+
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`) };
|
|
1794
|
+
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";
|
|
1795
|
+
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) };
|
|
1363
1796
|
}
|
|
1364
1797
|
}
|
|
1365
1798
|
}
|
|
@@ -1370,8 +1803,8 @@ export async function runCore(o) {
|
|
|
1370
1803
|
// plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
|
|
1371
1804
|
// A review without usable rows ends needs-human (or, with engine.review_incomplete: degraded, merges flagged).
|
|
1372
1805
|
await label("in-review");
|
|
1373
|
-
|
|
1374
|
-
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false;
|
|
1806
|
+
let shadow = conformance !== "enforce";
|
|
1807
|
+
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
|
|
1375
1808
|
const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
|
|
1376
1809
|
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).";
|
|
1377
1810
|
async function amend() {
|
|
@@ -1406,17 +1839,61 @@ export async function runCore(o) {
|
|
|
1406
1839
|
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
1407
1840
|
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
|
|
1408
1841
|
await renew();
|
|
1409
|
-
await attempt("review", ++reviewN, rr);
|
|
1842
|
+
await attempt("review", ++reviewN, rr, null, "review");
|
|
1410
1843
|
const parsed = parseReview(rr?.text);
|
|
1411
1844
|
review = repair ? mergeReviewRows(first, parsed) : parsed;
|
|
1412
1845
|
first = review;
|
|
1413
1846
|
d = decide(review, ctx);
|
|
1414
1847
|
if (d.review) review = d.review;
|
|
1415
1848
|
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
1849
|
+
const cmp = selfReviewCompared ? null : compareSelfReview(readSelfReview(result), d); // #4374: once, on the first judgement
|
|
1850
|
+
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) }); }
|
|
1416
1851
|
if (!d.state.startsWith("review.")) return d;
|
|
1417
1852
|
}
|
|
1418
1853
|
return d;
|
|
1419
1854
|
}
|
|
1855
|
+
/**
|
|
1856
|
+
* #4397: one independent adversarial verifier call per head: fresh context, read-only, never the builder's model, result
|
|
1857
|
+
* or notes. Returns the failures to enforce ([] in shadow, on an empty diff or on a verifier error, which is an anomaly).
|
|
1858
|
+
*/
|
|
1859
|
+
const verifiedHeads = new Map();
|
|
1860
|
+
let verifierRun = null, verifyN = 0;
|
|
1861
|
+
async function runVerifier() {
|
|
1862
|
+
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1863
|
+
if (verifiedHeads.has(head)) return verifiedHeads.get(head);
|
|
1864
|
+
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
1865
|
+
const run = { mode: verifierCfg.mode, head, findings: [], model: null, swapped: false, cost_usd: 0 };
|
|
1866
|
+
if (!diff.trim()) run.skipped = "empty_diff";
|
|
1867
|
+
else {
|
|
1868
|
+
const bytes = Buffer.byteLength(diff, "utf-8");
|
|
1869
|
+
const stat = bytes > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
1870
|
+
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1871
|
+
const callers = await callersOf({ symbols: changedSymbols(diff), gitGrep: git, ignore: IGNORE });
|
|
1872
|
+
Object.assign(run, pickVerifierModel({ configured: verifierCfg.model, builderModel, builderReported: [...impl.models, ...(route?.tier === "mid" ? [route.model] : [])] }));
|
|
1873
|
+
const { maxMs, maxTurns } = verifierBudget(bytes);
|
|
1874
|
+
try {
|
|
1875
|
+
await renew();
|
|
1876
|
+
const rr = await callRunner({ role: "verifier", issue, cwd: wt, model: run.model, resume: null, system: VERIFIER_SYSTEM, readOnly: true, maxMs, maxTurns,
|
|
1877
|
+
prompt: verifierPrompt({ issue: iss, comments: trustedComments, contract: effective, diff, stat, base, files, callers }) });
|
|
1878
|
+
await renew();
|
|
1879
|
+
await attempt("verify", ++verifyN, rr, null, "verifier");
|
|
1880
|
+
run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
|
|
1881
|
+
run.cost_usd = num(rr?.costUsd) ?? 0;
|
|
1882
|
+
} catch (e) {
|
|
1883
|
+
if (e?.deferUsageLimit) throw e;
|
|
1884
|
+
run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
|
|
1885
|
+
T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
const failures = verifierCfg.mode === "enforce" ? findingsToFailures(run.findings) : [];
|
|
1889
|
+
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,
|
|
1890
|
+
ratio: impl.cost > 0 ? Math.round((run.cost_usd / impl.cost) * 1000) / 1000 : null, ...(run.skipped ? { skipped: run.skipped } : {}), ...(run.error ? { error: run.error } : {}) });
|
|
1891
|
+
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(", ")}`);
|
|
1892
|
+
verifiedHeads.set(head, failures);
|
|
1893
|
+
verifierRun = run;
|
|
1894
|
+
return failures;
|
|
1895
|
+
}
|
|
1896
|
+
const verifierSection = () => { const n = renderVerifierNote(verifierRun); return n ? `\n\n${n}` : ""; };
|
|
1420
1897
|
/** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
|
|
1421
1898
|
async function rerunFailedValidates(contract) {
|
|
1422
1899
|
if (!io.runCommand) return null;
|
|
@@ -1437,12 +1914,26 @@ export async function runCore(o) {
|
|
|
1437
1914
|
await refreshIssue("conformance");
|
|
1438
1915
|
result = (await readResult()) ?? result;
|
|
1439
1916
|
files = await changedFiles();
|
|
1917
|
+
gateRan = true;
|
|
1918
|
+
// #4398: a diff touching a declared review.risk_paths glob makes this lane enforce from here on (sticky)
|
|
1919
|
+
const hits = shadow ? riskPathHits(files, riskPaths) : [];
|
|
1920
|
+
if (hits.length) {
|
|
1921
|
+
shadow = false; riskEnforced = hits;
|
|
1922
|
+
ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
|
|
1923
|
+
T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
|
|
1924
|
+
}
|
|
1440
1925
|
const amendProblems = await amend();
|
|
1441
1926
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1442
|
-
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1927
|
+
const hotspots = light ? null : await hotspotsOf({ base, paths: files }); // #4451
|
|
1443
1928
|
const validateRuns = await rerunFailedValidates(effective);
|
|
1444
1929
|
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
1445
|
-
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas });
|
|
1930
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
|
|
1931
|
+
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
1932
|
+
if (pc.superseded?.length) {
|
|
1933
|
+
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
1934
|
+
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(", ")})`;
|
|
1935
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1936
|
+
}
|
|
1446
1937
|
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1447
1938
|
// #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
|
|
1448
1939
|
// never a conformance failure, never a builder resume
|
|
@@ -1466,14 +1957,19 @@ export async function runCore(o) {
|
|
|
1466
1957
|
}
|
|
1467
1958
|
triggerSkipped = false;
|
|
1468
1959
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1960
|
+
let d = await reviewOnce();
|
|
1961
|
+
// #4397: the verifier runs only on a change every gate and the reviewer passed; in enforce its findings block
|
|
1962
|
+
if (verifierCfg.mode !== "off" && d.state === "conforms" && !pre.length) {
|
|
1963
|
+
const vf = await runVerifier();
|
|
1964
|
+
if (vf.length) d = { state: "nonconforming", failures: vf, by: "verifier" };
|
|
1965
|
+
}
|
|
1966
|
+
if (!shadow) return { ...d, by: d.by ?? "review" };
|
|
1471
1967
|
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
1472
1968
|
const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
|
|
1473
1969
|
const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
|
|
1474
1970
|
if (d.state.startsWith("review.")) return { ...d, by: "review", would, wouldFail, reviewState: d.state };
|
|
1475
1971
|
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
1476
|
-
return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
1972
|
+
return { state: blocking.length ? "nonconforming" : "conforms", by: d.by ?? "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
1477
1973
|
}
|
|
1478
1974
|
const verifyReported = new Set();
|
|
1479
1975
|
/**
|
|
@@ -1527,7 +2023,7 @@ export async function runCore(o) {
|
|
|
1527
2023
|
const outcome = await gate();
|
|
1528
2024
|
verdict = outcome;
|
|
1529
2025
|
for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
|
|
1530
|
-
if (outcome.state === "conforms") break;
|
|
2026
|
+
if (outcome.state === "conforms") { await gatePassed("conformance"); break; }
|
|
1531
2027
|
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1532
2028
|
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1533
2029
|
if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
|
|
@@ -1544,7 +2040,7 @@ export async function runCore(o) {
|
|
|
1544
2040
|
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`);
|
|
1545
2041
|
// A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
|
|
1546
2042
|
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 */ } }
|
|
1547
|
-
await
|
|
2043
|
+
await fixRound("conformance", conformanceResumePrompt(outcome.failures, vcfg.mode, lane), outcome.failures, "conformance", failureLog(outcome.failures));
|
|
1548
2044
|
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
1549
2045
|
}
|
|
1550
2046
|
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
@@ -1567,14 +2063,24 @@ export async function runCore(o) {
|
|
|
1567
2063
|
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1568
2064
|
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1569
2065
|
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1570
|
-
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) }));
|
|
2066
|
+
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());
|
|
1571
2067
|
} else {
|
|
1572
2068
|
T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1573
|
-
await post("FORGE:REVIEW", renderReview(review, effective));
|
|
2069
|
+
await post("FORGE:REVIEW", renderReview(review, effective) + verifierSection());
|
|
1574
2070
|
}
|
|
1575
2071
|
}
|
|
1576
2072
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
1577
|
-
const closing = closePlan(effective);
|
|
2073
|
+
const closing = closePlan(effective, { title: iss.title, review });
|
|
2074
|
+
if (closing.reason === "partial") { // #4398: an investigation whose question is not answered carries it forward
|
|
2075
|
+
const untracked = closing.open.filter((r) => !r.tracked);
|
|
2076
|
+
if (untracked.length) {
|
|
2077
|
+
const carried = untracked.map((r) => ({ ...r, disposition: "defer", why: "investigation question not answered" }));
|
|
2078
|
+
await track(carried, "the investigation's question is not answered");
|
|
2079
|
+
for (const [i, r] of untracked.entries()) r.tracked = carried[i].tracked;
|
|
2080
|
+
}
|
|
2081
|
+
for (const t of closing.open.map((r) => r.tracked).filter(Boolean)) if (!closing.tracked.includes(t)) closing.tracked.push(t);
|
|
2082
|
+
T.decisions.push(`Investigation not answered (${closing.open.map((r) => r.id).join(", ")}): closing as partial, carried forward in ${closing.tracked.join(", ")}`);
|
|
2083
|
+
}
|
|
1578
2084
|
if (degradedReq?.tracked && !closing.tracked.includes(degradedReq.tracked)) closing.tracked.push(degradedReq.tracked);
|
|
1579
2085
|
|
|
1580
2086
|
// ---- 5. push + PR ----
|
|
@@ -1611,7 +2117,8 @@ export async function runCore(o) {
|
|
|
1611
2117
|
T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
|
|
1612
2118
|
|
|
1613
2119
|
// ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
|
|
1614
|
-
|
|
2120
|
+
// #4386: the bound counts syncs against one base head; a sync overtaken by a new base head made progress.
|
|
2121
|
+
let syncs = 0, sameBaseSyncs = 0, lastSyncBase = null, mergeRetries = 0;
|
|
1615
2122
|
const MAX_SYNCS = 2;
|
|
1616
2123
|
const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
|
|
1617
2124
|
/** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
|
|
@@ -1646,11 +2153,12 @@ export async function runCore(o) {
|
|
|
1646
2153
|
const syncRow = [];
|
|
1647
2154
|
/** Returns null to continue with the merge flow, or a terminal result. */
|
|
1648
2155
|
async function baseSync(trigger, soft = false) {
|
|
1649
|
-
if (syncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
1650
|
-
syncs++;
|
|
1651
2156
|
await renew();
|
|
1652
2157
|
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
1653
|
-
const
|
|
2158
|
+
const baseFull = await gitOut(["rev-parse", base]), baseSha = baseFull.slice(0, 12);
|
|
2159
|
+
if (baseFull !== lastSyncBase) { lastSyncBase = baseFull; sameBaseSyncs = 0; }
|
|
2160
|
+
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);
|
|
2161
|
+
sameBaseSyncs++; syncs++;
|
|
1654
2162
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1655
2163
|
let clean = true, files = [];
|
|
1656
2164
|
try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
|
|
@@ -1658,13 +2166,21 @@ export async function runCore(o) {
|
|
|
1658
2166
|
await gitOut(["merge", "--abort"]);
|
|
1659
2167
|
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);
|
|
1660
2168
|
}
|
|
1661
|
-
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
|
|
2169
|
+
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)", detResolved = false;
|
|
2170
|
+
// #4386: conflicts only in sequence files (numbered, journal, snapshot, their index) resolve without a builder
|
|
2171
|
+
const rs = !clean && io.sequenceResolve ? ((await io.sequenceResolve({ cwd: wt, files }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? {}) : null;
|
|
2172
|
+
if (rs?.unsupported) T.decisions.push(`Base-sync ${syncs}: no deterministic resolution (${rs.unsupported})`);
|
|
1662
2173
|
if (clean) {
|
|
1663
2174
|
how = "clean merge of the base into the branch";
|
|
2175
|
+
} else if (rs?.ok) {
|
|
2176
|
+
detResolved = true;
|
|
2177
|
+
T.decisions.push(`Base-sync ${syncs}: conflicts only in sequence files (${files.join(", ")}); base side taken, lane files renumbered by the engine (no builder)`);
|
|
2178
|
+
how = `deterministic: base side taken for ${files.length} sequence file(s)`;
|
|
2179
|
+
tests = "not required (deterministic sequence resolution)";
|
|
1664
2180
|
} else {
|
|
1665
2181
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
1666
2182
|
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.`;
|
|
1667
|
-
await builder(++attemptN, prompt, sessionId ?? null);
|
|
2183
|
+
await builder(++attemptN, prompt, sessionId ?? null, false, undefined, { trigger: "recovery" }); // #4362: base-sync conflict
|
|
1668
2184
|
if (await unresolved()) {
|
|
1669
2185
|
await gitOut(["merge", "--abort"]);
|
|
1670
2186
|
return await needsHuman(`Base-sync could not resolve the conflicts in ${files.join(", ")} (conflict markers or an unfinished merge remain).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
@@ -1673,11 +2189,19 @@ export async function runCore(o) {
|
|
|
1673
2189
|
const r2 = await readResult();
|
|
1674
2190
|
tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
|
|
1675
2191
|
}
|
|
1676
|
-
const sq = await sequenceHandoff(`base-sync ${syncs}
|
|
2192
|
+
const sq = await sequenceHandoff(`base-sync ${syncs}`, detResolved ? { laneRef: before } : {}); // #4322: a clean text merge can still bring a taken number
|
|
1677
2193
|
if (sq?.done) return sq.done;
|
|
1678
|
-
if (sq?.renumbered) { how += "; builder renumbered sequence-numbered files"; }
|
|
2194
|
+
if (sq?.renumbered) { how += sq.deterministic ? "; lane files renumbered deterministically" : "; builder renumbered sequence-numbered files"; }
|
|
2195
|
+
let pm = { fixed: false, ran: "" }, dupFixed = false;
|
|
2196
|
+
if ((await gitOut(["rev-parse", "HEAD"])) !== before) { // #4406: the merged tree, before the push
|
|
2197
|
+
pm = await postMergeGate(`base-sync ${syncs}`);
|
|
2198
|
+
if (pm.ran) tests = clean ? pm.ran : `${tests}; post-merge: ${pm.ran}`;
|
|
2199
|
+
if (pm.fixed) how += "; builder fixed post-merge check failures";
|
|
2200
|
+
dupFixed = await duplicateGate(`base-sync ${syncs}`);
|
|
2201
|
+
if (dupFixed) how += "; builder resolved duplicate exports";
|
|
2202
|
+
}
|
|
1679
2203
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
1680
|
-
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
2204
|
+
if (soft && clean && after === before) { syncs--; sameBaseSyncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
1681
2205
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
1682
2206
|
let ciFixes = 0;
|
|
1683
2207
|
if (isSplit && after !== before) { // #4211: the merged head must still be a pure move against the new base
|
|
@@ -1686,12 +2210,14 @@ export async function runCore(o) {
|
|
|
1686
2210
|
rereview = "refactor-verify re-run against the new base: ok";
|
|
1687
2211
|
}
|
|
1688
2212
|
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1689
|
-
|
|
2213
|
+
const engineOnly = !ciFixes && !pm.fixed && !dupFixed && (clean || detResolved) && (!sq?.renumbered || sq.deterministic); // #4386: no builder touched the delta
|
|
2214
|
+
if (!isSplit && engineOnly && (detResolved || sq?.deterministic)) rereview = "not required (deterministic renumber + base merge)";
|
|
2215
|
+
else if (!isSplit && (!clean || sq?.renumbered || ciFixes || pm.fixed || dupFixed)) {
|
|
1690
2216
|
let rv = await doReview();
|
|
1691
2217
|
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1692
2218
|
if (rv?.fail) {
|
|
1693
2219
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
1694
|
-
await
|
|
2220
|
+
await fixRound("review", conformanceResumePrompt(rv.failures, vcfg.mode), rv.failures, "review", failureLog(rv.failures));
|
|
1695
2221
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
1696
2222
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
1697
2223
|
rv = await doReview();
|
|
@@ -1699,6 +2225,7 @@ export async function runCore(o) {
|
|
|
1699
2225
|
}
|
|
1700
2226
|
if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
|
|
1701
2227
|
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
2228
|
+
await gatePassed("review");
|
|
1702
2229
|
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
1703
2230
|
}
|
|
1704
2231
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
@@ -1724,7 +2251,7 @@ export async function runCore(o) {
|
|
|
1724
2251
|
T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1725
2252
|
ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
|
|
1726
2253
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1727
|
-
await
|
|
2254
|
+
await fixRound("ci", ciFailurePrompt(failures, vcfg.mode), [], "merge_ci", failureLog(failures));
|
|
1728
2255
|
await upsertStage("implementation");
|
|
1729
2256
|
result = (await readResult()) ?? result;
|
|
1730
2257
|
if ((await gitOut(["rev-parse", "HEAD"])) === before) {
|
|
@@ -1759,7 +2286,7 @@ export async function runCore(o) {
|
|
|
1759
2286
|
* only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
|
|
1760
2287
|
*/
|
|
1761
2288
|
async function recordStructure(pr) {
|
|
1762
|
-
const d = (await structureOf()) ?? {};
|
|
2289
|
+
const d = (light ? null : await structureOf()) ?? {}; // #4451
|
|
1763
2290
|
const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
|
|
1764
2291
|
const top_growth = numstat.split("\n").map((l) => l.split("\t")).filter((x) => x.length >= 3 && /^\d+$/.test(x[0]) && Number(x[0]) > 0 && !x[2].startsWith(`${LEAN_DIR}/`))
|
|
1765
2292
|
.map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
|
|
@@ -1781,6 +2308,30 @@ export async function runCore(o) {
|
|
|
1781
2308
|
await emit(rec);
|
|
1782
2309
|
}
|
|
1783
2310
|
|
|
2311
|
+
/**
|
|
2312
|
+
* #4403 e2e merge gate (e2e.mjs): a lane touching e2e specs or with [type:e2e] criteria runs the repo's e2e (label or
|
|
2313
|
+
* dispatch) on the reviewed head before merge. Returns null to merge, {again} after a fix round, or {done}.
|
|
2314
|
+
*/
|
|
2315
|
+
let e2eHead = null;
|
|
2316
|
+
const e2eRuns = [];
|
|
2317
|
+
async function e2eGate() {
|
|
2318
|
+
const cur = await changedFiles(), want = e2eWanted({ cfg: vcfg.e2e, files: cur, contract: effective });
|
|
2319
|
+
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; }
|
|
2320
|
+
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,
|
|
2321
|
+
changedFiles: cur, ciMaxMinutes, now, sleep, random, exclude: e2eRuns, onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "merge", detail: `e2e pending on PR #${pr}` }); } });
|
|
2322
|
+
if (v.runId != null) e2eRuns.push(v.runId);
|
|
2323
|
+
const own = v.result === "fail" ? (vcfg.e2e.blocking === "all" ? v.failures : ownFailures(v.failures, want.specs)) : [];
|
|
2324
|
+
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 };
|
|
2325
|
+
ev(rec); await emit({ ...rec, phase: "merge" });
|
|
2326
|
+
T.decisions.push(`e2e (${want.reason}): ${v.result} at ${localHead.slice(0, 12)}${v.detail ? ` (${v.detail})` : ""}`);
|
|
2327
|
+
if (v.result === "timeout") return { done: await finish("ci-pending", `PR #${pr} e2e still pending after ${ciMaxMinutes} min`, { pr }) };
|
|
2328
|
+
if (v.result === "unavailable") return { done: await needsHuman(`e2e verification unavailable on PR #${pr} (${v.detail}); nothing merged.`) };
|
|
2329
|
+
if (own.length) { const r = await mergeFixRound(own.map((f) => ({ name: f.job, url: f.url }))); return r ? { done: r } : { again: true }; }
|
|
2330
|
+
if (v.result === "fail") T.anomalies.push(`Pre-existing e2e failure(s) reported, not blocking: ${v.failures.map((f) => f.job).join(", ")}`);
|
|
2331
|
+
e2eHead = localHead;
|
|
2332
|
+
return null;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
1784
2335
|
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
1785
2336
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
1786
2337
|
for (;;) {
|
|
@@ -1842,11 +2393,34 @@ export async function runCore(o) {
|
|
|
1842
2393
|
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);
|
|
1843
2394
|
}
|
|
1844
2395
|
}
|
|
2396
|
+
if (e2eHead !== localHead) { const g = await e2eGate(); if (g?.done) return g.done; if (g?.again) continue; }
|
|
1845
2397
|
if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
|
|
2398
|
+
// #4386: late binding. A lane with numbered files holds the host sequence lease from here through the merge and
|
|
2399
|
+
// re-checks its numbers against the fetched lane; a taken number is renumbered by the engine and only CI is waited on.
|
|
2400
|
+
if (io.sequenceCheck && io.sequenceLease && !isSplit) {
|
|
2401
|
+
if (!seqLease) {
|
|
2402
|
+
const mine = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
2403
|
+
if (mine.length) {
|
|
2404
|
+
seqLease = await io.sequenceLease.acquire({ repo, lane, dirs: [...new Set(mine.map((f) => dirname(f.path)))], issue, maxWaitS: ciMaxMinutes * 60 }).catch(() => null);
|
|
2405
|
+
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)`);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
if (seqLease) {
|
|
2409
|
+
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
2410
|
+
const late = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
2411
|
+
if (late.length) {
|
|
2412
|
+
ev({ event: "SEQUENCE_LATE_BIND", pr, files: late.map((c) => c.lane) });
|
|
2413
|
+
const r = await baseSync("merge gate: sequence number taken"); if (r) return r;
|
|
2414
|
+
continue; // wait for the required checks of the renumbered head
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
1846
2418
|
T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
|
|
1847
2419
|
laneGuard(resumed?.base ?? lane, "merge");
|
|
1848
2420
|
try {
|
|
1849
2421
|
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
|
2422
|
+
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
2423
|
+
seqLease = null;
|
|
1850
2424
|
break;
|
|
1851
2425
|
} catch (e) {
|
|
1852
2426
|
const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
|
|
@@ -1879,8 +2453,11 @@ export async function runCore(o) {
|
|
|
1879
2453
|
}
|
|
1880
2454
|
try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
|
|
1881
2455
|
await deleteMergedBranch();
|
|
1882
|
-
|
|
1883
|
-
|
|
2456
|
+
const partial = closing.reason === "partial";
|
|
2457
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
|
|
2458
|
+
if (partial) {
|
|
2459
|
+
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 */ }
|
|
2460
|
+
} else if (closing.tracked.length) {
|
|
1884
2461
|
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 */ }
|
|
1885
2462
|
}
|
|
1886
2463
|
if (isSplit && split.rest) { // #4211: the rest of a large split is the next PR of the chain, planned already (no grouping call)
|
|
@@ -1893,7 +2470,8 @@ export async function runCore(o) {
|
|
|
1893
2470
|
}
|
|
1894
2471
|
await label("merged");
|
|
1895
2472
|
// forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
|
|
1896
|
-
|
|
2473
|
+
await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
|
|
2474
|
+
return await finish("merged", `PR #${pr}`, { pr, ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
1897
2475
|
} catch (e) {
|
|
1898
2476
|
if (e?.deferUsageLimit) {
|
|
1899
2477
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|