forgedock 1.27.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +39 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +601 -42
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +234 -57
- package/bin/lean/conformance.test.mjs +168 -7
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +104 -9
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +723 -154
- package/bin/lean/lean.test.mjs +1603 -35
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +389 -19
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +106 -17
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +49 -10
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +66 -10
- package/bin/lean/verify.test.mjs +104 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +24 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +99 -0
- package/bin/stats/stats.test.mjs +94 -1
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +62 -8
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +192 -2
- package/docs/OUTCOME-CODES.md +22 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +4 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
package/bin/lean/lean.mjs
CHANGED
|
@@ -5,36 +5,67 @@
|
|
|
5
5
|
* ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
|
|
6
6
|
* severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
|
|
7
7
|
*/
|
|
8
|
-
import { join, dirname, basename } from "node:path";
|
|
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
11
|
import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
12
12
|
import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
|
|
13
13
|
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking,
|
|
16
|
-
import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
15
|
+
import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
16
|
+
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
17
|
+
import { namedPaths } from "./prefilter.mjs";
|
|
17
18
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
18
19
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
20
|
+
import { repoKey } from "../runner.mjs";
|
|
21
|
+
import { prepareToolchain } from "./toolchain.mjs";
|
|
22
|
+
import { readTestMap, readTestMapAtRef, selectCommands, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
19
23
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
20
24
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
25
|
+
import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
26
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
|
|
27
|
+
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
28
|
+
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
|
|
29
|
+
import { laneBackendId } from "./limits.mjs";
|
|
30
|
+
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
31
|
+
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
32
|
+
import { priorityFromLabels } from "./sched.mjs";
|
|
33
|
+
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts } from "./context.mjs";
|
|
23
34
|
|
|
35
|
+
/**
|
|
36
|
+
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
37
|
+
* with RECOVERABLE_CODES (bin/engine/outcome-codes.mjs); recovery.test.mjs checks it (#4298).
|
|
38
|
+
*/
|
|
39
|
+
export const RECOVER_HOW = Object.freeze({
|
|
40
|
+
[OUTCOME_CODES.CI_RED]: "the PR's required checks failed. Read the failed checks' logs (`gh run view <run-id> --log-failed`, or the repo's declared check commands), fix the causes, re-run those checks locally and push new commits only (never rewrite history)",
|
|
41
|
+
[OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
|
|
42
|
+
[OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
|
|
43
|
+
[OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
|
|
44
|
+
});
|
|
24
45
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
25
46
|
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
26
47
|
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
27
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression
|
|
50
|
+
* (precheck form: SHADOW_PRE_BLOCKS). A review without usable rows never merges either.
|
|
51
|
+
*/
|
|
28
52
|
const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
|
|
29
53
|
/**
|
|
30
|
-
* Precheck failures that block in shadow mode too (#4160):
|
|
31
|
-
*
|
|
32
|
-
*
|
|
54
|
+
* Precheck failures that block in shadow mode too (#4160): every precheck or amendment failure coded
|
|
55
|
+
* conformance.unimplemented (a validate command that failed or has no recorded result, a deliver requirement reported as
|
|
56
|
+
* not done, an invalid amendment) or conformance.regression, the same codes a review blocks on, and unexempted growth of an
|
|
57
|
+
* over-threshold file (maintenance.hotspot_growth, forge#4195). Scope creep stays recorded-only.
|
|
33
58
|
*/
|
|
34
|
-
const SHADOW_PRE_BLOCKS = [
|
|
59
|
+
const SHADOW_PRE_BLOCKS = [...SHADOW_BLOCKS, OUTCOME_CODES.MAINTENANCE_HOTSPOT_GROWTH];
|
|
60
|
+
/** forge.yaml engine.review_incomplete (#4160): "needs-human" (default), or "degraded": in shadow, merge an incomplete review with a review-degraded label and a tracked follow-up. */
|
|
61
|
+
export const REVIEW_INCOMPLETE_MODES = Object.freeze(["needs-human", "degraded"]);
|
|
62
|
+
export const REVIEW_DEGRADED_LABEL = "review-degraded";
|
|
63
|
+
const UNUSABLE_REVIEW_CODES = { "review.unparseable": OUTCOME_CODES.REVIEW_NO_VERDICT, "review.incomplete": OUTCOME_CODES.REVIEW_INCOMPLETE, "review.inconsistent": OUTCOME_CODES.REVIEW_INCONSISTENT };
|
|
35
64
|
export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
|
|
36
65
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
37
66
|
export const MAX_SUBISSUES = 8;
|
|
67
|
+
/** #4266: required checks with no rollup entry this long into a CI wait on a MERGEABLE PR log CI_NOT_STARTED (once per wait). */
|
|
68
|
+
export const CI_NOT_STARTED_GRACE_MS = 10 * 60 * 1000;
|
|
38
69
|
/** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
|
|
39
70
|
export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
|
|
40
71
|
|
|
@@ -86,17 +117,34 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
86
117
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
87
118
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
88
119
|
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
120
|
+
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
121
|
+
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
|
|
89
122
|
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
123
|
+
* @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
|
|
124
|
+
* @param {"off"|"prefer"} [o.reproTest] forge.yaml lean.repro_test (#4271): prefer (default) freezes a red-green repro_test on bug lanes; off is today's contract
|
|
125
|
+
* @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
|
|
126
|
+
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
127
|
+
* @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
|
|
128
|
+
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
129
|
+
* @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
|
|
130
|
+
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
131
|
+
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
132
|
+
* @param {object} [o.recover] {code, attempt}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
133
|
+
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note
|
|
90
134
|
*/
|
|
91
135
|
export async function runCore(o) {
|
|
92
136
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
93
|
-
ciMaxMinutes = 360, conformance = "shadow", 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;
|
|
137
|
+
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
94
138
|
const now = io.now ?? (() => Date.now());
|
|
95
139
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
96
140
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
97
141
|
const remoteVerify = vcfg.mode !== "local";
|
|
142
|
+
const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
|
|
143
|
+
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
98
144
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
99
145
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
146
|
+
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
|
|
147
|
+
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
|
|
100
148
|
const branch = `feat/issue-${issue}-lean`;
|
|
101
149
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
102
150
|
const t0 = now();
|
|
@@ -143,18 +191,43 @@ export async function runCore(o) {
|
|
|
143
191
|
let leaseState = null, leaseTimer = null;
|
|
144
192
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
145
193
|
const renew = async () => { if (io.projector && leaseState && !finishing) { /* never re-take a lease a finish (or signal) released */ try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
|
|
146
|
-
async function releaseLease(reason) {
|
|
194
|
+
async function releaseLease(reason, code = null) {
|
|
147
195
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
148
196
|
if (!io.projector || !leaseState) return;
|
|
149
197
|
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
150
|
-
|
|
198
|
+
// forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
|
|
199
|
+
const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
|
|
200
|
+
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
|
|
151
201
|
}
|
|
152
202
|
|
|
153
203
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
154
204
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
205
|
+
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
206
|
+
const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
|
|
207
|
+
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
208
|
+
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
155
209
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
210
|
+
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
156
211
|
const runId = `${issue}-${t0}`;
|
|
157
212
|
let runTmp = null;
|
|
213
|
+
let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
|
|
214
|
+
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
|
|
215
|
+
// #4169: heavy steps take a host semaphore slot (io.heavy); LLM phases are not gated. `heavyEnv` puts the heavy shims on
|
|
216
|
+
// the runner PATH; `waits` ({phase: ms}) and `heavyQueueMax` ride on the lane result for the batch report.
|
|
217
|
+
const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
|
|
218
|
+
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
219
|
+
const waits = {};
|
|
220
|
+
const addWait = (phase, ms, queued = 0) => {
|
|
221
|
+
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
222
|
+
if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
|
|
223
|
+
};
|
|
224
|
+
const lanePriority = () => priorityFromLabels(iss?.labels);
|
|
225
|
+
/** A host heavy-step slot for an engine-side step, or null (no io.heavy, disabled, or the registry failed: ungated). */
|
|
226
|
+
async function heavyAcquire(phase) {
|
|
227
|
+
if (!heavyOn) return null;
|
|
228
|
+
try { const g = await io.heavy.acquire({ issue, phase, priority: lanePriority() }, heavyCfg); addWait(phase, g?.waitMs, g?.queued); return g; } catch { return null; }
|
|
229
|
+
}
|
|
230
|
+
async function heavyStep(phase, fn) { const g = await heavyAcquire(phase); try { return await fn(); } finally { try { g?.release?.(); } catch { /* fail-open */ } } }
|
|
158
231
|
// ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
|
|
159
232
|
let ledger = null, deleteLocalBranch = false;
|
|
160
233
|
const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
|
|
@@ -236,10 +309,26 @@ export async function runCore(o) {
|
|
|
236
309
|
return finishing;
|
|
237
310
|
}
|
|
238
311
|
async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
|
|
312
|
+
if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
|
|
313
|
+
try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
|
|
314
|
+
}
|
|
239
315
|
await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
|
|
240
316
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
241
|
-
|
|
242
|
-
|
|
317
|
+
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
318
|
+
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
319
|
+
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
320
|
+
context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}) }; // forge#4275: the on/off A/B groups runs by these
|
|
321
|
+
ev(term);
|
|
322
|
+
await emit(term);
|
|
323
|
+
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
324
|
+
// decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
|
|
325
|
+
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
|
|
326
|
+
if (recoverUnlabelled && reason === "ci-pending") {
|
|
327
|
+
try {
|
|
328
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${recoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
|
|
329
|
+
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
330
|
+
} catch { /* best effort */ }
|
|
331
|
+
}
|
|
243
332
|
if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
|
|
244
333
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
245
334
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
@@ -250,16 +339,19 @@ export async function runCore(o) {
|
|
|
250
339
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
251
340
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
252
341
|
}
|
|
253
|
-
await releaseLease(reason);
|
|
254
|
-
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,
|
|
342
|
+
await releaseLease(reason, code);
|
|
343
|
+
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
|
|
344
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
255
345
|
return done;
|
|
256
346
|
}
|
|
257
|
-
|
|
347
|
+
/** #4297: what a recovery lane that did not merge tried and how it ended; the issue stays needs-human with this. */
|
|
348
|
+
const recoveryEvidence = (reason, code, detail) => `Recovery pass for \`${recover.code}\` (attempt ${recover.attempt}) did not merge: it ended \`${reason}\`${code ? ` (\`${code}\`)` : ""}${String(detail ?? "").trim() ? `: ${String(detail).trim().split("\n")[0].slice(0, 300)}` : ""}. The issue stays \`needs-human\`.`;
|
|
349
|
+
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
258
350
|
try {
|
|
259
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
|
|
351
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${recoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
260
352
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
261
353
|
} catch { /* best effort */ }
|
|
262
|
-
return finish("needs-human", why, { code }, code);
|
|
354
|
+
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
263
355
|
}
|
|
264
356
|
|
|
265
357
|
// ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
|
|
@@ -324,13 +416,25 @@ export async function runCore(o) {
|
|
|
324
416
|
}
|
|
325
417
|
}
|
|
326
418
|
|
|
327
|
-
/**
|
|
419
|
+
/** Verification worker cap for this call (#4182): an operator-set positive int wins, else cores / live lanes; null when unknown. */
|
|
420
|
+
function verifyParallelism() {
|
|
421
|
+
const set = String(process.env.FORGE_HOST_PARALLELISM ?? "").trim();
|
|
422
|
+
if (/^\d+$/.test(set) && Number(set) > 0) return set;
|
|
423
|
+
if (!io.host?.cores) return null;
|
|
424
|
+
try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
|
|
425
|
+
}
|
|
426
|
+
/** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
|
|
328
427
|
async function callRunner(args) {
|
|
329
428
|
let pauses = 0;
|
|
330
429
|
for (;;) {
|
|
331
430
|
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
332
431
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
333
|
-
|
|
432
|
+
const par = verifyParallelism(); // #4182
|
|
433
|
+
let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
|
|
434
|
+
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
435
|
+
// #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
|
|
436
|
+
const heavy = heavyEnv ? { ...heavyEnv, FORGE_LANE_PHASE: args.role === "builder" ? stage : String(args.role ?? "step"), FORGE_LANE_ISSUE: String(issue), FORGE_LANE_PRIORITY: String(lanePriority()) } : {};
|
|
437
|
+
try { return await io.runner({ ...args, env: { ...deps, FORGE_DOCKER_LABELS: labels, ...(runTmp ? { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
|
|
334
438
|
catch (e) {
|
|
335
439
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
336
440
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -366,11 +470,15 @@ export async function runCore(o) {
|
|
|
366
470
|
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
367
471
|
}
|
|
368
472
|
const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
|
|
473
|
+
const recoverNote = () => (recover ? `\n\nRecovery pass (${recover.code}): ${(RECOVER_HOW[recover.code] ?? "an earlier run ended needs-human for a mechanical reason; resolve it").replaceAll("BASE", lane)}.` : "");
|
|
369
474
|
const PLAN_RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD` to see what exists, but plan against the issue itself. This step is still planning only: do not edit code, do not commit, and do not write the result file unless the issue is invalid, already fixed or blocked.";
|
|
370
475
|
const SALVAGE_NOTE = "\n\nAn earlier run on this issue stopped with uncommitted work, saved at `REF` (not on the branch). Inspect it with `git diff HEAD REF --stat` and reuse what fits the contract (`git checkout REF -- <path>`) instead of rewriting it.";
|
|
371
476
|
const RERUN_NOTE = `\n\nThis is a rerun: the branch already holds commits from an earlier run. Read \`git log\` and \`git diff origin/BASE...HEAD\`, verify that work against the contract, complete what is missing, ${vcfg.mode === "ci" ? "re-run the light checks (the engine verifies on CI)" : "re-run every validate command"} and write the result file. Do not start over.`;
|
|
372
477
|
|
|
373
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume
|
|
480
|
+
* when under. Since #4169 the ceiling is the mem/tmp floors (load average only when configured); paused time is a wait.
|
|
481
|
+
*/
|
|
374
482
|
function startGovernor(control) {
|
|
375
483
|
if (!io.host || o.governor === false || o.admission?.enabled === false) return { stop() {} };
|
|
376
484
|
const pollMs = o.governorPollMs ?? 15000, maxPauseMs = o.maxPauseMs ?? 600000;
|
|
@@ -384,16 +492,19 @@ export async function runCore(o) {
|
|
|
384
492
|
try { ok = (await checkAdmission({ cfg: o.admission, host: io.host })).ok; } catch { ok = true; }
|
|
385
493
|
const nowMs = Date.now();
|
|
386
494
|
if (!ok && !paused && !forced && control.pause) { if (control.pause()) { paused = true; pausedAt = nowMs; ev({ event: "LANE_PAUSED", reason: "host over ceiling" }); onProgress({ event: "phase_paused", phase: "builder", reason: "host-busy", detail: "host over the admission ceiling; lane paused" }); } }
|
|
387
|
-
else if (ok && paused) { control.resume?.(); paused = false; forced = false; ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
|
|
495
|
+
else if (ok && paused) { control.resume?.(); paused = false; forced = false; addWait(stage, nowMs - pausedAt); ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
|
|
388
496
|
else if (ok) forced = false;
|
|
389
|
-
else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
|
|
497
|
+
else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; addWait(stage, nowMs - pausedAt); ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
|
|
390
498
|
}
|
|
391
499
|
})();
|
|
392
|
-
return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
|
|
500
|
+
return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; addWait(stage, Date.now() - pausedAt); } } };
|
|
393
501
|
}
|
|
394
502
|
|
|
395
503
|
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
396
|
-
|
|
504
|
+
let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
|
|
505
|
+
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
506
|
+
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
507
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
|
|
397
508
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
398
509
|
let timeoutRetries = 0, p = prompt;
|
|
399
510
|
for (;;) {
|
|
@@ -534,6 +645,7 @@ export async function runCore(o) {
|
|
|
534
645
|
if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
|
|
535
646
|
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
|
|
536
647
|
if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
|
|
648
|
+
if (o.placement?.where) T.decisions.push(`Placement: ${o.placement.where}${o.placement.reason ? ` (${o.placement.reason})` : ""}`);
|
|
537
649
|
}
|
|
538
650
|
/** Last guard before a PR is created or merged: the default branch is never a lane. */
|
|
539
651
|
const laneGuard = (target, what) => {
|
|
@@ -558,7 +670,8 @@ export async function runCore(o) {
|
|
|
558
670
|
} else if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
|
|
559
671
|
return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
|
|
560
672
|
}
|
|
561
|
-
leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false
|
|
673
|
+
leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false,
|
|
674
|
+
...(o.placement ? { placement: o.placement } : {}) }; // #4256: the latest placement replaces an earlier run's
|
|
562
675
|
try { await writeLease(leaseObj()); } catch { /* best effort */ }
|
|
563
676
|
leaseTimer = setInterval(() => { writeLease(leaseObj()).catch(() => {}); }, o.leaseRenewMs ?? 120000);
|
|
564
677
|
leaseTimer.unref?.();
|
|
@@ -577,9 +690,8 @@ export async function runCore(o) {
|
|
|
577
690
|
if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
|
|
578
691
|
}
|
|
579
692
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
580
|
-
let iss;
|
|
581
693
|
try {
|
|
582
|
-
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
|
|
694
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
583
695
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
584
696
|
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
585
697
|
|
|
@@ -587,6 +699,16 @@ export async function runCore(o) {
|
|
|
587
699
|
// ---- 1. claim ----
|
|
588
700
|
await label("investigating");
|
|
589
701
|
await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
|
|
702
|
+
if (recover) {
|
|
703
|
+
// #4267: the batch decided to recover this issue itself; the label goes so the issue is live again.
|
|
704
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", "needs-human"]); } catch { /* best effort */ }
|
|
705
|
+
recoverUnlabelled = true;
|
|
706
|
+
try {
|
|
707
|
+
await post("FORGE:DECISION", `## Decision: recovery pass for \`${recover.code}\` (attempt ${recover.attempt})\n**What**: removed \`needs-human\` and resumed the open PR / branch on \`${lane}\` (${(RECOVER_HOW[recover.code] ?? "rerun").split(".")[0]}).\n**Why**: \`${recover.code}\` is a mechanical outcome an agent can fix without operator input.\n**Alternative**: leave the issue \`needs-human\` and skip its dependents until the operator relaunches it.`);
|
|
708
|
+
} catch { /* best effort */ }
|
|
709
|
+
T.decisions.push(`Recovery pass for ${recover.code} (attempt ${recover.attempt}): needs-human removed, PR resumed`);
|
|
710
|
+
ev({ event: "RECOVERY_START", code: recover.code, attempt: recover.attempt });
|
|
711
|
+
}
|
|
590
712
|
|
|
591
713
|
// ---- 2. worktree (resume an open PR / remote branch) ----
|
|
592
714
|
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
@@ -621,10 +743,12 @@ export async function runCore(o) {
|
|
|
621
743
|
};
|
|
622
744
|
await leftoverDir();
|
|
623
745
|
const LOCK_RE = /could not lock|cannot lock ref|\.lock'?: File exists|Unable to create .*\.lock|index\.lock|packed-refs\.lock/i;
|
|
624
|
-
|
|
746
|
+
// #4221: tracked blobs >= sparseMaxKb stay out of the checkout (lockfiles/manifests always in); falls back to a full checkout.
|
|
747
|
+
const sparseMaxKb = Number.isFinite(o.sparseMaxKb) && o.sparseMaxKb >= 0 ? o.sparseMaxKb : DEFAULT_SPARSE_MAX_KB;
|
|
748
|
+
const addWorktree = async (args, ref) => {
|
|
625
749
|
let cleaned = false;
|
|
626
750
|
for (let i = 0; ; i++) {
|
|
627
|
-
try { return await io.git
|
|
751
|
+
try { return await addLaneWorktree(io.git, io.fs?.write, { cwd, wt, addArgs: args, ref, maxBlobBytes: sparseMaxKb * 1024 }); } catch (e) {
|
|
628
752
|
const msg = String(e?.stderr ?? e?.message ?? e);
|
|
629
753
|
if (!cleaned && /already exists/i.test(msg) && (await leftoverDir())) { cleaned = true; continue; }
|
|
630
754
|
if (i >= 4 || !LOCK_RE.test(msg)) throw e;
|
|
@@ -645,19 +769,105 @@ export async function runCore(o) {
|
|
|
645
769
|
}
|
|
646
770
|
} catch { /* no local branch */ }
|
|
647
771
|
ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
|
|
772
|
+
let wtInfo = null;
|
|
648
773
|
if (keepLocal) {
|
|
649
|
-
await addWorktree(["worktree", "add", wt, branch]);
|
|
774
|
+
wtInfo = await addWorktree(["worktree", "add", wt, branch], `refs/heads/${branch}`);
|
|
650
775
|
resumed = resumed ?? { pr: null, local: true };
|
|
651
776
|
ev({ event: "LOCAL_BRANCH_REUSED", branch });
|
|
652
777
|
} else if (resumed) {
|
|
653
|
-
await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
|
|
778
|
+
wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`], `origin/${branch}`);
|
|
654
779
|
} else {
|
|
655
|
-
await addWorktree(["worktree", "add", "-B", branch, wt, base]);
|
|
780
|
+
wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
|
|
656
781
|
}
|
|
657
782
|
wtCreated = true;
|
|
783
|
+
if (wtInfo?.sparse) {
|
|
784
|
+
sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
|
|
785
|
+
ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
|
|
786
|
+
T.decisions.push(`Sparse worktree: ${wtInfo.excluded} tracked file(s) >= ${sparseMaxKb} KB (${Math.round(wtInfo.bytes / 1048576)} MB) left out of the checkout`);
|
|
787
|
+
} else if (wtInfo?.fallback) {
|
|
788
|
+
ev({ event: "WORKTREE_SPARSE_FALLBACK", reason: wtInfo.fallback });
|
|
789
|
+
T.anomalies.push(`Sparse checkout failed, full checkout used: ${wtInfo.fallback}`);
|
|
790
|
+
}
|
|
658
791
|
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
659
792
|
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
660
|
-
|
|
793
|
+
// #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
|
|
794
|
+
if (io.deps?.prepare) {
|
|
795
|
+
let res;
|
|
796
|
+
try { res = await io.deps.prepare({ wt, repoKey: repoKey(cwd) }); } catch (e) { res = [{ status: "failed", eco: null, key: null, ms: 0, reason: String(e?.message ?? e).slice(0, 300) }]; }
|
|
797
|
+
for (const r of Array.isArray(res) ? res : []) {
|
|
798
|
+
ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
|
|
799
|
+
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` : ""}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// ---- 2b. toolchain pins + env.setup (#4163): before the builder; zero LLM. No pins and no setup changes nothing ----
|
|
803
|
+
try {
|
|
804
|
+
const tc = await (io.prepareToolchain ?? prepareToolchain)({ dir: wt, override: toolchainOverride, read: io.fs.read,
|
|
805
|
+
binDir: join(runTmp ?? join(wt, LEAN_DIR), "toolchain", "bin") });
|
|
806
|
+
if (tc?.summary) {
|
|
807
|
+
toolchain = { env: tc.env ?? null, summary: tc.summary };
|
|
808
|
+
ev({ event: "TOOLCHAIN_PINNED", pins: tc.summary, shims: tc.shims });
|
|
809
|
+
T.decisions.push(tc.env ? `Toolchain pinned on the runner PATH: ${tc.summary}` : `Toolchain pins detected (no shim): ${tc.summary}`);
|
|
810
|
+
}
|
|
811
|
+
if (tc?.warning) { T.anomalies.push(tc.warning); ev({ event: "TOOLCHAIN_WARNING", warning: tc.warning }); }
|
|
812
|
+
for (const n of tc?.notes ?? []) T.anomalies.push(n);
|
|
813
|
+
} catch (e) { T.anomalies.push(`Toolchain detection failed (host toolchain used): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
814
|
+
// ---- 2c. heavy-step shims (#4169): after the toolchain, so a heavy shim's real binary is the pinned toolchain shim ----
|
|
815
|
+
if (heavyOn) {
|
|
816
|
+
try {
|
|
817
|
+
const root = runTmp ?? join(wt, LEAN_DIR), binDir = join(root, "heavy", "bin"), path = toolchain?.env?.PATH ?? process.env.PATH ?? "";
|
|
818
|
+
const names = await (io.writeHeavyShims ?? writeHeavyShims)(binDir, { path });
|
|
819
|
+
if (names?.length) {
|
|
820
|
+
heavyWaitsFile = join(root, "heavy-waits.jsonl");
|
|
821
|
+
const pinned = /^\d+$/.test(String(process.env.FORGE_HOST_PARALLELISM ?? "").trim()) && Number(process.env.FORGE_HOST_PARALLELISM) > 0;
|
|
822
|
+
heavyEnv = { PATH: path ? `${binDir}${delimiter}${path}` : binDir, FORGE_HEAVY_WAITS: heavyWaitsFile, ...(pinned ? { FORGE_HEAVY_PARALLELISM_PINNED: "1" } : {}),
|
|
823
|
+
...Object.fromEntries([["FORGE_HEAVY_MAX", heavyCfg.max], ["FORGE_HEAVY_STEP_MEM_MB", heavyCfg.step_mem_mb], ["FORGE_HEAVY_RESERVE_MB", heavyCfg.reserve_mb]].filter(([, v]) => Number(v) > 0).map(([k, v]) => [k, String(v)])) };
|
|
824
|
+
ev({ event: "HEAVY_SHIMS", names });
|
|
825
|
+
}
|
|
826
|
+
} catch (e) { T.anomalies.push(`Heavy-step shims not written (builder steps ungated): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
827
|
+
}
|
|
828
|
+
if (typeof envSetup === "string" && envSetup.trim()) {
|
|
829
|
+
let r;
|
|
830
|
+
if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
|
|
831
|
+
else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: { ...(runTmp ? { TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}) } })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
|
|
832
|
+
ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
|
|
833
|
+
if (r?.exit !== 0) {
|
|
834
|
+
const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
|
|
835
|
+
T.anomalies.push(`Environment error: forge.yaml env.setup exited ${r?.exit ?? "?"}; the builder did not run`);
|
|
836
|
+
return await needsHuman(`Environment error (not a build failure): forge.yaml \`env.setup\` (\`${envSetup.slice(0, 200)}\`) exited ${r?.exit ?? "?"} in the fresh worktree, so the builder was not started. Fix the setup command or the environment, then rerun.${tail ? `\n\n\`\`\`\n${tail}\n\`\`\`` : ""}`, OUTCOME_CODES.ENV_SETUP_FAILED);
|
|
837
|
+
}
|
|
838
|
+
T.decisions.push("forge.yaml env.setup ran once in the fresh worktree");
|
|
839
|
+
}
|
|
840
|
+
const names = (out) => String(out).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
841
|
+
// #4160: files the branch touched (three-dot) whose content still differs from the base tip (two-dot); a file merged
|
|
842
|
+
// in from the base by a base-sync equals the base and drops out of the scope check.
|
|
843
|
+
const changedFiles = async () => {
|
|
844
|
+
const touched = names(await git(["diff", "--name-only", `${base}...HEAD`]));
|
|
845
|
+
if (!touched.length) return touched;
|
|
846
|
+
const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
847
|
+
return touched.filter((f) => differs.has(f));
|
|
848
|
+
};
|
|
849
|
+
/** forge#4195: scripts/hotspots.sh facts in the worktree, or null (no io.hotspots, script failure): never fails a run. */
|
|
850
|
+
const hotspotsOf = async (args) => {
|
|
851
|
+
if (!io.hotspots) return null;
|
|
852
|
+
const h = await io.hotspots({ cwd: wt, ...args }).catch(() => null);
|
|
853
|
+
return h && typeof h === "object" && Array.isArray(h.files) ? h : null;
|
|
854
|
+
};
|
|
855
|
+
/** #4271: a bug lane with lean.repro_test on; refactor:split lanes never set it (no plan call, no repro_test). */
|
|
856
|
+
const reproOn = () => reproTest !== "off" && isBugIssue(iss?.labels);
|
|
857
|
+
let reproNoted = false;
|
|
858
|
+
/** #4271: the frozen repro_test's red-green words for precheck, or null (gate off, "none", no io.redGreen). */
|
|
859
|
+
const reproOf = async (c) => {
|
|
860
|
+
const t = c?.repro_test;
|
|
861
|
+
if (!reproOn() || !io.redGreen || !t || typeof t !== "object") {
|
|
862
|
+
if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
|
|
866
|
+
const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
867
|
+
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
868
|
+
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
869
|
+
return words;
|
|
870
|
+
};
|
|
661
871
|
|
|
662
872
|
// ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
|
|
663
873
|
let result = null, attemptN = 0, reviewN = 0;
|
|
@@ -669,18 +879,45 @@ export async function runCore(o) {
|
|
|
669
879
|
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
670
880
|
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
671
881
|
});
|
|
672
|
-
|
|
673
|
-
|
|
882
|
+
// #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
|
|
883
|
+
const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
|
|
884
|
+
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
|
|
885
|
+
let trustedComments = trustedOf(iss), issueEdited = false;
|
|
674
886
|
let issueText = textOf(iss, trustedComments);
|
|
887
|
+
let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
|
|
888
|
+
ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
|
|
889
|
+
/**
|
|
890
|
+
* forge#4275: compile (or read from the cache) the context pack, log CONTEXT_PACK and post FORGE:CONTEXT. Off, no
|
|
891
|
+
* io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
|
|
892
|
+
*/
|
|
893
|
+
async function contextPackStep() {
|
|
894
|
+
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
895
|
+
try {
|
|
896
|
+
const headSha = String(await git(["rev-parse", base])).trim();
|
|
897
|
+
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { updatedAt: iss.updatedAt ?? null, attempts: priorAttempts(iss.body, trustedComments) },
|
|
898
|
+
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
899
|
+
const p = r.pack;
|
|
900
|
+
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
901
|
+
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
902
|
+
ev(rec); await emit(rec);
|
|
903
|
+
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
904
|
+
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
905
|
+
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"}`);
|
|
906
|
+
return p;
|
|
907
|
+
} catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
|
|
908
|
+
}
|
|
675
909
|
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
676
910
|
async function refreshIssue(stage) {
|
|
677
911
|
let next;
|
|
678
|
-
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]))); } catch { return; }
|
|
912
|
+
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]))); } catch { return; }
|
|
679
913
|
if (!next || typeof next !== "object") return;
|
|
680
914
|
const tc = trustedOf(next), text = textOf(next, tc);
|
|
681
|
-
|
|
915
|
+
if (contractHash(issueText) === contractHash(text)) return;
|
|
916
|
+
// #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
|
|
917
|
+
const old = scopeHash, now = contractHash(scopeText(next, tc));
|
|
918
|
+
iss = next; trustedComments = tc; issueText = text; scopeHash = now;
|
|
682
919
|
if (old === now) return;
|
|
683
|
-
|
|
920
|
+
issueEdited = true;
|
|
684
921
|
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
685
922
|
T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
|
|
686
923
|
}
|
|
@@ -712,82 +949,208 @@ export async function runCore(o) {
|
|
|
712
949
|
}
|
|
713
950
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
714
951
|
}
|
|
715
|
-
/**
|
|
952
|
+
/**
|
|
953
|
+
* The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
|
|
954
|
+
* (#4265): a trusted comment that the engine did not post came after it.
|
|
955
|
+
*/
|
|
716
956
|
async function readFrozenContract() {
|
|
717
957
|
let out = "";
|
|
718
958
|
try { out = String(await ghc(["api", "--paginate", `repos/${repo}/issues/${issue}/comments`, "--jq", ".[] | {body, author_association, user: {type: .user.type, login: .user.login}}"])); } catch { return null; }
|
|
719
959
|
let found = null;
|
|
720
960
|
for (const l of out.split("\n")) {
|
|
721
961
|
let c; try { c = JSON.parse(l); } catch { continue; }
|
|
722
|
-
if (!
|
|
723
|
-
const
|
|
724
|
-
if (
|
|
962
|
+
if (!isTrustedComment(c)) continue;
|
|
963
|
+
const b = String(c?.body ?? "");
|
|
964
|
+
if (!b.startsWith("<!-- FORGE:CONTRACT -->")) { if (found && b.trim() && !b.trimStart().startsWith("<!-- FORGE:")) found.humanAfter = true; continue; }
|
|
965
|
+
const fc = parseContractComment(b);
|
|
966
|
+
if (fc && contractHash(fc.text) === fc.hash) found = { ...fc, humanAfter: false };
|
|
725
967
|
}
|
|
726
968
|
return found;
|
|
727
969
|
}
|
|
728
970
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
971
|
+
const isSplit = (iss.labels ?? []).some((l) => (l?.name ?? l) === SPLIT_LABEL);
|
|
972
|
+
/**
|
|
973
|
+
* #4211: the refactor:split step. One tool-less grouping call over the symbol index (none for a chained plan), the
|
|
974
|
+
* codemod move and its deterministic proof (bin/lean/refactor.mjs). A rerun on a branch that already holds the move
|
|
975
|
+
* re-verifies it instead. Returns {done} (needs-human, worktree left at its start commit) or the split result with a
|
|
976
|
+
* synthetic location-only contract for the renderers.
|
|
977
|
+
*/
|
|
978
|
+
async function splitStep() {
|
|
979
|
+
await label("building");
|
|
980
|
+
const mod = o.refactorModule ?? (await import("./refactor.mjs"));
|
|
981
|
+
const body = String(iss.body ?? "");
|
|
982
|
+
const precheckList = Array.isArray(o.precheck?.commands) ? o.precheck.commands : [];
|
|
983
|
+
let r;
|
|
984
|
+
if (hasCommits) {
|
|
985
|
+
const target = mod.parseSplitTarget(body) ?? mod.parseSplitPlan(body)?.target, testCmd = mod.parseTestCmd(body, precheckList);
|
|
986
|
+
r = target && testCmd ? await heavyStep("split", () => mod.reverifySplit({ wt, base, target, testCmd, runTmp, git })) : { ok: false, code: OUTCOME_CODES.SPLIT_PLAN_INVALID, reason: "rerun of a split branch without a target or test command in the issue" };
|
|
987
|
+
if (r.ok) {
|
|
988
|
+
r = { ...r, target, testCmd, package: null, modules: {}, rest: null, grouped: false };
|
|
989
|
+
T.anomalies.push("Rerun of a split branch: the existing move was re-verified; a chained remainder is not recomputed");
|
|
990
|
+
}
|
|
991
|
+
} else {
|
|
992
|
+
let slot = await heavyAcquire("split"); // the grouper's LLM call gives the slot back while it runs (#4169)
|
|
993
|
+
try {
|
|
994
|
+
r = await mod.runSplitStep({ wt, base, issue, issueBody: body, precheckCommands: precheckList, runTmp, git, ...(o.splitMaxLines ? { maxLines: o.splitMaxLines } : {}),
|
|
995
|
+
callGrouper: async (prompt, { cwd } = {}) => {
|
|
996
|
+
try { slot?.release?.(); } catch { /* fail-open */ }
|
|
997
|
+
slot = null;
|
|
998
|
+
try {
|
|
999
|
+
await renew();
|
|
1000
|
+
const rr = await callRunner({ role: "grouper", issue, cwd: cwd ?? runTmp ?? wt, model: reviewerModel, resume: null, system: GROUPING_SYSTEM, prompt, noTools: true });
|
|
1001
|
+
await renew();
|
|
1002
|
+
await attempt("group", 1, rr);
|
|
1003
|
+
return rr?.text ?? "";
|
|
1004
|
+
} finally { slot = await heavyAcquire("split"); }
|
|
1005
|
+
} });
|
|
1006
|
+
} finally { try { slot?.release?.(); } catch { /* fail-open */ } }
|
|
1007
|
+
}
|
|
1008
|
+
ev({ event: "SPLIT", ok: r.ok, code: r.code ?? null, grouped: Boolean(r.grouped), modules: Object.keys(r.modules ?? {}).length, rest: Object.keys(r.rest ?? {}).length });
|
|
1009
|
+
if (!r.ok) {
|
|
1010
|
+
T.validate = ["SPLIT REFUSED", r.code]; T.plan = "skipped"; T.implement = "none (worktree reset)"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
1011
|
+
return { done: await needsHuman(`refactor:split refused (${r.code}); nothing was pushed:\n${r.reason}`, r.code) };
|
|
1012
|
+
}
|
|
1013
|
+
const moved = await changedFiles();
|
|
1014
|
+
const contract = { requirements: [], deliverables: moved.map((path, i) => ({ id: `D${i + 1}`, path, change: "location-only: moved verbatim or re-export shim", requirements: [] })), preserve: [], validate: [] };
|
|
1015
|
+
const mods = Object.entries(r.modules ?? {});
|
|
1016
|
+
try {
|
|
1017
|
+
await post("FORGE:SPLIT", `## refactor:split\n**Target:** \`${r.target}\`\n**Package:** \`${r.package ?? "(rerun)"}\`\n**Modules:** ${mods.map(([m, l]) => `\`${m}\` (${l.length})`).join(", ") || "n/a"}\n**Plan:** ${r.grouped ? "one grouping call over the symbol index" : "no LLM call"}\n**Remaining:** ${Object.keys(r.rest ?? {}).length} module(s)\n\n\`\`\`json\n${JSON.stringify(r.verify ?? {})}\n\`\`\``);
|
|
1018
|
+
} catch { /* best effort */ }
|
|
1019
|
+
T.validate = ["CONFIRMED", `refactor:split \`${r.target}\``];
|
|
1020
|
+
T.decisions.push(`refactor:split: ${r.grouped ? "one grouping call" : "no LLM call"}, ${mods.length} module(s) into ${r.package ?? "(rerun)"}, refactor-verify ok${r.rest ? `, ${Object.keys(r.rest).length} module(s) chained` : ""}`);
|
|
1021
|
+
T.implement = [`${moved.length} file(s)`, branch];
|
|
1022
|
+
sessionId = null;
|
|
1023
|
+
return { ...r, contract };
|
|
733
1024
|
}
|
|
734
|
-
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1025
|
+
let frozen = null, hash = null, planned = false, files = [], split = null;
|
|
1026
|
+
if (isSplit) {
|
|
1027
|
+
// ---- 3'. refactor:split (#4211): deterministic move + proof replaces the builder; no builder session exists ----
|
|
1028
|
+
split = await splitStep();
|
|
1029
|
+
if (split.done) return split.done;
|
|
1030
|
+
frozen = split.contract; hash = contractHash(contractJson(frozen));
|
|
1031
|
+
files = await changedFiles();
|
|
1032
|
+
} else {
|
|
1033
|
+
if (resumed) {
|
|
1034
|
+
const fc = await readFrozenContract();
|
|
1035
|
+
if (fc) {
|
|
1036
|
+
// #4265: a contract planned from an older issue scope is stale: re-plan and freeze a new one instead of judging against it
|
|
1037
|
+
await refreshIssue("rerun contract check");
|
|
1038
|
+
const gone = (Array.isArray(fc.contract?.requirements) ? fc.contract.requirements : []).filter((r) => r?.disposition === "deliver" && !inIssue(r.quote, issueText)).map((r) => r.id);
|
|
1039
|
+
const stale = fc.scope ? (fc.scope !== scopeHash ? `issue scope ${fc.scope} -> ${scopeHash}` : null)
|
|
1040
|
+
: fc.humanAfter ? "a human comment was posted after it" : gone.length ? `quote(s) no longer in the issue: ${gone.join(", ")}` : null;
|
|
1041
|
+
if (stale) {
|
|
1042
|
+
ev({ event: "CONTRACT_STALE", hash: fc.hash, scope: fc.scope, now: scopeHash, reason: stale });
|
|
1043
|
+
T.decisions.push(`Rerun: frozen contract ${fc.hash} is stale (${stale}); re-planning`);
|
|
1044
|
+
} else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
1045
|
+
}
|
|
750
1046
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
await
|
|
755
|
-
|
|
756
|
-
if (!
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1047
|
+
await label("building");
|
|
1048
|
+
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1049
|
+
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1050
|
+
const ctxPack = frozen ? null : await contextPackStep();
|
|
1051
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1052
|
+
if (!frozen) {
|
|
1053
|
+
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
1054
|
+
planned = true;
|
|
1055
|
+
result = await readResult();
|
|
1056
|
+
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
1057
|
+
const closes = ["invalid", "already-fixed"].includes(result?.status);
|
|
1058
|
+
if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
|
|
1059
|
+
T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
|
|
1060
|
+
if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
|
|
1061
|
+
if (closes) {
|
|
1062
|
+
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
1063
|
+
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
1064
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
1065
|
+
await label("invalid");
|
|
1066
|
+
return await finish("invalid", result.summary ?? result.status);
|
|
1067
|
+
}
|
|
1068
|
+
const blocked = await endIfBlocked();
|
|
1069
|
+
if (blocked) return blocked;
|
|
1070
|
+
let contract = await readContract();
|
|
760
1071
|
await refreshIssue("contract validation");
|
|
761
|
-
vc = validateContract(contract, issueText);
|
|
1072
|
+
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1073
|
+
if (!vc.ok && sessionId) {
|
|
1074
|
+
T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
|
|
1075
|
+
await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, planPrompt);
|
|
1076
|
+
contract = await readContract();
|
|
1077
|
+
await refreshIssue("contract validation");
|
|
1078
|
+
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1079
|
+
}
|
|
1080
|
+
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
1081
|
+
if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
|
|
1082
|
+
frozen = contract;
|
|
1083
|
+
hash = contractHash(contractJson(frozen));
|
|
1084
|
+
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1085
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
|
|
1086
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
1087
|
+
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
1088
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
1089
|
+
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
1090
|
+
} else {
|
|
1091
|
+
T.validate = "resumed (existing branch)";
|
|
762
1092
|
}
|
|
763
|
-
|
|
764
|
-
if (
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1093
|
+
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1094
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1095
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
|
|
1096
|
+
liveKeys = ["implementation"];
|
|
1097
|
+
stage = "implement";
|
|
1098
|
+
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
1099
|
+
stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
|
|
1100
|
+
result = await readResult();
|
|
1101
|
+
{
|
|
1102
|
+
const blocked = await endIfBlocked();
|
|
1103
|
+
if (blocked) return blocked;
|
|
1104
|
+
}
|
|
1105
|
+
files = await changedFiles();
|
|
1106
|
+
if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
|
|
1107
|
+
await flushStages(result, ["implementation"]);
|
|
1108
|
+
T.implement = [`${files.length} file(s)`, branch];
|
|
1109
|
+
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
775
1110
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1111
|
+
|
|
1112
|
+
// ---- 3a. declared check commands (#4240): the repo's own cheap gates (forge.yaml learned.test_commands), run locally in
|
|
1113
|
+
// the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
|
|
1114
|
+
// verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
|
|
1115
|
+
let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
|
|
1116
|
+
// #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
|
|
1117
|
+
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1118
|
+
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1119
|
+
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1120
|
+
const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return m.length ? { commands: m, source: "test-map" } : { commands: learnedCommands, source: "learned" }; };
|
|
1121
|
+
let picked = pickChecks();
|
|
1122
|
+
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1123
|
+
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1124
|
+
if (picked.source === "test-map") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.commands.length} of ${testMap.entries.length} entries`);
|
|
1125
|
+
if (picked.commands.length && io.runCommand) {
|
|
1126
|
+
for (let round = 0; ; round++) {
|
|
1127
|
+
await renew();
|
|
1128
|
+
if (round) picked = pickChecks();
|
|
1129
|
+
const precheckCommands = picked.commands;
|
|
1130
|
+
const results = [];
|
|
1131
|
+
for (const command of precheckCommands) {
|
|
1132
|
+
let r;
|
|
1133
|
+
try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1134
|
+
results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
|
|
1135
|
+
}
|
|
1136
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source === "test-map" ? { source: "test-map" } : {}) });
|
|
1137
|
+
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1138
|
+
const failed = results.filter((r) => r.rc !== 0);
|
|
1139
|
+
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
1140
|
+
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1141
|
+
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);
|
|
1142
|
+
checkCommandsFailing = true;
|
|
1143
|
+
if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
|
|
1144
|
+
T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1145
|
+
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1146
|
+
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
1147
|
+
await builder(++attemptN, precheckFailurePrompt(capped, vcfg.mode), sessionId);
|
|
1148
|
+
await upsertStage("implementation");
|
|
1149
|
+
result = (await readResult()) ?? result;
|
|
1150
|
+
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; }
|
|
1151
|
+
files = await changedFiles();
|
|
1152
|
+
}
|
|
785
1153
|
}
|
|
786
|
-
let files = await changedFiles();
|
|
787
|
-
if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
|
|
788
|
-
await flushStages(result, ["implementation"]);
|
|
789
|
-
T.implement = [`${files.length} file(s)`, branch];
|
|
790
|
-
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
791
1154
|
|
|
792
1155
|
// ---- 3b. structure delta (forge#4159): deterministic, before the conformance review; no reviewer run of its own ----
|
|
793
1156
|
// A package.json edge between workspace packages the contract does not name blocks (one builder resume) in either
|
|
@@ -802,8 +1165,7 @@ export async function runCore(o) {
|
|
|
802
1165
|
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
803
1166
|
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
804
1167
|
import_files_added: sum("files_added"), import_files_removed: sum("files_removed"), boundary_changed: Boolean(structure.boundary_config_changed?.changed), boundary_loosened: Boolean(structure.boundary_config_changed?.loosens) };
|
|
805
|
-
ev(rec);
|
|
806
|
-
await emit({ ...rec, phase: "implement" });
|
|
1168
|
+
ev(rec); // run log only: the telemetry write is the merge-time record (forge#4165)
|
|
807
1169
|
const undeclared = () => unnamedEdges(applyAmendments(frozen, result?.amendments, issueText).contract, structure);
|
|
808
1170
|
let edges = undeclared();
|
|
809
1171
|
if (edges.length && sessionId) {
|
|
@@ -814,10 +1176,28 @@ export async function runCore(o) {
|
|
|
814
1176
|
structure = (await structureOf()) ?? structure;
|
|
815
1177
|
edges = undeclared();
|
|
816
1178
|
}
|
|
1179
|
+
verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
|
|
817
1180
|
if (edges.length) {
|
|
818
1181
|
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);
|
|
819
1182
|
}
|
|
820
1183
|
}
|
|
1184
|
+
// ---- 3c. the target repo's devdocs freshness rule, when it has one: deterministic, before any PR, so a lane does not
|
|
1185
|
+
// pay for a failed required check. Exit 1 resumes the builder once; still failing is needs-human docs.stale.
|
|
1186
|
+
if (io.docsFreshness) {
|
|
1187
|
+
let docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1188
|
+
if (docs && !docs.ok && sessionId) {
|
|
1189
|
+
ev({ event: "DOCS_STALE", round: 1 });
|
|
1190
|
+
T.decisions.push("Docs freshness check failed; builder resumed once");
|
|
1191
|
+
await builder(++attemptN, docsPrompt(docs.output), sessionId);
|
|
1192
|
+
await upsertStage("implementation");
|
|
1193
|
+
result = (await readResult()) ?? result;
|
|
1194
|
+
docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1195
|
+
}
|
|
1196
|
+
verifyFacts.docs = docs;
|
|
1197
|
+
if (docs && !docs.ok) {
|
|
1198
|
+
return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
821
1201
|
const structureLine = structureNote(structure);
|
|
822
1202
|
|
|
823
1203
|
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
@@ -851,6 +1231,7 @@ export async function runCore(o) {
|
|
|
851
1231
|
return { done: await finish("ci-pending", `CI verification of ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
|
|
852
1232
|
}
|
|
853
1233
|
if (v.result === "unavailable") {
|
|
1234
|
+
if (isSplit) return { done: await needsHuman(`CI verification unavailable for the refactor:split move (${v.detail}); a split never falls back to builder validation; nothing merged.`) };
|
|
854
1235
|
ciFellBack = true;
|
|
855
1236
|
row("unavailable: local fallback");
|
|
856
1237
|
T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
|
|
@@ -867,6 +1248,7 @@ export async function runCore(o) {
|
|
|
867
1248
|
}
|
|
868
1249
|
ciRounds++; fixes++;
|
|
869
1250
|
T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1251
|
+
ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
|
|
870
1252
|
await builder(++attemptN, ciFailurePrompt(v.failures, vcfg.mode), sessionId);
|
|
871
1253
|
await upsertStage("implementation");
|
|
872
1254
|
result = (await readResult()) ?? result;
|
|
@@ -880,9 +1262,12 @@ export async function runCore(o) {
|
|
|
880
1262
|
// enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
|
|
881
1263
|
// but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression),
|
|
882
1264
|
// plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
|
|
1265
|
+
// A review without usable rows ends needs-human (or, with engine.review_incomplete: degraded, merges flagged).
|
|
883
1266
|
await label("in-review");
|
|
884
1267
|
const shadow = conformance !== "enforce";
|
|
885
|
-
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
|
|
1268
|
+
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false;
|
|
1269
|
+
const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
|
|
1270
|
+
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).";
|
|
886
1271
|
async function amend() {
|
|
887
1272
|
const a = applyAmendments(frozen, result?.amendments, issueText);
|
|
888
1273
|
const sig = JSON.stringify(a.applied);
|
|
@@ -900,21 +1285,24 @@ export async function runCore(o) {
|
|
|
900
1285
|
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
901
1286
|
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
902
1287
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
903
|
-
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
|
|
904
|
-
let d = null,
|
|
905
|
-
const skeleton = reviewSkeleton({ contract: effective, changedFiles: files, ignore: IGNORE }); // #4160: every required row, pre-filled
|
|
1288
|
+
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
|
|
1289
|
+
let d = null, first = null;
|
|
906
1290
|
for (let t = 0; t < 2; t++) {
|
|
1291
|
+
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
1292
|
+
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
907
1293
|
await renew();
|
|
908
1294
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
909
|
-
prompt:
|
|
1295
|
+
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
1296
|
+
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
|
|
910
1297
|
await renew();
|
|
911
1298
|
await attempt("review", ++reviewN, rr);
|
|
912
|
-
|
|
1299
|
+
const parsed = parseReview(rr?.text);
|
|
1300
|
+
review = repair ? mergeReviewRows(first, parsed) : parsed;
|
|
1301
|
+
first = review;
|
|
913
1302
|
d = decide(review, ctx);
|
|
1303
|
+
if (d.review) review = d.review;
|
|
914
1304
|
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
915
1305
|
if (!d.state.startsWith("review.")) return d;
|
|
916
|
-
// #4160: the second run is told exactly what was missing, instead of getting the same prompt again
|
|
917
|
-
retry = d.state === "review.incomplete" ? { problems: d.failures.map((x) => x.detail), previous: review ? JSON.stringify(review) : String(rr?.text ?? "") } : null;
|
|
918
1306
|
}
|
|
919
1307
|
return d;
|
|
920
1308
|
}
|
|
@@ -925,64 +1313,132 @@ export async function runCore(o) {
|
|
|
925
1313
|
files = await changedFiles();
|
|
926
1314
|
const amendProblems = await amend();
|
|
927
1315
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
928
|
-
const
|
|
1316
|
+
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1317
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
|
|
1318
|
+
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
929
1319
|
const pre = [...pc.failures, ...amendProblems];
|
|
930
1320
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
931
1321
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
932
1322
|
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
933
|
-
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.
|
|
934
|
-
if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard };
|
|
1323
|
+
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
|
|
1324
|
+
if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
|
|
1325
|
+
// #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
|
|
1326
|
+
if (triggerMode !== "off") {
|
|
1327
|
+
let t = null;
|
|
1328
|
+
try { t = computeTrigger({ files, riskPaths, structure, hotspots, gates: { precheckFailures: pre, checkCommandsFailing, ciFellBack }, contract: effective }); } catch { /* fall back to reviewing */ }
|
|
1329
|
+
const skip = triggerMode === "enforce" && t?.would_skip === true;
|
|
1330
|
+
ev({ event: "REVIEW_TRIGGER", mode: triggerMode, would_skip: t ? t.would_skip : false, reasons: t ? t.reasons.map((r) => r.code) : ["trigger.error"], skipped: skip });
|
|
1331
|
+
if (skip) {
|
|
1332
|
+
triggerSkipped = true;
|
|
1333
|
+
return { state: "conforms", by: "trigger", failures: [], ...(shadow ? { would: "conforms", wouldFail: [], reviewState: null } : {}) };
|
|
1334
|
+
}
|
|
1335
|
+
triggerSkipped = false;
|
|
1336
|
+
}
|
|
935
1337
|
const d = await reviewOnce();
|
|
936
1338
|
if (!shadow) return { ...d, by: "review" };
|
|
937
1339
|
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
938
1340
|
const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
|
|
939
1341
|
const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
|
|
1342
|
+
if (d.state.startsWith("review.")) return { ...d, by: "review", would, wouldFail, reviewState: d.state };
|
|
940
1343
|
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
941
|
-
return { state:
|
|
1344
|
+
return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
942
1345
|
}
|
|
943
|
-
|
|
1346
|
+
const verifyReported = new Set();
|
|
1347
|
+
/**
|
|
1348
|
+
* #4270 verify.check_run: one `forgedock/verify` check run per pushed head, reporting the gates' last results (no gate
|
|
1349
|
+
* logic, never blocks). Off: no call, no event. A missing seam or a Checks API failure is an anomaly, never a lane failure.
|
|
1350
|
+
*/
|
|
1351
|
+
async function reportVerify() {
|
|
1352
|
+
if (vcfg.checkRun !== true) return;
|
|
1353
|
+
let head = "";
|
|
1354
|
+
try { head = String(await git(["rev-parse", "HEAD"])).trim(); } catch { return; }
|
|
1355
|
+
if (!head || verifyReported.has(head)) return;
|
|
1356
|
+
verifyReported.add(head);
|
|
1357
|
+
const r = verifyReport({ head, ...verifyFacts, contract: effective, result });
|
|
1358
|
+
let posted = false, url = null, error = null;
|
|
1359
|
+
try {
|
|
1360
|
+
if (!io.createCheckRun) throw new Error("no Checks API seam");
|
|
1361
|
+
const c = await io.createCheckRun({ sha: head, name: VERIFY_CHECK_NAME, conclusion: r.conclusion, title: r.title, summary: r.summary, text: r.text });
|
|
1362
|
+
posted = true; url = c?.html_url ?? null;
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
error = String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 200);
|
|
1365
|
+
T.anomalies.push(`${VERIFY_CHECK_NAME} check run not posted for ${head.slice(0, 12)}: ${error}`);
|
|
1366
|
+
}
|
|
1367
|
+
ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
|
|
1368
|
+
}
|
|
1369
|
+
/** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
|
|
944
1370
|
const doReview = async () => {
|
|
945
1371
|
const d = await gate();
|
|
946
|
-
if (d.state.startsWith("review.")) return
|
|
1372
|
+
if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
|
|
947
1373
|
return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
|
|
948
1374
|
};
|
|
949
|
-
|
|
1375
|
+
let degradedReq = null;
|
|
1376
|
+
if (isSplit) {
|
|
1377
|
+
// #4211: location-only change proved by refactor-verify; no conformance reviewer (the optional review is not spent)
|
|
950
1378
|
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
951
|
-
|
|
952
|
-
verdict = outcome;
|
|
953
|
-
if (outcome.state === "conforms") break;
|
|
954
|
-
const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
955
|
-
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
956
|
-
if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
|
|
957
|
-
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
958
|
-
if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
|
|
959
|
-
resumes++;
|
|
960
|
-
T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
|
|
961
|
-
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
|
|
962
|
-
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
963
|
-
}
|
|
964
|
-
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
965
|
-
const toFile = pendingTracking(effective);
|
|
966
|
-
if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
|
|
967
|
-
if (shadow) {
|
|
968
|
-
const wf = verdict.wouldFail ?? [];
|
|
969
|
-
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
970
|
-
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
971
|
-
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
972
|
-
await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
|
|
1379
|
+
T.review = "skipped (refactor-verify passed: location-only change)";
|
|
973
1380
|
} else {
|
|
974
|
-
|
|
975
|
-
|
|
1381
|
+
for (;;) {
|
|
1382
|
+
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
1383
|
+
const outcome = await gate();
|
|
1384
|
+
verdict = outcome;
|
|
1385
|
+
for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
|
|
1386
|
+
if (outcome.state === "conforms") break;
|
|
1387
|
+
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1388
|
+
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1389
|
+
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1390
|
+
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1391
|
+
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
1392
|
+
T.decisions.push(`Review degraded (${outcome.state} after the targeted re-ask${degraded.missing.length ? `: ${degraded.missing.join(", ")}` : ""}); merging with label ${REVIEW_DEGRADED_LABEL} and a tracked follow-up`);
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
|
|
1396
|
+
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
1397
|
+
if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
|
|
1398
|
+
resumes++;
|
|
1399
|
+
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`);
|
|
1400
|
+
// A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
|
|
1401
|
+
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 */ } }
|
|
1402
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode, lane), sessionId);
|
|
1403
|
+
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
1404
|
+
}
|
|
1405
|
+
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
1406
|
+
const toFile = pendingTracking(effective);
|
|
1407
|
+
if (toFile.length) await track(toFile, "accepted by the conformance review");
|
|
1408
|
+
degradedReq = degraded ? { id: "REVIEW", quote: `conformance review ${degraded.state}: re-run the review`, disposition: "defer", why: `${REVIEW_DEGRADED_LABEL}: rows still missing for ${degraded.missing.join(", ") || "the verdict"}` } : null;
|
|
1409
|
+
if (degradedReq) await track([degradedReq], "the conformance review stayed incomplete");
|
|
1410
|
+
if (triggerSkipped) {
|
|
1411
|
+
T.decisions.push("Conformance reviewer skipped by review.trigger enforce: no trigger reason (risk path, package edge, hotspot, failed or ambiguous gate)");
|
|
1412
|
+
if (shadow) {
|
|
1413
|
+
shadowWould = verdict.would;
|
|
1414
|
+
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: [] });
|
|
1415
|
+
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: [] });
|
|
1416
|
+
}
|
|
1417
|
+
T.review = [TRIGGER_SKIP, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1418
|
+
await post("FORGE:REVIEW", triggerSkipNote);
|
|
1419
|
+
} else if (shadow) {
|
|
1420
|
+
const wf = verdict.wouldFail ?? [];
|
|
1421
|
+
shadowWould = verdict.would;
|
|
1422
|
+
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1423
|
+
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1424
|
+
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1425
|
+
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) }));
|
|
1426
|
+
} else {
|
|
1427
|
+
T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1428
|
+
await post("FORGE:REVIEW", renderReview(review, effective));
|
|
1429
|
+
}
|
|
976
1430
|
}
|
|
977
1431
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
978
|
-
const closing = closePlan(
|
|
1432
|
+
const closing = closePlan(effective);
|
|
1433
|
+
if (degradedReq?.tracked && !closing.tracked.includes(degradedReq.tracked)) closing.tracked.push(degradedReq.tracked);
|
|
979
1434
|
|
|
980
1435
|
// ---- 5. push + PR ----
|
|
981
1436
|
files = await changedFiles();
|
|
982
1437
|
await gitPush(["push", "-u", "origin", branch]);
|
|
983
1438
|
laneGuard(lane, "open a PR");
|
|
984
1439
|
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
985
|
-
"--title", `${iss.title} (#${issue})`, "--body",
|
|
1440
|
+
"--title", `${iss.title} (#${issue})`, "--body", isSplit ? (o.refactorModule ?? (await import("./refactor.mjs"))).renderSplitPrBody({ issue, ...split })
|
|
1441
|
+
: renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
|
|
986
1442
|
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
987
1443
|
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
988
1444
|
ev({ event: "PR_OPENED", pr });
|
|
@@ -990,9 +1446,15 @@ export async function runCore(o) {
|
|
|
990
1446
|
try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
|
|
991
1447
|
catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
|
|
992
1448
|
}
|
|
1449
|
+
if (degraded) {
|
|
1450
|
+
try { await ghw(["label", "create", REVIEW_DEGRADED_LABEL, "-R", repo, "--force", "--color", "FBCA04", "--description", "Merged with an incomplete conformance review. Managed by ForgeDock."]); } catch { /* exists, or no rights: the edit below says */ }
|
|
1451
|
+
try { await ghw(["pr", "edit", String(pr), "-R", repo, "--add-label", REVIEW_DEGRADED_LABEL]); }
|
|
1452
|
+
catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not label PR #${pr} ${REVIEW_DEGRADED_LABEL}; nothing merged, rerun to retry`; throw e; }
|
|
1453
|
+
}
|
|
993
1454
|
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
994
1455
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
995
1456
|
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1457
|
+
await reportVerify();
|
|
996
1458
|
|
|
997
1459
|
// ---- 6. merge policy: required (default) | all | none ----
|
|
998
1460
|
let policy = mergeWait, required = [];
|
|
@@ -1025,6 +1487,13 @@ export async function runCore(o) {
|
|
|
1025
1487
|
try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
|
|
1026
1488
|
return String(j.mergeable ?? "").toUpperCase() === "MERGEABLE" && String(j.mergeStateStatus ?? "").toUpperCase() === "BLOCKED";
|
|
1027
1489
|
};
|
|
1490
|
+
/** #4266: one mergeability read inside the CI wait: "conflict", "mergeable", or null (unknown / unreadable / other). */
|
|
1491
|
+
const waitMergeState = async () => {
|
|
1492
|
+
let j;
|
|
1493
|
+
try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return null; }
|
|
1494
|
+
const m = String(j.mergeable ?? "").toUpperCase(), st = String(j.mergeStateStatus ?? "").toUpperCase();
|
|
1495
|
+
return m === "CONFLICTING" || st === "DIRTY" ? "conflict" : m === "MERGEABLE" ? "mergeable" : null;
|
|
1496
|
+
};
|
|
1028
1497
|
let blockStartedAt = null, blockPollIndex = 0;
|
|
1029
1498
|
const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
1030
1499
|
const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
|
|
@@ -1040,6 +1509,10 @@ export async function runCore(o) {
|
|
|
1040
1509
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1041
1510
|
let clean = true, files = [];
|
|
1042
1511
|
try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
|
|
1512
|
+
if (isSplit && !clean) { // #4211: no builder resolves a split's conflicts; a fresh run re-splits on the new base
|
|
1513
|
+
await gitOut(["merge", "--abort"]);
|
|
1514
|
+
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);
|
|
1515
|
+
}
|
|
1043
1516
|
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
|
|
1044
1517
|
if (clean) {
|
|
1045
1518
|
how = "clean merge of the base into the branch";
|
|
@@ -1059,8 +1532,13 @@ export async function runCore(o) {
|
|
|
1059
1532
|
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
1060
1533
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
1061
1534
|
let ciFixes = 0;
|
|
1535
|
+
if (isSplit && after !== before) { // #4211: the merged head must still be a pure move against the new base
|
|
1536
|
+
const rv = await (o.refactorModule ?? (await import("./refactor.mjs"))).reverifySplit({ wt, base, target: split.target, testCmd: split.testCmd, runTmp, git });
|
|
1537
|
+
if (!rv.ok) return await needsHuman(`refactor:split no longer verifies after merging ${base}: ${rv.reason}`, rv.code);
|
|
1538
|
+
rereview = "refactor-verify re-run against the new base: ok";
|
|
1539
|
+
}
|
|
1062
1540
|
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1063
|
-
if (!clean || ciFixes) {
|
|
1541
|
+
if (!isSplit && (!clean || ciFixes)) {
|
|
1064
1542
|
let rv = await doReview();
|
|
1065
1543
|
if (rv?.fail) {
|
|
1066
1544
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
@@ -1069,17 +1547,47 @@ export async function runCore(o) {
|
|
|
1069
1547
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
1070
1548
|
rv = await doReview();
|
|
1071
1549
|
}
|
|
1072
|
-
if (
|
|
1550
|
+
if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
|
|
1073
1551
|
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1074
1552
|
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
1075
1553
|
}
|
|
1076
1554
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1555
|
+
await reportVerify();
|
|
1077
1556
|
syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
|
|
1078
1557
|
T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
|
|
1079
1558
|
await post("FORGE:BASE_SYNC", `## Base Sync\n**Trigger:** ${trigger}\n**Base merged:** \`${base}\` at \`${baseSha}\`\n**Result:** ${clean ? "clean merge" : `conflicts in ${files.map((f) => `\`${f}\``).join(", ") || "unknown files"}`}\n**Resolution:** ${how}\n**Tests run:** ${tests}\n**Re-review:** ${rereview}\n**New head:** \`${localHead.slice(0, 12)}\``);
|
|
1080
1559
|
return null;
|
|
1081
1560
|
}
|
|
1082
1561
|
const NR_TESTS = "not reported";
|
|
1562
|
+
/**
|
|
1563
|
+
* #4240: a required check failed at merge (any verify mode). One CI fix round in the builder session with the failed
|
|
1564
|
+
* logs, shared with ciVerify's verify.max_rounds budget; the fixed head is re-reviewed and pushed, then the wait restarts.
|
|
1565
|
+
* Returns null to wait again, or a terminal result (rounds exhausted, no session, no new commit, review failure).
|
|
1566
|
+
*/
|
|
1567
|
+
async function mergeFixRound(checks) {
|
|
1568
|
+
const jobs = checks.map((c) => c.name);
|
|
1569
|
+
if (ciRounds >= vcfg.maxRounds || !sessionId) {
|
|
1570
|
+
return await needsHuman(`Required check(s) failed on PR #${pr}: ${jobs.join(", ")}${ciRounds ? ` (after ${ciRounds} CI fix round(s))` : ""}`, OUTCOME_CODES.CI_RED);
|
|
1571
|
+
}
|
|
1572
|
+
const failures = await collectFailedLogs(io, checks);
|
|
1573
|
+
ciRounds++;
|
|
1574
|
+
T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1575
|
+
ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
|
|
1576
|
+
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1577
|
+
await builder(++attemptN, ciFailurePrompt(failures, vcfg.mode), sessionId);
|
|
1578
|
+
await upsertStage("implementation");
|
|
1579
|
+
result = (await readResult()) ?? result;
|
|
1580
|
+
if ((await gitOut(["rev-parse", "HEAD"])) === before) {
|
|
1581
|
+
return await needsHuman(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED);
|
|
1582
|
+
}
|
|
1583
|
+
const rv = await doReview(); // never merge an unreviewed head
|
|
1584
|
+
if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1585
|
+
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1586
|
+
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1587
|
+
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1588
|
+
await reportVerify();
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1083
1591
|
/** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
|
|
1084
1592
|
async function deleteMergedBranch() {
|
|
1085
1593
|
const skip = (why) => { ev({ event: "BRANCH_DELETE_SKIPPED", branch, why }); T.decisions.push(`Lane branch kept: ${why}`); };
|
|
@@ -1094,6 +1602,34 @@ export async function runCore(o) {
|
|
|
1094
1602
|
catch (e) { ev({ event: "BRANCH_DELETE_FAILED", branch, where: "origin", error: String(e?.stderr ?? e?.message ?? e).slice(0, 300) }); }
|
|
1095
1603
|
}
|
|
1096
1604
|
|
|
1605
|
+
/**
|
|
1606
|
+
* forge#4165: one STRUCTURE_DELTA telemetry record per merged run, from scripts/structure-delta.sh (the only place the
|
|
1607
|
+
* delta is computed) plus one local `git diff --numstat` for growth and the contract's decision provenance. Local git
|
|
1608
|
+
* only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
|
|
1609
|
+
*/
|
|
1610
|
+
async function recordStructure(pr) {
|
|
1611
|
+
const d = (await structureOf()) ?? {};
|
|
1612
|
+
const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
|
|
1613
|
+
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}/`))
|
|
1614
|
+
.map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
|
|
1615
|
+
const pairs = Array.isArray(d.import_pairs) ? d.import_pairs : [], sum = (k) => pairs.reduce((n, x) => n + (Number(x?.[k]) || 0), 0);
|
|
1616
|
+
const edge = (e) => ({ from: e?.from, to: e?.to });
|
|
1617
|
+
const s = {
|
|
1618
|
+
deps_added: (d.package_edges_added ?? []).map((e) => ({ ...edge(e), kind: e?.kind, manifest: e?.manifest })),
|
|
1619
|
+
deps_removed: (d.package_edges_removed ?? []).map((e) => ({ ...edge(e), kind: e?.kind, manifest: e?.manifest })),
|
|
1620
|
+
new_edges: pairs.filter((x) => x?.files_added > 0).map((x) => ({ ...edge(x), file: Array.isArray(x.files) ? x.files[0] ?? null : null, files: x.files_added })),
|
|
1621
|
+
removed_edges: pairs.filter((x) => x?.files_removed > 0).map((x) => ({ ...edge(x), files: x.files_removed })),
|
|
1622
|
+
top_growth,
|
|
1623
|
+
decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })),
|
|
1624
|
+
};
|
|
1625
|
+
T.structure = s;
|
|
1626
|
+
const rec = { event: "STRUCTURE_DELTA", phase: "merge", pr, ...(milestone ? { milestone } : {}), ...s,
|
|
1627
|
+
edges_added: s.deps_added.length, edges_removed: s.deps_removed.length, import_files_added: sum("files_added"), import_files_removed: sum("files_removed"),
|
|
1628
|
+
boundary_changed: Boolean(d.boundary_config_changed?.changed), boundary_loosened: Boolean(d.boundary_config_changed?.loosens) };
|
|
1629
|
+
ev(rec);
|
|
1630
|
+
await emit(rec);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1097
1633
|
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
1098
1634
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
1099
1635
|
for (;;) {
|
|
@@ -1103,22 +1639,41 @@ export async function runCore(o) {
|
|
|
1103
1639
|
// merge right after review
|
|
1104
1640
|
T.merge = [policyText, "merged right after review"];
|
|
1105
1641
|
} else {
|
|
1106
|
-
|
|
1107
|
-
let pollIndex = 0, gateHead = null;
|
|
1642
|
+
let startedAt = now();
|
|
1643
|
+
let pollIndex = 0, gateHead = null, conflicted = false, notStartedLogged = false;
|
|
1108
1644
|
for (;;) {
|
|
1109
1645
|
await renew();
|
|
1646
|
+
let states = [];
|
|
1647
|
+
// a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
|
|
1648
|
+
const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
|
|
1110
1649
|
if (policy === "all") {
|
|
1111
|
-
|
|
1650
|
+
// #4270: the lane's own report-only check never gates its own merge
|
|
1651
|
+
const w = await io.waitCiGreen(pr, repo, 0, ...(vcfg.checkRun === true ? [{ ignore: [VERIFY_CHECK_NAME] }] : [])).catch(() => ({ rc: 2, text: "" }));
|
|
1112
1652
|
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
1113
1653
|
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
1114
1654
|
} else {
|
|
1115
|
-
let states = [];
|
|
1116
1655
|
try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
|
|
1117
|
-
const
|
|
1656
|
+
const check = (n) => states.find((x) => x.name === n && current(x));
|
|
1657
|
+
const st = (n) => check(n)?.state ?? "pending";
|
|
1118
1658
|
const failed = required.filter((n) => st(n) === "failure");
|
|
1119
|
-
if (failed.length)
|
|
1659
|
+
if (failed.length) {
|
|
1660
|
+
const r = await mergeFixRound(failed.map((n) => ({ name: n, url: check(n)?.url ?? null })));
|
|
1661
|
+
if (r) return r;
|
|
1662
|
+
startedAt = now(); pollIndex = 0; // the fixed head gets the full wait budget
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1120
1665
|
if (required.every((n) => st(n) === "success")) break;
|
|
1121
1666
|
}
|
|
1667
|
+
// #4266: a PR that turns conflicting gets no merge ref, so its pull_request workflows never start: base-sync now.
|
|
1668
|
+
const ms = await waitMergeState();
|
|
1669
|
+
if (ms === "conflict") { conflicted = true; break; }
|
|
1670
|
+
if (policy !== "all" && ms === "mergeable" && !notStartedLogged && now() - startedAt >= CI_NOT_STARTED_GRACE_MS
|
|
1671
|
+
&& required.every((n) => !states.some((x) => x.name === n && current(x)))) {
|
|
1672
|
+
notStartedLogged = true;
|
|
1673
|
+
const minutes = Math.round((now() - startedAt) / 60000);
|
|
1674
|
+
ev({ event: "CI_NOT_STARTED", pr, checks: required, minutes });
|
|
1675
|
+
T.anomalies.push(`Required check(s) ${required.join(", ")} not started on PR #${pr} after ${minutes} min (missing workflow trigger?)`);
|
|
1676
|
+
}
|
|
1122
1677
|
if (now() - startedAt >= ciMaxMinutes * 60000) {
|
|
1123
1678
|
// Slow CI is never a needs-human: leave the PR open and resumable.
|
|
1124
1679
|
ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
|
|
@@ -1128,6 +1683,10 @@ export async function runCore(o) {
|
|
|
1128
1683
|
onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
|
|
1129
1684
|
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
1130
1685
|
}
|
|
1686
|
+
if (conflicted) {
|
|
1687
|
+
const r = await baseSync("PR became conflicting while waiting for CI"); if (r) return r;
|
|
1688
|
+
continue; // wait again on the synced head with a fresh budget
|
|
1689
|
+
}
|
|
1131
1690
|
if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
|
|
1132
1691
|
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);
|
|
1133
1692
|
}
|
|
@@ -1167,17 +1726,27 @@ export async function runCore(o) {
|
|
|
1167
1726
|
const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
|
|
1168
1727
|
}
|
|
1169
1728
|
}
|
|
1729
|
+
try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
|
|
1170
1730
|
await deleteMergedBranch();
|
|
1171
1731
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
1172
1732
|
if (closing.tracked.length) {
|
|
1173
1733
|
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 */ }
|
|
1174
1734
|
}
|
|
1735
|
+
if (isSplit && split.rest) { // #4211: the rest of a large split is the next PR of the chain, planned already (no grouping call)
|
|
1736
|
+
const mod = o.refactorModule ?? (await import("./refactor.mjs"));
|
|
1737
|
+
const c = mod.renderChainIssue({ parent: issue, target: split.target, packageName: split.packageName, rest: split.rest, testCmd: mod.parseTestCmd(iss.body ?? "", []) });
|
|
1738
|
+
try {
|
|
1739
|
+
const out = String(await ghw(["issue", "create", "-R", repo, "--title", c.title, "--body", c.body, ...c.labels.flatMap((l) => ["--label", l]), ...(milestone ? ["--milestone", milestone] : [])]));
|
|
1740
|
+
T.decisions.push(`refactor:split chain: ${Object.keys(split.rest).length} module(s) left, filed ${/\/issues\/(\d+)/.exec(out)?.[1] ? `#${/\/issues\/(\d+)/.exec(out)[1]}` : out.trim().slice(0, 120)}`);
|
|
1741
|
+
} catch (e) { T.anomalies.push(`refactor:split chain issue not filed (the maintenance scan re-files the hotspot): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
1742
|
+
}
|
|
1175
1743
|
await label("merged");
|
|
1176
|
-
|
|
1744
|
+
// forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
|
|
1745
|
+
return await finish("merged", `PR #${pr}`, { pr, structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
1177
1746
|
} catch (e) {
|
|
1178
1747
|
if (e?.deferUsageLimit) {
|
|
1179
1748
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
1180
|
-
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null }, OUTCOME_CODES.RATE_LIMITED);
|
|
1749
|
+
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env) }, OUTCOME_CODES.RATE_LIMITED);
|
|
1181
1750
|
}
|
|
1182
1751
|
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
|
|
1183
1752
|
if (e?.laneCode) return await needsHuman(e.message, e.laneCode);
|