forgedock 1.26.0 → 1.28.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 +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- 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 +178 -16
- 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 +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- 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/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- 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 +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- 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 +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- 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 +227 -3
- package/docs/OUTCOME-CODES.md +11 -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 +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -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 +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- 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/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/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- 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/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,28 +5,55 @@
|
|
|
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
|
-
import { openDependency, isTrustedComment } from "../engine/phases.mjs";
|
|
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 } from "./conformance.mjs";
|
|
16
|
-
import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
15
|
+
import { TRACKED, isBugIssue, 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";
|
|
19
|
+
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
20
|
+
import { repoKey } from "../runner.mjs";
|
|
21
|
+
import { prepareToolchain } from "./toolchain.mjs";
|
|
18
22
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
19
23
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
20
|
-
import {
|
|
24
|
+
import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
25
|
+
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, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
|
|
26
|
+
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
27
|
+
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES } from "./verify.mjs";
|
|
28
|
+
import { laneBackendId } from "./limits.mjs";
|
|
29
|
+
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
30
|
+
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
31
|
+
import { priorityFromLabels } from "./sched.mjs";
|
|
21
32
|
|
|
22
33
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
23
34
|
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
24
35
|
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
25
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression
|
|
38
|
+
* (precheck form: SHADOW_PRE_BLOCKS). A review without usable rows never merges either.
|
|
39
|
+
*/
|
|
26
40
|
const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
|
|
41
|
+
/**
|
|
42
|
+
* Precheck failures that block in shadow mode too (#4160): every precheck or amendment failure coded
|
|
43
|
+
* conformance.unimplemented (a validate command that failed or has no recorded result, a deliver requirement reported as
|
|
44
|
+
* not done, an invalid amendment) or conformance.regression, the same codes a review blocks on, and unexempted growth of an
|
|
45
|
+
* over-threshold file (maintenance.hotspot_growth, forge#4195). Scope creep stays recorded-only.
|
|
46
|
+
*/
|
|
47
|
+
const SHADOW_PRE_BLOCKS = [...SHADOW_BLOCKS, OUTCOME_CODES.MAINTENANCE_HOTSPOT_GROWTH];
|
|
48
|
+
/** 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. */
|
|
49
|
+
export const REVIEW_INCOMPLETE_MODES = Object.freeze(["needs-human", "degraded"]);
|
|
50
|
+
export const REVIEW_DEGRADED_LABEL = "review-degraded";
|
|
51
|
+
const UNUSABLE_REVIEW_CODES = { "review.unparseable": OUTCOME_CODES.REVIEW_NO_VERDICT, "review.incomplete": OUTCOME_CODES.REVIEW_INCOMPLETE, "review.inconsistent": OUTCOME_CODES.REVIEW_INCONSISTENT };
|
|
27
52
|
export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
|
|
28
53
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
29
54
|
export const MAX_SUBISSUES = 8;
|
|
55
|
+
/** #4266: required checks with no rollup entry this long into a CI wait on a MERGEABLE PR log CI_NOT_STARTED (once per wait). */
|
|
56
|
+
export const CI_NOT_STARTED_GRACE_MS = 10 * 60 * 1000;
|
|
30
57
|
/** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
|
|
31
58
|
export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
|
|
32
59
|
|
|
@@ -55,6 +82,15 @@ export function classifyBlock(result, issue) {
|
|
|
55
82
|
return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
|
|
56
83
|
}
|
|
57
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Never a branch-deletion target (#4179): anything but the issue's own `feat/issue-<n>-lean`, and never main, master,
|
|
87
|
+
* staging, the lane, forge.yaml's default/staging branch, `milestone/*` or a default branch of the repo.
|
|
88
|
+
*/
|
|
89
|
+
export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, defaults = [] } = {}) {
|
|
90
|
+
if (typeof b !== "string" || b !== `feat/issue-${issue}-lean`) return true;
|
|
91
|
+
return ["main", "master", "staging", lane, branches?.default, branches?.staging].includes(b) || /^milestone\//.test(b) || isDefaultLane(b, defaults);
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/**
|
|
59
95
|
* @param {object} o
|
|
60
96
|
* @param {number} o.issue
|
|
@@ -69,13 +105,29 @@ export function classifyBlock(result, issue) {
|
|
|
69
105
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
70
106
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
71
107
|
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
108
|
+
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
109
|
+
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
|
|
110
|
+
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
111
|
+
* @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
|
|
112
|
+
* @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
|
|
113
|
+
* @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
|
|
114
|
+
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
115
|
+
* @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
|
|
116
|
+
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
117
|
+
* @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)
|
|
118
|
+
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
72
119
|
*/
|
|
73
120
|
export async function runCore(o) {
|
|
74
121
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
75
|
-
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;
|
|
122
|
+
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;
|
|
76
123
|
const now = io.now ?? (() => Date.now());
|
|
124
|
+
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
125
|
+
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
126
|
+
const remoteVerify = vcfg.mode !== "local";
|
|
127
|
+
const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
|
|
128
|
+
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
77
129
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
78
|
-
let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
|
|
130
|
+
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
79
131
|
const branch = `feat/issue-${issue}-lean`;
|
|
80
132
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
81
133
|
const t0 = now();
|
|
@@ -121,7 +173,7 @@ export async function runCore(o) {
|
|
|
121
173
|
const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
|
|
122
174
|
let leaseState = null, leaseTimer = null;
|
|
123
175
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
124
|
-
const renew = async () => { if (io.projector && leaseState) { try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
|
|
176
|
+
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 */ } } };
|
|
125
177
|
async function releaseLease(reason) {
|
|
126
178
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
127
179
|
if (!io.projector || !leaseState) return;
|
|
@@ -131,14 +183,119 @@ export async function runCore(o) {
|
|
|
131
183
|
|
|
132
184
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
133
185
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
134
|
-
let
|
|
186
|
+
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
187
|
+
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
135
188
|
const runId = `${issue}-${t0}`;
|
|
136
189
|
let runTmp = null;
|
|
137
|
-
|
|
138
|
-
|
|
190
|
+
let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
|
|
191
|
+
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
|
|
192
|
+
// #4169: heavy steps take a host semaphore slot (io.heavy); LLM phases are not gated. `heavyEnv` puts the heavy shims on
|
|
193
|
+
// the runner PATH; `waits` ({phase: ms}) and `heavyQueueMax` ride on the lane result for the batch report.
|
|
194
|
+
const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
|
|
195
|
+
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
196
|
+
const waits = {};
|
|
197
|
+
const addWait = (phase, ms, queued = 0) => {
|
|
198
|
+
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
199
|
+
if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
|
|
200
|
+
};
|
|
201
|
+
const lanePriority = () => priorityFromLabels(iss?.labels);
|
|
202
|
+
/** A host heavy-step slot for an engine-side step, or null (no io.heavy, disabled, or the registry failed: ungated). */
|
|
203
|
+
async function heavyAcquire(phase) {
|
|
204
|
+
if (!heavyOn) return null;
|
|
205
|
+
try { const g = await io.heavy.acquire({ issue, phase, priority: lanePriority() }, heavyCfg); addWait(phase, g?.waitMs, g?.queued); return g; } catch { return null; }
|
|
206
|
+
}
|
|
207
|
+
async function heavyStep(phase, fn) { const g = await heavyAcquire(phase); try { return await fn(); } finally { try { g?.release?.(); } catch { /* fail-open */ } } }
|
|
208
|
+
// ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
|
|
209
|
+
let ledger = null, deleteLocalBranch = false;
|
|
210
|
+
const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
|
|
211
|
+
const protectedBranch = (b) => isProtectedLaneBranch(b, { issue, lane, branches, defaults });
|
|
212
|
+
/** Teardown from the ledger (#4179), on every exit path: builder pids, checkpoint of unpushed work, run tmp, worktree, branch, docker. */
|
|
213
|
+
async function teardown(reason, extra) {
|
|
214
|
+
for (const p of ledger ? ledger.entries("pid") : []) {
|
|
215
|
+
try { if (await io.killGroup?.(p.value, p.marker)) ledgerDo((l) => l.release("pid", p.value)); } catch { /* keep it listed */ }
|
|
216
|
+
}
|
|
217
|
+
// Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
|
|
218
|
+
const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
|
|
219
|
+
if (runTmp) {
|
|
220
|
+
try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
|
|
221
|
+
runTmp = null;
|
|
222
|
+
} // every terminal, including engine-error and deferred
|
|
223
|
+
if (wtCreated && saved) {
|
|
224
|
+
let removed = false;
|
|
225
|
+
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); removed = true; } catch { /* best effort */ }
|
|
226
|
+
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
227
|
+
if (removed) ledgerDo((l) => l.release("worktree", wt));
|
|
228
|
+
let branchDone = true;
|
|
229
|
+
if ((reason === "invalid" && !extra.pr) || (reason === "merged" && deleteLocalBranch)) {
|
|
230
|
+
try { await io.git(["branch", "-D", branch], { cwd }); } catch { branchDone = reason !== "merged"; }
|
|
231
|
+
}
|
|
232
|
+
if (branchDone) ledgerDo((l) => l.release("branch", branch));
|
|
233
|
+
} else if (wtCreated) {
|
|
234
|
+
ev({ event: "TEARDOWN_KEPT", reason: "checkpoint failed: worktree and branch kept so no work is lost", worktree: wt });
|
|
235
|
+
}
|
|
236
|
+
for (const kind of ["container", "volume"]) {
|
|
237
|
+
for (const r of ledger ? ledger.entries(kind) : []) {
|
|
238
|
+
if (!io.docker) continue;
|
|
239
|
+
try { await io.docker(kind === "container" ? ["rm", "-f", String(r.value)] : ["volume", "rm", String(r.value)]); ledgerDo((l) => l.release(kind, r.value)); } catch { /* docker absent or busy: keep it listed */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
ledgerDo((l) => { if (l.remove()) ev({ event: "LEDGER_CLEARED", path: l.path }); else ev({ event: "LEDGER_KEPT", path: l.path, resources: l.entries().map((r) => `${r.kind}:${r.value}`) }); });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* #4162: save the worktree's uncommitted work (tracked, untracked and .forge-lean/) to refs/forge/salvage/<issue>/<ms>
|
|
246
|
+
* before an earlier run's worktree is removed at the start of this run (a run that died hard never ran its
|
|
247
|
+
* teardown checkpoint, #4179). It stages into the worktree's own index, which is discarded with the worktree, and
|
|
248
|
+
* the ref lives in the shared repo, so it survives the removal. Returns the ref, or null when there is nothing to save.
|
|
249
|
+
*/
|
|
250
|
+
async function salvage(why) {
|
|
251
|
+
if (!io.fs?.exists || !(await io.fs.exists(wt))) return null;
|
|
252
|
+
const g = (a) => io.git(a, { cwd: wt });
|
|
253
|
+
try {
|
|
254
|
+
await g(["add", "-A"]);
|
|
255
|
+
if (await io.fs.exists(join(wt, LEAN_DIR))) await g(["add", "-f", "--", LEAN_DIR]);
|
|
256
|
+
const tree = String(await g(["write-tree"])).trim();
|
|
257
|
+
const headTree = String(await g(["rev-parse", "HEAD^{tree}"])).trim();
|
|
258
|
+
if (!/^[0-9a-f]{40,64}$/.test(tree) || tree === headTree) return null;
|
|
259
|
+
const commit = String(await g(["-c", "user.name=forgedock", "-c", "user.email=forgedock@localhost", "commit-tree", tree, "-p", "HEAD", "-m", `forge salvage #${issue}: ${why}`])).trim();
|
|
260
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit)) return null;
|
|
261
|
+
const ref = `refs/forge/salvage/${issue}/${now()}`;
|
|
262
|
+
await io.git(["update-ref", ref, commit], { cwd });
|
|
263
|
+
ev({ event: "WORKTREE_SALVAGED", ref, why });
|
|
264
|
+
T.anomalies.push(`Uncommitted work from ${why} saved to \`${ref}\` (inspect: \`git diff HEAD ${ref} --stat\`)`);
|
|
265
|
+
return ref;
|
|
266
|
+
} catch (e) {
|
|
267
|
+
ev({ event: "WORKTREE_SALVAGE_FAILED", why, error: String(e?.message ?? e).slice(0, 300) });
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** #4162: stop processes still running inside the worktree (an orphaned runner from a dead run) before it is removed. */
|
|
272
|
+
async function stopWorktreeProcs() {
|
|
273
|
+
if (!io.proc) return;
|
|
274
|
+
const procs = await Promise.resolve(io.proc.inDir(wt)).catch(() => []);
|
|
275
|
+
if (!procs.length) return;
|
|
276
|
+
for (const p of procs) io.proc.kill(p.pid, "SIGTERM");
|
|
277
|
+
for (let i = 0; i < 10 && procs.some((p) => io.proc.alive(p.pid)); i++) await sleep(500);
|
|
278
|
+
const left = procs.filter((p) => io.proc.alive(p.pid));
|
|
279
|
+
for (const p of left) io.proc.kill(p.pid, "SIGKILL");
|
|
280
|
+
ev({ event: "WORKTREE_PROCS_STOPPED", pids: procs.map((p) => p.pid), killed: left.map((p) => p.pid) });
|
|
281
|
+
T.anomalies.push(`Stopped ${procs.length} process(es) still running in the worktree from an earlier run: ${procs.map((p) => `${p.pid} ${p.cmd ?? ""}`.trim().slice(0, 80)).join("; ")}`);
|
|
282
|
+
}
|
|
283
|
+
function finish(...args) {
|
|
284
|
+
if (done) return Promise.resolve(done);
|
|
285
|
+
finishing ??= finishOnce(...args); // a signal during a normal finish waits for the same teardown
|
|
286
|
+
return finishing;
|
|
287
|
+
}
|
|
288
|
+
async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
|
|
289
|
+
if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
|
|
290
|
+
try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
|
|
291
|
+
}
|
|
292
|
+
await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
|
|
139
293
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
140
|
-
|
|
141
|
-
|
|
294
|
+
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
295
|
+
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
296
|
+
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}) };
|
|
297
|
+
ev(term);
|
|
298
|
+
await emit(term);
|
|
142
299
|
if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
|
|
143
300
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
144
301
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
@@ -150,12 +307,8 @@ export async function runCore(o) {
|
|
|
150
307
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
151
308
|
}
|
|
152
309
|
await releaseLease(reason);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
|
|
156
|
-
if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
|
|
157
|
-
}
|
|
158
|
-
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, ...extra };
|
|
310
|
+
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,
|
|
311
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
159
312
|
return done;
|
|
160
313
|
}
|
|
161
314
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR) {
|
|
@@ -228,11 +381,25 @@ export async function runCore(o) {
|
|
|
228
381
|
}
|
|
229
382
|
}
|
|
230
383
|
|
|
231
|
-
/**
|
|
384
|
+
/** Verification worker cap for this call (#4182): an operator-set positive int wins, else cores / live lanes; null when unknown. */
|
|
385
|
+
function verifyParallelism() {
|
|
386
|
+
const set = String(process.env.FORGE_HOST_PARALLELISM ?? "").trim();
|
|
387
|
+
if (/^\d+$/.test(set) && Number(set) > 0) return set;
|
|
388
|
+
if (!io.host?.cores) return null;
|
|
389
|
+
try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
|
|
390
|
+
}
|
|
391
|
+
/** 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). */
|
|
232
392
|
async function callRunner(args) {
|
|
233
393
|
let pauses = 0;
|
|
234
394
|
for (;;) {
|
|
235
|
-
|
|
395
|
+
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
396
|
+
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
397
|
+
const par = verifyParallelism(); // #4182
|
|
398
|
+
let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
|
|
399
|
+
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
400
|
+
// #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
|
|
401
|
+
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()) } : {};
|
|
402
|
+
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 } }); }
|
|
236
403
|
catch (e) {
|
|
237
404
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
238
405
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -264,13 +431,18 @@ export async function runCore(o) {
|
|
|
264
431
|
const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
|
|
265
432
|
if (ahead) await gitPush(["push", "-u", "origin", branch]);
|
|
266
433
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
267
|
-
|
|
434
|
+
return true;
|
|
435
|
+
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
268
436
|
}
|
|
269
437
|
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.";
|
|
270
438
|
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.";
|
|
271
|
-
const
|
|
439
|
+
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.";
|
|
440
|
+
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.`;
|
|
272
441
|
|
|
273
|
-
/**
|
|
442
|
+
/**
|
|
443
|
+
* Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume
|
|
444
|
+
* when under. Since #4169 the ceiling is the mem/tmp floors (load average only when configured); paused time is a wait.
|
|
445
|
+
*/
|
|
274
446
|
function startGovernor(control) {
|
|
275
447
|
if (!io.host || o.governor === false || o.admission?.enabled === false) return { stop() {} };
|
|
276
448
|
const pollMs = o.governorPollMs ?? 15000, maxPauseMs = o.maxPauseMs ?? 600000;
|
|
@@ -284,16 +456,18 @@ export async function runCore(o) {
|
|
|
284
456
|
try { ok = (await checkAdmission({ cfg: o.admission, host: io.host })).ok; } catch { ok = true; }
|
|
285
457
|
const nowMs = Date.now();
|
|
286
458
|
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" }); } }
|
|
287
|
-
else if (ok && paused) { control.resume?.(); paused = false; forced = false; ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
|
|
459
|
+
else if (ok && paused) { control.resume?.(); paused = false; forced = false; addWait(stage, nowMs - pausedAt); ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
|
|
288
460
|
else if (ok) forced = false;
|
|
289
|
-
else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
|
|
461
|
+
else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; addWait(stage, nowMs - pausedAt); ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
|
|
290
462
|
}
|
|
291
463
|
})();
|
|
292
|
-
return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
|
|
464
|
+
return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; addWait(stage, Date.now() - pausedAt); } } };
|
|
293
465
|
}
|
|
294
466
|
|
|
295
467
|
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
296
|
-
const
|
|
468
|
+
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse });
|
|
469
|
+
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
470
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
|
|
297
471
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
298
472
|
let timeoutRetries = 0, p = prompt;
|
|
299
473
|
for (;;) {
|
|
@@ -316,8 +490,11 @@ export async function runCore(o) {
|
|
|
316
490
|
await renew();
|
|
317
491
|
const control = {};
|
|
318
492
|
const governor = startGovernor(control);
|
|
319
|
-
|
|
493
|
+
let spawned = null;
|
|
494
|
+
const onSpawn = (child) => { if (Number.isSafeInteger(child?.pid) && !finishing) { spawned = child.pid; ledgerDo((l) => l.register("pid", child.pid, { marker: basename(String(child.spawnfile ?? "")) || null })); } };
|
|
495
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt, control, onSpawn,
|
|
320
496
|
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
|
|
497
|
+
call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
|
|
321
498
|
let r;
|
|
322
499
|
call.catch(() => governor.stop());
|
|
323
500
|
if (live && io.fs.stat) {
|
|
@@ -334,6 +511,7 @@ export async function runCore(o) {
|
|
|
334
511
|
} else { try { r = await call; } finally { /* governor stops below */ } }
|
|
335
512
|
await governor.stop();
|
|
336
513
|
await renew();
|
|
514
|
+
await registerReported();
|
|
337
515
|
if (r?.sessionId) sessionId = r.sessionId;
|
|
338
516
|
await attempt("implement", n, r);
|
|
339
517
|
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
@@ -341,6 +519,17 @@ export async function runCore(o) {
|
|
|
341
519
|
}
|
|
342
520
|
const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
343
521
|
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
522
|
+
/** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
|
|
523
|
+
async function registerReported() {
|
|
524
|
+
if (!ledger || finishing) return; // after teardown nothing new is owned
|
|
525
|
+
const res = (await readResult())?.resources;
|
|
526
|
+
if (!res || typeof res !== "object") return;
|
|
527
|
+
for (const [key, kind] of [["containers", "container"], ["volumes", "volume"]]) {
|
|
528
|
+
for (const v of Array.isArray(res[key]) ? res[key] : []) {
|
|
529
|
+
if (typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(v)) ledgerDo((l) => l.register(kind, v));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
344
533
|
|
|
345
534
|
/** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
|
|
346
535
|
async function resolveBlock(r) {
|
|
@@ -353,8 +542,20 @@ export async function runCore(o) {
|
|
|
353
542
|
return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
354
543
|
}
|
|
355
544
|
if (String(v?.state).toUpperCase() !== "OPEN") continue;
|
|
356
|
-
open.
|
|
357
|
-
|
|
545
|
+
// #4153: an open decomposed parent resolves to its open sub-issues; it is satisfied once they all close.
|
|
546
|
+
const subs = isDecomposedRecord(v) ? await decomposedSubissues(d, io) : null;
|
|
547
|
+
const recs = [];
|
|
548
|
+
if (!subs) recs.push([d, v]);
|
|
549
|
+
else for (const n of subs) {
|
|
550
|
+
try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
|
|
551
|
+
return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for (const [n, rec] of recs) {
|
|
555
|
+
if (String(rec?.state).toUpperCase() !== "OPEN" || n === Number(issue) || open.includes(n)) continue;
|
|
556
|
+
open.push(n);
|
|
557
|
+
if ((rec.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(n);
|
|
558
|
+
}
|
|
358
559
|
}
|
|
359
560
|
if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
|
|
360
561
|
return { kind: "dependency", issues: open, blockers };
|
|
@@ -381,7 +582,8 @@ export async function runCore(o) {
|
|
|
381
582
|
try {
|
|
382
583
|
for (const sub of b.subissues) {
|
|
383
584
|
const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
|
|
384
|
-
|
|
585
|
+
// #4153: the parent's milestone, so each sub-issue derives the parent's lane before any lane runs.
|
|
586
|
+
const out = String(await ghw(["issue", "create", "-R", repo, "--title", sub.title, "--body", `${sub.body.trim()}\n\n${lines.join("\n")}`, ...(milestone ? ["--milestone", milestone] : [])]));
|
|
385
587
|
const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
|
|
386
588
|
if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
|
|
387
589
|
filed.push(n);
|
|
@@ -404,8 +606,9 @@ export async function runCore(o) {
|
|
|
404
606
|
{
|
|
405
607
|
const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
|
|
406
608
|
if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
|
|
407
|
-
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
|
|
609
|
+
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
|
|
408
610
|
if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
|
|
611
|
+
if (o.placement?.where) T.decisions.push(`Placement: ${o.placement.where}${o.placement.reason ? ` (${o.placement.reason})` : ""}`);
|
|
409
612
|
}
|
|
410
613
|
/** Last guard before a PR is created or merged: the default branch is never a lane. */
|
|
411
614
|
const laneGuard = (target, what) => {
|
|
@@ -430,20 +633,28 @@ export async function runCore(o) {
|
|
|
430
633
|
} else if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
|
|
431
634
|
return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
|
|
432
635
|
}
|
|
433
|
-
leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false
|
|
636
|
+
leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false,
|
|
637
|
+
...(o.placement ? { placement: o.placement } : {}) }; // #4256: the latest placement replaces an earlier run's
|
|
434
638
|
try { await writeLease(leaseObj()); } catch { /* best effort */ }
|
|
435
639
|
leaseTimer = setInterval(() => { writeLease(leaseObj()).catch(() => {}); }, o.leaseRenewMs ?? 120000);
|
|
436
640
|
leaseTimer.unref?.();
|
|
437
641
|
}
|
|
438
642
|
|
|
643
|
+
if (io.ledger) {
|
|
644
|
+
try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, host: myHost }); } catch { ledger = null; }
|
|
645
|
+
}
|
|
646
|
+
// A signal (engine-cli SIGTERM/SIGINT) runs the same teardown and lease release as any other exit.
|
|
647
|
+
o.onTeardown?.((signal) => finish("deferred", `interrupted by ${signal}`, { code: OUTCOME_CODES.RUN_INTERRUPTED }, OUTCOME_CODES.RUN_INTERRUPTED));
|
|
439
648
|
if (io.runTmp) {
|
|
440
649
|
try { await io.runTmp.prune?.(); } catch { /* best effort */ }
|
|
650
|
+
const planned = io.runTmp.path?.(runId);
|
|
651
|
+
if (planned) ledgerDo((l) => l.register("tmp", planned));
|
|
441
652
|
try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
|
|
653
|
+
if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
|
|
442
654
|
}
|
|
443
655
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
444
|
-
let iss;
|
|
445
656
|
try {
|
|
446
|
-
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
|
|
657
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]));
|
|
447
658
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
448
659
|
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
449
660
|
|
|
@@ -465,6 +676,10 @@ export async function runCore(o) {
|
|
|
465
676
|
return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
|
|
466
677
|
isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
|
|
467
678
|
}
|
|
679
|
+
// #4162: a run that died can leave its runner writing into this worktree, and uncommitted work in it. Stop the
|
|
680
|
+
// processes and save the work before the worktree is removed; the lease above makes this run the only one doing so.
|
|
681
|
+
await stopWorktreeProcs();
|
|
682
|
+
salvagedRef = await salvage("an earlier run");
|
|
468
683
|
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
|
|
469
684
|
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
470
685
|
// Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
|
|
@@ -481,10 +696,12 @@ export async function runCore(o) {
|
|
|
481
696
|
};
|
|
482
697
|
await leftoverDir();
|
|
483
698
|
const LOCK_RE = /could not lock|cannot lock ref|\.lock'?: File exists|Unable to create .*\.lock|index\.lock|packed-refs\.lock/i;
|
|
484
|
-
|
|
699
|
+
// #4221: tracked blobs >= sparseMaxKb stay out of the checkout (lockfiles/manifests always in); falls back to a full checkout.
|
|
700
|
+
const sparseMaxKb = Number.isFinite(o.sparseMaxKb) && o.sparseMaxKb >= 0 ? o.sparseMaxKb : DEFAULT_SPARSE_MAX_KB;
|
|
701
|
+
const addWorktree = async (args, ref) => {
|
|
485
702
|
let cleaned = false;
|
|
486
703
|
for (let i = 0; ; i++) {
|
|
487
|
-
try { return await io.git
|
|
704
|
+
try { return await addLaneWorktree(io.git, io.fs?.write, { cwd, wt, addArgs: args, ref, maxBlobBytes: sparseMaxKb * 1024 }); } catch (e) {
|
|
488
705
|
const msg = String(e?.stderr ?? e?.message ?? e);
|
|
489
706
|
if (!cleaned && /already exists/i.test(msg) && (await leftoverDir())) { cleaned = true; continue; }
|
|
490
707
|
if (i >= 4 || !LOCK_RE.test(msg)) throw e;
|
|
@@ -504,19 +721,106 @@ export async function runCore(o) {
|
|
|
504
721
|
keepLocal = containsRemote;
|
|
505
722
|
}
|
|
506
723
|
} catch { /* no local branch */ }
|
|
724
|
+
ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
|
|
725
|
+
let wtInfo = null;
|
|
507
726
|
if (keepLocal) {
|
|
508
|
-
await addWorktree(["worktree", "add", wt, branch]);
|
|
727
|
+
wtInfo = await addWorktree(["worktree", "add", wt, branch], `refs/heads/${branch}`);
|
|
509
728
|
resumed = resumed ?? { pr: null, local: true };
|
|
510
729
|
ev({ event: "LOCAL_BRANCH_REUSED", branch });
|
|
511
730
|
} else if (resumed) {
|
|
512
|
-
await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
|
|
731
|
+
wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`], `origin/${branch}`);
|
|
513
732
|
} else {
|
|
514
|
-
await addWorktree(["worktree", "add", "-B", branch, wt, base]);
|
|
733
|
+
wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
|
|
515
734
|
}
|
|
516
735
|
wtCreated = true;
|
|
736
|
+
if (wtInfo?.sparse) {
|
|
737
|
+
sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
|
|
738
|
+
ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
|
|
739
|
+
T.decisions.push(`Sparse worktree: ${wtInfo.excluded} tracked file(s) >= ${sparseMaxKb} KB (${Math.round(wtInfo.bytes / 1048576)} MB) left out of the checkout`);
|
|
740
|
+
} else if (wtInfo?.fallback) {
|
|
741
|
+
ev({ event: "WORKTREE_SPARSE_FALLBACK", reason: wtInfo.fallback });
|
|
742
|
+
T.anomalies.push(`Sparse checkout failed, full checkout used: ${wtInfo.fallback}`);
|
|
743
|
+
}
|
|
517
744
|
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
518
745
|
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
519
|
-
|
|
746
|
+
// #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
|
|
747
|
+
if (io.deps?.prepare) {
|
|
748
|
+
let res;
|
|
749
|
+
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) }]; }
|
|
750
|
+
for (const r of Array.isArray(res) ? res : []) {
|
|
751
|
+
ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
|
|
752
|
+
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` : ""}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// ---- 2b. toolchain pins + env.setup (#4163): before the builder; zero LLM. No pins and no setup changes nothing ----
|
|
756
|
+
try {
|
|
757
|
+
const tc = await (io.prepareToolchain ?? prepareToolchain)({ dir: wt, override: toolchainOverride, read: io.fs.read,
|
|
758
|
+
binDir: join(runTmp ?? join(wt, LEAN_DIR), "toolchain", "bin") });
|
|
759
|
+
if (tc?.summary) {
|
|
760
|
+
toolchain = { env: tc.env ?? null, summary: tc.summary };
|
|
761
|
+
ev({ event: "TOOLCHAIN_PINNED", pins: tc.summary, shims: tc.shims });
|
|
762
|
+
T.decisions.push(tc.env ? `Toolchain pinned on the runner PATH: ${tc.summary}` : `Toolchain pins detected (no shim): ${tc.summary}`);
|
|
763
|
+
}
|
|
764
|
+
if (tc?.warning) { T.anomalies.push(tc.warning); ev({ event: "TOOLCHAIN_WARNING", warning: tc.warning }); }
|
|
765
|
+
for (const n of tc?.notes ?? []) T.anomalies.push(n);
|
|
766
|
+
} catch (e) { T.anomalies.push(`Toolchain detection failed (host toolchain used): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
767
|
+
// ---- 2c. heavy-step shims (#4169): after the toolchain, so a heavy shim's real binary is the pinned toolchain shim ----
|
|
768
|
+
if (heavyOn) {
|
|
769
|
+
try {
|
|
770
|
+
const root = runTmp ?? join(wt, LEAN_DIR), binDir = join(root, "heavy", "bin"), path = toolchain?.env?.PATH ?? process.env.PATH ?? "";
|
|
771
|
+
const names = await (io.writeHeavyShims ?? writeHeavyShims)(binDir, { path });
|
|
772
|
+
if (names?.length) {
|
|
773
|
+
heavyWaitsFile = join(root, "heavy-waits.jsonl");
|
|
774
|
+
const pinned = /^\d+$/.test(String(process.env.FORGE_HOST_PARALLELISM ?? "").trim()) && Number(process.env.FORGE_HOST_PARALLELISM) > 0;
|
|
775
|
+
heavyEnv = { PATH: path ? `${binDir}${delimiter}${path}` : binDir, FORGE_HEAVY_WAITS: heavyWaitsFile, ...(pinned ? { FORGE_HEAVY_PARALLELISM_PINNED: "1" } : {}),
|
|
776
|
+
...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)])) };
|
|
777
|
+
ev({ event: "HEAVY_SHIMS", names });
|
|
778
|
+
}
|
|
779
|
+
} catch (e) { T.anomalies.push(`Heavy-step shims not written (builder steps ungated): ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
780
|
+
}
|
|
781
|
+
if (typeof envSetup === "string" && envSetup.trim()) {
|
|
782
|
+
let r;
|
|
783
|
+
if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
|
|
784
|
+
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) }; } }
|
|
785
|
+
ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
|
|
786
|
+
if (r?.exit !== 0) {
|
|
787
|
+
const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
|
|
788
|
+
T.anomalies.push(`Environment error: forge.yaml env.setup exited ${r?.exit ?? "?"}; the builder did not run`);
|
|
789
|
+
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);
|
|
790
|
+
}
|
|
791
|
+
T.decisions.push("forge.yaml env.setup ran once in the fresh worktree");
|
|
792
|
+
}
|
|
793
|
+
const names = (out) => String(out).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
794
|
+
// #4160: files the branch touched (three-dot) whose content still differs from the base tip (two-dot); a file merged
|
|
795
|
+
// in from the base by a base-sync equals the base and drops out of the scope check.
|
|
796
|
+
const changedFiles = async () => {
|
|
797
|
+
const touched = names(await git(["diff", "--name-only", `${base}...HEAD`]));
|
|
798
|
+
if (!touched.length) return touched;
|
|
799
|
+
const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
|
|
800
|
+
return touched.filter((f) => differs.has(f));
|
|
801
|
+
};
|
|
802
|
+
/** forge#4195: scripts/hotspots.sh facts in the worktree, or null (no io.hotspots, script failure): never fails a run. */
|
|
803
|
+
const hotspotsOf = async (args) => {
|
|
804
|
+
if (!io.hotspots) return null;
|
|
805
|
+
const h = await io.hotspots({ cwd: wt, ...args }).catch(() => null);
|
|
806
|
+
return h && typeof h === "object" && Array.isArray(h.files) ? h : null;
|
|
807
|
+
};
|
|
808
|
+
/** #4271: a bug lane with lean.repro_test on; refactor:split lanes never set it (no plan call, no repro_test). */
|
|
809
|
+
const reproOn = () => reproTest !== "off" && isBugIssue(iss?.labels);
|
|
810
|
+
let reproNoted = false;
|
|
811
|
+
/** #4271: the frozen repro_test's red-green words for precheck, or null (gate off, "none", no io.redGreen). */
|
|
812
|
+
const reproOf = async (c) => {
|
|
813
|
+
const t = c?.repro_test;
|
|
814
|
+
if (!reproOn() || !io.redGreen || !t || typeof t !== "object") {
|
|
815
|
+
if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
|
|
819
|
+
const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
820
|
+
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
821
|
+
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
822
|
+
return words;
|
|
823
|
+
};
|
|
520
824
|
|
|
521
825
|
// ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
|
|
522
826
|
let result = null, attemptN = 0, reviewN = 0;
|
|
@@ -524,11 +828,25 @@ export async function runCore(o) {
|
|
|
524
828
|
const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
|
|
525
829
|
const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
|
|
526
830
|
// Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
|
|
527
|
-
const
|
|
831
|
+
const trustedOf = (i) => (i.comments ?? []).filter((c) => {
|
|
528
832
|
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
529
833
|
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
530
834
|
});
|
|
531
|
-
const
|
|
835
|
+
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => c?.body ?? "")].join("\n\n");
|
|
836
|
+
let trustedComments = trustedOf(iss);
|
|
837
|
+
let issueText = textOf(iss, trustedComments);
|
|
838
|
+
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
839
|
+
async function refreshIssue(stage) {
|
|
840
|
+
let next;
|
|
841
|
+
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]))); } catch { return; }
|
|
842
|
+
if (!next || typeof next !== "object") return;
|
|
843
|
+
const tc = trustedOf(next), text = textOf(next, tc);
|
|
844
|
+
const old = contractHash(issueText), now = contractHash(text);
|
|
845
|
+
if (old === now) return;
|
|
846
|
+
iss = next; trustedComments = tc; issueText = text;
|
|
847
|
+
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
848
|
+
T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
|
|
849
|
+
}
|
|
532
850
|
/** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
|
|
533
851
|
async function endIfBlocked() {
|
|
534
852
|
let block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
@@ -571,73 +889,281 @@ export async function runCore(o) {
|
|
|
571
889
|
return found;
|
|
572
890
|
}
|
|
573
891
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
892
|
+
const isSplit = (iss.labels ?? []).some((l) => (l?.name ?? l) === SPLIT_LABEL);
|
|
893
|
+
/**
|
|
894
|
+
* #4211: the refactor:split step. One tool-less grouping call over the symbol index (none for a chained plan), the
|
|
895
|
+
* codemod move and its deterministic proof (bin/lean/refactor.mjs). A rerun on a branch that already holds the move
|
|
896
|
+
* re-verifies it instead. Returns {done} (needs-human, worktree left at its start commit) or the split result with a
|
|
897
|
+
* synthetic location-only contract for the renderers.
|
|
898
|
+
*/
|
|
899
|
+
async function splitStep() {
|
|
900
|
+
await label("building");
|
|
901
|
+
const mod = o.refactorModule ?? (await import("./refactor.mjs"));
|
|
902
|
+
const body = String(iss.body ?? "");
|
|
903
|
+
const precheckList = Array.isArray(o.precheck?.commands) ? o.precheck.commands : [];
|
|
904
|
+
let r;
|
|
905
|
+
if (hasCommits) {
|
|
906
|
+
const target = mod.parseSplitTarget(body) ?? mod.parseSplitPlan(body)?.target, testCmd = mod.parseTestCmd(body, precheckList);
|
|
907
|
+
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" };
|
|
908
|
+
if (r.ok) {
|
|
909
|
+
r = { ...r, target, testCmd, package: null, modules: {}, rest: null, grouped: false };
|
|
910
|
+
T.anomalies.push("Rerun of a split branch: the existing move was re-verified; a chained remainder is not recomputed");
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
let slot = await heavyAcquire("split"); // the grouper's LLM call gives the slot back while it runs (#4169)
|
|
914
|
+
try {
|
|
915
|
+
r = await mod.runSplitStep({ wt, base, issue, issueBody: body, precheckCommands: precheckList, runTmp, git, ...(o.splitMaxLines ? { maxLines: o.splitMaxLines } : {}),
|
|
916
|
+
callGrouper: async (prompt, { cwd } = {}) => {
|
|
917
|
+
try { slot?.release?.(); } catch { /* fail-open */ }
|
|
918
|
+
slot = null;
|
|
919
|
+
try {
|
|
920
|
+
await renew();
|
|
921
|
+
const rr = await callRunner({ role: "grouper", issue, cwd: cwd ?? runTmp ?? wt, model: reviewerModel, resume: null, system: GROUPING_SYSTEM, prompt, noTools: true });
|
|
922
|
+
await renew();
|
|
923
|
+
await attempt("group", 1, rr);
|
|
924
|
+
return rr?.text ?? "";
|
|
925
|
+
} finally { slot = await heavyAcquire("split"); }
|
|
926
|
+
} });
|
|
927
|
+
} finally { try { slot?.release?.(); } catch { /* fail-open */ } }
|
|
928
|
+
}
|
|
929
|
+
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 });
|
|
930
|
+
if (!r.ok) {
|
|
931
|
+
T.validate = ["SPLIT REFUSED", r.code]; T.plan = "skipped"; T.implement = "none (worktree reset)"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
932
|
+
return { done: await needsHuman(`refactor:split refused (${r.code}); nothing was pushed:\n${r.reason}`, r.code) };
|
|
933
|
+
}
|
|
934
|
+
const moved = await changedFiles();
|
|
935
|
+
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: [] };
|
|
936
|
+
const mods = Object.entries(r.modules ?? {});
|
|
937
|
+
try {
|
|
938
|
+
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\`\`\``);
|
|
939
|
+
} catch { /* best effort */ }
|
|
940
|
+
T.validate = ["CONFIRMED", `refactor:split \`${r.target}\``];
|
|
941
|
+
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` : ""}`);
|
|
942
|
+
T.implement = [`${moved.length} file(s)`, branch];
|
|
943
|
+
sessionId = null;
|
|
944
|
+
return { ...r, contract };
|
|
578
945
|
}
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
|
|
587
|
-
T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
|
|
588
|
-
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);
|
|
589
|
-
if (closes) {
|
|
590
|
-
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
591
|
-
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
592
|
-
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
593
|
-
await label("invalid");
|
|
594
|
-
return await finish("invalid", result.summary ?? result.status);
|
|
595
|
-
}
|
|
596
|
-
const blocked = await endIfBlocked();
|
|
597
|
-
if (blocked) return blocked;
|
|
598
|
-
let contract = await readContract();
|
|
599
|
-
let vc = validateContract(contract, issueText);
|
|
600
|
-
if (!vc.ok && sessionId) {
|
|
601
|
-
T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
|
|
602
|
-
await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
|
|
603
|
-
contract = await readContract();
|
|
604
|
-
vc = validateContract(contract, issueText);
|
|
605
|
-
}
|
|
606
|
-
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
607
|
-
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);
|
|
608
|
-
frozen = contract;
|
|
609
|
-
hash = contractHash(contractJson(frozen));
|
|
610
|
-
let body = renderContract(frozen, hash);
|
|
611
|
-
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
|
|
612
|
-
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
613
|
-
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
614
|
-
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
615
|
-
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
946
|
+
let frozen = null, hash = null, planned = false, files = [], split = null;
|
|
947
|
+
if (isSplit) {
|
|
948
|
+
// ---- 3'. refactor:split (#4211): deterministic move + proof replaces the builder; no builder session exists ----
|
|
949
|
+
split = await splitStep();
|
|
950
|
+
if (split.done) return split.done;
|
|
951
|
+
frozen = split.contract; hash = contractHash(contractJson(frozen));
|
|
952
|
+
files = await changedFiles();
|
|
616
953
|
} else {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
954
|
+
if (resumed) {
|
|
955
|
+
const fc = await readFrozenContract();
|
|
956
|
+
if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
957
|
+
}
|
|
958
|
+
await label("building");
|
|
959
|
+
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
960
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn());
|
|
961
|
+
if (!frozen) {
|
|
962
|
+
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, planPrompt);
|
|
963
|
+
planned = true;
|
|
964
|
+
result = await readResult();
|
|
965
|
+
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
966
|
+
const closes = ["invalid", "already-fixed"].includes(result?.status);
|
|
967
|
+
if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
|
|
968
|
+
T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
|
|
969
|
+
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);
|
|
970
|
+
if (closes) {
|
|
971
|
+
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
972
|
+
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
973
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
974
|
+
await label("invalid");
|
|
975
|
+
return await finish("invalid", result.summary ?? result.status);
|
|
976
|
+
}
|
|
977
|
+
const blocked = await endIfBlocked();
|
|
978
|
+
if (blocked) return blocked;
|
|
979
|
+
let contract = await readContract();
|
|
980
|
+
await refreshIssue("contract validation");
|
|
981
|
+
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
982
|
+
if (!vc.ok && sessionId) {
|
|
983
|
+
T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
|
|
984
|
+
await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, planPrompt);
|
|
985
|
+
contract = await readContract();
|
|
986
|
+
await refreshIssue("contract validation");
|
|
987
|
+
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
988
|
+
}
|
|
989
|
+
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
990
|
+
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);
|
|
991
|
+
frozen = contract;
|
|
992
|
+
hash = contractHash(contractJson(frozen));
|
|
993
|
+
let body = renderContract(frozen, hash);
|
|
994
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
|
|
995
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
996
|
+
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
997
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
998
|
+
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
999
|
+
} else {
|
|
1000
|
+
T.validate = "resumed (existing branch)";
|
|
1001
|
+
}
|
|
1002
|
+
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1003
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1004
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
|
|
1005
|
+
liveKeys = ["implementation"];
|
|
1006
|
+
stage = "implement";
|
|
1007
|
+
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
1008
|
+
stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
|
|
1009
|
+
result = await readResult();
|
|
1010
|
+
{
|
|
1011
|
+
const blocked = await endIfBlocked();
|
|
1012
|
+
if (blocked) return blocked;
|
|
1013
|
+
}
|
|
1014
|
+
files = await changedFiles();
|
|
1015
|
+
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);
|
|
1016
|
+
await flushStages(result, ["implementation"]);
|
|
1017
|
+
T.implement = [`${files.length} file(s)`, branch];
|
|
1018
|
+
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ---- 3a. declared check commands (#4240): the repo's own cheap gates (forge.yaml learned.test_commands), run locally in
|
|
1022
|
+
// the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
|
|
1023
|
+
// verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
|
|
1024
|
+
let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
|
|
1025
|
+
const precheckCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1026
|
+
if (precheckCommands.length && io.runCommand) {
|
|
1027
|
+
for (let round = 0; ; round++) {
|
|
1028
|
+
await renew();
|
|
1029
|
+
const results = [];
|
|
1030
|
+
for (const command of precheckCommands) {
|
|
1031
|
+
let r;
|
|
1032
|
+
try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1033
|
+
results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
|
|
1034
|
+
}
|
|
1035
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })) });
|
|
1036
|
+
const failed = results.filter((r) => r.rc !== 0);
|
|
1037
|
+
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
1038
|
+
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
1039
|
+
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);
|
|
1040
|
+
checkCommandsFailing = true;
|
|
1041
|
+
if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
|
|
1042
|
+
T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
|
|
1043
|
+
const before = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1044
|
+
const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
|
|
1045
|
+
await builder(++attemptN, precheckFailurePrompt(capped, vcfg.mode), sessionId);
|
|
1046
|
+
await upsertStage("implementation");
|
|
1047
|
+
result = (await readResult()) ?? result;
|
|
1048
|
+
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; }
|
|
1049
|
+
files = await changedFiles();
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ---- 3b. structure delta (forge#4159): deterministic, before the conformance review; no reviewer run of its own ----
|
|
1054
|
+
// A package.json edge between workspace packages the contract does not name blocks (one builder resume) in either
|
|
1055
|
+
// conformance mode; import-pair growth and boundary-config loosening only add one line to the reviewer prompt.
|
|
1056
|
+
const structureOf = async () => {
|
|
1057
|
+
if (!io.structureDelta) return null;
|
|
1058
|
+
const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
|
|
1059
|
+
return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
|
|
1060
|
+
};
|
|
1061
|
+
let structure = await structureOf();
|
|
1062
|
+
if (structure) {
|
|
1063
|
+
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
1064
|
+
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
1065
|
+
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) };
|
|
1066
|
+
ev(rec); // run log only: the telemetry write is the merge-time record (forge#4165)
|
|
1067
|
+
const undeclared = () => unnamedEdges(applyAmendments(frozen, result?.amendments, issueText).contract, structure);
|
|
1068
|
+
let edges = undeclared();
|
|
1069
|
+
if (edges.length && sessionId) {
|
|
1070
|
+
T.decisions.push(`Undeclared package edge(s) ${edges.map((e) => `${e.from}→${e.to}`).join(", ")}; builder resumed once`);
|
|
1071
|
+
await builder(++attemptN, structurePrompt(edges, vcfg.mode), sessionId);
|
|
1072
|
+
await upsertStage("implementation");
|
|
1073
|
+
result = (await readResult()) ?? result;
|
|
1074
|
+
structure = (await structureOf()) ?? structure;
|
|
1075
|
+
edges = undeclared();
|
|
1076
|
+
}
|
|
1077
|
+
if (edges.length) {
|
|
1078
|
+
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);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// ---- 3c. the target repo's devdocs freshness rule, when it has one: deterministic, before any PR, so a lane does not
|
|
1082
|
+
// pay for a failed required check. Exit 1 resumes the builder once; still failing is needs-human docs.stale.
|
|
1083
|
+
if (io.docsFreshness) {
|
|
1084
|
+
let docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1085
|
+
if (docs && !docs.ok && sessionId) {
|
|
1086
|
+
ev({ event: "DOCS_STALE", round: 1 });
|
|
1087
|
+
T.decisions.push("Docs freshness check failed; builder resumed once");
|
|
1088
|
+
await builder(++attemptN, docsPrompt(docs.output), sessionId);
|
|
1089
|
+
await upsertStage("implementation");
|
|
1090
|
+
result = (await readResult()) ?? result;
|
|
1091
|
+
docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1092
|
+
}
|
|
1093
|
+
if (docs && !docs.ok) {
|
|
1094
|
+
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);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const structureLine = structureNote(structure);
|
|
1098
|
+
|
|
1099
|
+
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
1100
|
+
// A red run resumes the builder with the failed jobs' logs (verify.max_rounds), slow CI ends ci-pending, and CI that
|
|
1101
|
+
// cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
|
|
1102
|
+
let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
|
|
1103
|
+
const ciSeenRuns = [];
|
|
1104
|
+
/** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
|
|
1105
|
+
async function ciVerify(trigger) {
|
|
1106
|
+
let fixes = 0;
|
|
1107
|
+
for (;;) {
|
|
1108
|
+
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1109
|
+
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
1110
|
+
files = await changedFiles();
|
|
1111
|
+
const v = await runCiVerify({ io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns,
|
|
1112
|
+
push: () => gitPush(["push", "-u", "origin", branch]),
|
|
1113
|
+
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } });
|
|
1114
|
+
if (v.runId != null) ciSeenRuns.push(v.runId);
|
|
1115
|
+
const rec = { event: "CI_VERIFY", trigger, mode: vcfg.mode, round: ciRounds, result: v.result, skipped: v.result === "skipped", run_url: v.runUrl ?? null, failures: v.failures.map((f) => f.job) };
|
|
1116
|
+
ev(rec); await emit({ ...rec, phase: "verify" });
|
|
1117
|
+
const row = (res) => { T.verify = [res, `mode ${vcfg.mode}, ${ciRounds} fix round(s)${v.runUrl ? `, ${v.runUrl}` : ""}`]; };
|
|
1118
|
+
if (v.result === "pass" || v.result === "skipped") {
|
|
1119
|
+
ciVerifiedHead = head;
|
|
1120
|
+
row(v.result === "pass" ? `CI green at ${head.slice(0, 12)}` : "skipped (docs-only change)");
|
|
1121
|
+
if (v.result === "skipped") T.decisions.push(`CI verification skipped: ${v.detail}`);
|
|
1122
|
+
return { fixes };
|
|
1123
|
+
}
|
|
1124
|
+
if (v.result === "timeout") {
|
|
1125
|
+
row("CI pending");
|
|
1126
|
+
T.anomalies.push("CI verification still pending at the wait budget");
|
|
1127
|
+
return { done: await finish("ci-pending", `CI verification of ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
|
|
1128
|
+
}
|
|
1129
|
+
if (v.result === "unavailable") {
|
|
1130
|
+
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.`) };
|
|
1131
|
+
ciFellBack = true;
|
|
1132
|
+
row("unavailable: local fallback");
|
|
1133
|
+
T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
|
|
1134
|
+
await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null);
|
|
1135
|
+
await upsertStage("implementation");
|
|
1136
|
+
result = (await readResult()) ?? result;
|
|
1137
|
+
return { fixes: fixes + 1 };
|
|
1138
|
+
}
|
|
1139
|
+
// fail
|
|
1140
|
+
const jobs = v.failures.map((f) => f.job).join(", ") || "unknown job";
|
|
1141
|
+
row(`CI red (${jobs})`);
|
|
1142
|
+
if (ciRounds >= vcfg.maxRounds || !sessionId) {
|
|
1143
|
+
return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${v.failures.slice(0, 10).map((f) => `- ${f.job}${f.url ? ` (${f.url})` : ""}`).join("\n")}`, OUTCOME_CODES.CI_RED) };
|
|
1144
|
+
}
|
|
1145
|
+
ciRounds++; fixes++;
|
|
1146
|
+
T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1147
|
+
ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
|
|
1148
|
+
await builder(++attemptN, ciFailurePrompt(v.failures, vcfg.mode), sessionId);
|
|
1149
|
+
await upsertStage("implementation");
|
|
1150
|
+
result = (await readResult()) ?? result;
|
|
1151
|
+
if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
|
|
1152
|
+
return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED) };
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
634
1156
|
|
|
635
1157
|
// ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
|
|
636
1158
|
// enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
|
|
637
|
-
// but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression)
|
|
1159
|
+
// but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression),
|
|
1160
|
+
// plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
|
|
1161
|
+
// A review without usable rows ends needs-human (or, with engine.review_incomplete: degraded, merges flagged).
|
|
638
1162
|
await label("in-review");
|
|
639
1163
|
const shadow = conformance !== "enforce";
|
|
640
|
-
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
|
|
1164
|
+
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false;
|
|
1165
|
+
const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
|
|
1166
|
+
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).";
|
|
641
1167
|
async function amend() {
|
|
642
1168
|
const a = applyAmendments(frozen, result?.amendments, issueText);
|
|
643
1169
|
const sig = JSON.stringify(a.applied);
|
|
@@ -655,16 +1181,22 @@ export async function runCore(o) {
|
|
|
655
1181
|
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
656
1182
|
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
657
1183
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
658
|
-
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
|
|
659
|
-
let d = null;
|
|
1184
|
+
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
|
|
1185
|
+
let d = null, first = null;
|
|
660
1186
|
for (let t = 0; t < 2; t++) {
|
|
1187
|
+
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
1188
|
+
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
661
1189
|
await renew();
|
|
662
1190
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
663
|
-
prompt:
|
|
1191
|
+
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
1192
|
+
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
|
|
664
1193
|
await renew();
|
|
665
1194
|
await attempt("review", ++reviewN, rr);
|
|
666
|
-
|
|
1195
|
+
const parsed = parseReview(rr?.text);
|
|
1196
|
+
review = repair ? mergeReviewRows(first, parsed) : parsed;
|
|
1197
|
+
first = review;
|
|
667
1198
|
d = decide(review, ctx);
|
|
1199
|
+
if (d.review) review = d.review;
|
|
668
1200
|
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
669
1201
|
if (!d.state.startsWith("review.")) return d;
|
|
670
1202
|
}
|
|
@@ -672,63 +1204,109 @@ export async function runCore(o) {
|
|
|
672
1204
|
}
|
|
673
1205
|
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
674
1206
|
async function gate() {
|
|
1207
|
+
await refreshIssue("conformance");
|
|
675
1208
|
result = (await readResult()) ?? result;
|
|
676
1209
|
files = await changedFiles();
|
|
677
1210
|
const amendProblems = await amend();
|
|
678
|
-
const
|
|
1211
|
+
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1212
|
+
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1213
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
|
|
679
1214
|
const pre = [...pc.failures, ...amendProblems];
|
|
680
1215
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
681
1216
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
1217
|
+
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
1218
|
+
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
|
|
1219
|
+
if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
|
|
1220
|
+
// #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
|
|
1221
|
+
if (triggerMode !== "off") {
|
|
1222
|
+
let t = null;
|
|
1223
|
+
try { t = computeTrigger({ files, riskPaths, structure, hotspots, gates: { precheckFailures: pre, checkCommandsFailing, ciFellBack }, contract: effective }); } catch { /* fall back to reviewing */ }
|
|
1224
|
+
const skip = triggerMode === "enforce" && t?.would_skip === true;
|
|
1225
|
+
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 });
|
|
1226
|
+
if (skip) {
|
|
1227
|
+
triggerSkipped = true;
|
|
1228
|
+
return { state: "conforms", by: "trigger", failures: [], ...(shadow ? { would: "conforms", wouldFail: [], reviewState: null } : {}) };
|
|
1229
|
+
}
|
|
1230
|
+
triggerSkipped = false;
|
|
1231
|
+
}
|
|
682
1232
|
const d = await reviewOnce();
|
|
683
1233
|
if (!shadow) return { ...d, by: "review" };
|
|
684
1234
|
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
685
1235
|
const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
|
|
686
1236
|
const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
|
|
1237
|
+
if (d.state.startsWith("review.")) return { ...d, by: "review", would, wouldFail, reviewState: d.state };
|
|
687
1238
|
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
688
|
-
return { state:
|
|
1239
|
+
return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
689
1240
|
}
|
|
690
|
-
/** Shape the base-sync path expects:
|
|
1241
|
+
/** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
|
|
691
1242
|
const doReview = async () => {
|
|
692
1243
|
const d = await gate();
|
|
693
|
-
if (d.state.startsWith("review.")) return
|
|
1244
|
+
if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
|
|
694
1245
|
return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
|
|
695
1246
|
};
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if (
|
|
700
|
-
|
|
701
|
-
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
702
|
-
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);
|
|
703
|
-
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
704
|
-
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);
|
|
705
|
-
resumes++;
|
|
706
|
-
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`);
|
|
707
|
-
await builder(++attemptN, conformanceResumePrompt(outcome.failures), sessionId);
|
|
708
|
-
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
709
|
-
}
|
|
710
|
-
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
711
|
-
const toFile = pendingTracking(effective);
|
|
712
|
-
if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
|
|
713
|
-
if (shadow) {
|
|
714
|
-
const wf = verdict.wouldFail ?? [];
|
|
715
|
-
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
716
|
-
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
717
|
-
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
718
|
-
await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
|
|
1247
|
+
let degradedReq = null;
|
|
1248
|
+
if (isSplit) {
|
|
1249
|
+
// #4211: location-only change proved by refactor-verify; no conformance reviewer (the optional review is not spent)
|
|
1250
|
+
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
1251
|
+
T.review = "skipped (refactor-verify passed: location-only change)";
|
|
719
1252
|
} else {
|
|
720
|
-
|
|
721
|
-
|
|
1253
|
+
for (;;) {
|
|
1254
|
+
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
1255
|
+
const outcome = await gate();
|
|
1256
|
+
verdict = outcome;
|
|
1257
|
+
if (outcome.state === "conforms") break;
|
|
1258
|
+
const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1259
|
+
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1260
|
+
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1261
|
+
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
1262
|
+
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`);
|
|
1263
|
+
break;
|
|
1264
|
+
}
|
|
1265
|
+
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);
|
|
1266
|
+
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
1267
|
+
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);
|
|
1268
|
+
resumes++;
|
|
1269
|
+
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`);
|
|
1270
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
|
|
1271
|
+
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
1272
|
+
}
|
|
1273
|
+
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
1274
|
+
const toFile = pendingTracking(effective);
|
|
1275
|
+
if (toFile.length) await track(toFile, "accepted by the conformance review");
|
|
1276
|
+
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;
|
|
1277
|
+
if (degradedReq) await track([degradedReq], "the conformance review stayed incomplete");
|
|
1278
|
+
if (triggerSkipped) {
|
|
1279
|
+
T.decisions.push("Conformance reviewer skipped by review.trigger enforce: no trigger reason (risk path, package edge, hotspot, failed or ambiguous gate)");
|
|
1280
|
+
if (shadow) {
|
|
1281
|
+
shadowWould = verdict.would;
|
|
1282
|
+
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: [] });
|
|
1283
|
+
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: [] });
|
|
1284
|
+
}
|
|
1285
|
+
T.review = [TRIGGER_SKIP, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1286
|
+
await post("FORGE:REVIEW", triggerSkipNote);
|
|
1287
|
+
} else if (shadow) {
|
|
1288
|
+
const wf = verdict.wouldFail ?? [];
|
|
1289
|
+
shadowWould = verdict.would;
|
|
1290
|
+
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1291
|
+
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
1292
|
+
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1293
|
+
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) }));
|
|
1294
|
+
} else {
|
|
1295
|
+
T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
1296
|
+
await post("FORGE:REVIEW", renderReview(review, effective));
|
|
1297
|
+
}
|
|
722
1298
|
}
|
|
723
1299
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
724
|
-
const closing = closePlan(
|
|
1300
|
+
const closing = closePlan(effective);
|
|
1301
|
+
if (degradedReq?.tracked && !closing.tracked.includes(degradedReq.tracked)) closing.tracked.push(degradedReq.tracked);
|
|
725
1302
|
|
|
726
1303
|
// ---- 5. push + PR ----
|
|
727
1304
|
files = await changedFiles();
|
|
728
1305
|
await gitPush(["push", "-u", "origin", branch]);
|
|
729
1306
|
laneGuard(lane, "open a PR");
|
|
730
1307
|
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
731
|
-
"--title", `${iss.title} (#${issue})`, "--body",
|
|
1308
|
+
"--title", `${iss.title} (#${issue})`, "--body", isSplit ? (o.refactorModule ?? (await import("./refactor.mjs"))).renderSplitPrBody({ issue, ...split })
|
|
1309
|
+
: renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
|
|
732
1310
|
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
733
1311
|
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
734
1312
|
ev({ event: "PR_OPENED", pr });
|
|
@@ -736,6 +1314,11 @@ export async function runCore(o) {
|
|
|
736
1314
|
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 })]); }
|
|
737
1315
|
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; }
|
|
738
1316
|
}
|
|
1317
|
+
if (degraded) {
|
|
1318
|
+
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 */ }
|
|
1319
|
+
try { await ghw(["pr", "edit", String(pr), "-R", repo, "--add-label", REVIEW_DEGRADED_LABEL]); }
|
|
1320
|
+
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; }
|
|
1321
|
+
}
|
|
739
1322
|
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
740
1323
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
741
1324
|
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
@@ -765,6 +1348,20 @@ export async function runCore(o) {
|
|
|
765
1348
|
}
|
|
766
1349
|
return "unknown";
|
|
767
1350
|
};
|
|
1351
|
+
/** #4158: MERGEABLE but BLOCKED is a base branch policy block (ruleset / required checks pending), not a conflict. */
|
|
1352
|
+
const policyBlocked = async () => {
|
|
1353
|
+
let j;
|
|
1354
|
+
try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
|
|
1355
|
+
return String(j.mergeable ?? "").toUpperCase() === "MERGEABLE" && String(j.mergeStateStatus ?? "").toUpperCase() === "BLOCKED";
|
|
1356
|
+
};
|
|
1357
|
+
/** #4266: one mergeability read inside the CI wait: "conflict", "mergeable", or null (unknown / unreadable / other). */
|
|
1358
|
+
const waitMergeState = async () => {
|
|
1359
|
+
let j;
|
|
1360
|
+
try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return null; }
|
|
1361
|
+
const m = String(j.mergeable ?? "").toUpperCase(), st = String(j.mergeStateStatus ?? "").toUpperCase();
|
|
1362
|
+
return m === "CONFLICTING" || st === "DIRTY" ? "conflict" : m === "MERGEABLE" ? "mergeable" : null;
|
|
1363
|
+
};
|
|
1364
|
+
let blockStartedAt = null, blockPollIndex = 0;
|
|
768
1365
|
const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
769
1366
|
const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
|
|
770
1367
|
|| (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
|
|
@@ -779,12 +1376,16 @@ export async function runCore(o) {
|
|
|
779
1376
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
780
1377
|
let clean = true, files = [];
|
|
781
1378
|
try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
|
|
1379
|
+
if (isSplit && !clean) { // #4211: no builder resolves a split's conflicts; a fresh run re-splits on the new base
|
|
1380
|
+
await gitOut(["merge", "--abort"]);
|
|
1381
|
+
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);
|
|
1382
|
+
}
|
|
782
1383
|
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
|
|
783
1384
|
if (clean) {
|
|
784
1385
|
how = "clean merge of the base into the branch";
|
|
785
1386
|
} else {
|
|
786
1387
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
787
|
-
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes,
|
|
1388
|
+
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge. Do not push.`;
|
|
788
1389
|
await builder(++attemptN, prompt, sessionId ?? null);
|
|
789
1390
|
if (await unresolved()) {
|
|
790
1391
|
await gitOut(["merge", "--abort"]);
|
|
@@ -797,16 +1398,23 @@ export async function runCore(o) {
|
|
|
797
1398
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
798
1399
|
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
799
1400
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
800
|
-
|
|
1401
|
+
let ciFixes = 0;
|
|
1402
|
+
if (isSplit && after !== before) { // #4211: the merged head must still be a pure move against the new base
|
|
1403
|
+
const rv = await (o.refactorModule ?? (await import("./refactor.mjs"))).reverifySplit({ wt, base, target: split.target, testCmd: split.testCmd, runTmp, git });
|
|
1404
|
+
if (!rv.ok) return await needsHuman(`refactor:split no longer verifies after merging ${base}: ${rv.reason}`, rv.code);
|
|
1405
|
+
rereview = "refactor-verify re-run against the new base: ok";
|
|
1406
|
+
}
|
|
1407
|
+
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1408
|
+
if (!isSplit && (!clean || ciFixes)) {
|
|
801
1409
|
let rv = await doReview();
|
|
802
1410
|
if (rv?.fail) {
|
|
803
1411
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
804
|
-
await builder(++attemptN, conformanceResumePrompt(rv.failures), sessionId ?? null);
|
|
1412
|
+
await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
|
|
805
1413
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
806
1414
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
807
1415
|
rv = await doReview();
|
|
808
1416
|
}
|
|
809
|
-
if (
|
|
1417
|
+
if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
|
|
810
1418
|
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
811
1419
|
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
812
1420
|
}
|
|
@@ -817,6 +1425,75 @@ export async function runCore(o) {
|
|
|
817
1425
|
return null;
|
|
818
1426
|
}
|
|
819
1427
|
const NR_TESTS = "not reported";
|
|
1428
|
+
/**
|
|
1429
|
+
* #4240: a required check failed at merge (any verify mode). One CI fix round in the builder session with the failed
|
|
1430
|
+
* logs, shared with ciVerify's verify.max_rounds budget; the fixed head is re-reviewed and pushed, then the wait restarts.
|
|
1431
|
+
* Returns null to wait again, or a terminal result (rounds exhausted, no session, no new commit, review failure).
|
|
1432
|
+
*/
|
|
1433
|
+
async function mergeFixRound(checks) {
|
|
1434
|
+
const jobs = checks.map((c) => c.name);
|
|
1435
|
+
if (ciRounds >= vcfg.maxRounds || !sessionId) {
|
|
1436
|
+
return await needsHuman(`Required check(s) failed on PR #${pr}: ${jobs.join(", ")}${ciRounds ? ` (after ${ciRounds} CI fix round(s))` : ""}`, OUTCOME_CODES.CI_RED);
|
|
1437
|
+
}
|
|
1438
|
+
const failures = await collectFailedLogs(io, checks);
|
|
1439
|
+
ciRounds++;
|
|
1440
|
+
T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
1441
|
+
ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
|
|
1442
|
+
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
1443
|
+
await builder(++attemptN, ciFailurePrompt(failures, vcfg.mode), sessionId);
|
|
1444
|
+
await upsertStage("implementation");
|
|
1445
|
+
result = (await readResult()) ?? result;
|
|
1446
|
+
if ((await gitOut(["rev-parse", "HEAD"])) === before) {
|
|
1447
|
+
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);
|
|
1448
|
+
}
|
|
1449
|
+
const rv = await doReview(); // never merge an unreviewed head
|
|
1450
|
+
if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1451
|
+
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1452
|
+
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1453
|
+
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
/** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
|
|
1457
|
+
async function deleteMergedBranch() {
|
|
1458
|
+
const skip = (why) => { ev({ event: "BRANCH_DELETE_SKIPPED", branch, why }); T.decisions.push(`Lane branch kept: ${why}`); };
|
|
1459
|
+
if (protectedBranch(branch)) return skip("protected branch name");
|
|
1460
|
+
for (const side of ["--head", "--base"]) {
|
|
1461
|
+
let open;
|
|
1462
|
+
try { open = JSON.parse(String(await ghc(["pr", "list", "-R", repo, side, branch, "--state", "open", "--json", "number"])) || "[]"); } catch { return skip("open-PR check failed"); }
|
|
1463
|
+
if (open.length) return skip(`open PR #${open[0].number} uses it as ${side.slice(2)}`);
|
|
1464
|
+
}
|
|
1465
|
+
deleteLocalBranch = true;
|
|
1466
|
+
try { await io.git(["push", "origin", "--delete", branch], { cwd }); ev({ event: "BRANCH_DELETED", branch, where: "origin" }); }
|
|
1467
|
+
catch (e) { ev({ event: "BRANCH_DELETE_FAILED", branch, where: "origin", error: String(e?.stderr ?? e?.message ?? e).slice(0, 300) }); }
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* forge#4165: one STRUCTURE_DELTA telemetry record per merged run, from scripts/structure-delta.sh (the only place the
|
|
1472
|
+
* delta is computed) plus one local `git diff --numstat` for growth and the contract's decision provenance. Local git
|
|
1473
|
+
* only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
|
|
1474
|
+
*/
|
|
1475
|
+
async function recordStructure(pr) {
|
|
1476
|
+
const d = (await structureOf()) ?? {};
|
|
1477
|
+
const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
|
|
1478
|
+
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}/`))
|
|
1479
|
+
.map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
|
|
1480
|
+
const pairs = Array.isArray(d.import_pairs) ? d.import_pairs : [], sum = (k) => pairs.reduce((n, x) => n + (Number(x?.[k]) || 0), 0);
|
|
1481
|
+
const edge = (e) => ({ from: e?.from, to: e?.to });
|
|
1482
|
+
const s = {
|
|
1483
|
+
deps_added: (d.package_edges_added ?? []).map((e) => ({ ...edge(e), kind: e?.kind, manifest: e?.manifest })),
|
|
1484
|
+
deps_removed: (d.package_edges_removed ?? []).map((e) => ({ ...edge(e), kind: e?.kind, manifest: e?.manifest })),
|
|
1485
|
+
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 })),
|
|
1486
|
+
removed_edges: pairs.filter((x) => x?.files_removed > 0).map((x) => ({ ...edge(x), files: x.files_removed })),
|
|
1487
|
+
top_growth,
|
|
1488
|
+
decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })),
|
|
1489
|
+
};
|
|
1490
|
+
T.structure = s;
|
|
1491
|
+
const rec = { event: "STRUCTURE_DELTA", phase: "merge", pr, ...(milestone ? { milestone } : {}), ...s,
|
|
1492
|
+
edges_added: s.deps_added.length, edges_removed: s.deps_removed.length, import_files_added: sum("files_added"), import_files_removed: sum("files_removed"),
|
|
1493
|
+
boundary_changed: Boolean(d.boundary_config_changed?.changed), boundary_loosened: Boolean(d.boundary_config_changed?.loosens) };
|
|
1494
|
+
ev(rec);
|
|
1495
|
+
await emit(rec);
|
|
1496
|
+
}
|
|
820
1497
|
|
|
821
1498
|
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
822
1499
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
@@ -827,22 +1504,40 @@ export async function runCore(o) {
|
|
|
827
1504
|
// merge right after review
|
|
828
1505
|
T.merge = [policyText, "merged right after review"];
|
|
829
1506
|
} else {
|
|
830
|
-
|
|
831
|
-
let pollIndex = 0, gateHead = null;
|
|
1507
|
+
let startedAt = now();
|
|
1508
|
+
let pollIndex = 0, gateHead = null, conflicted = false, notStartedLogged = false;
|
|
832
1509
|
for (;;) {
|
|
833
1510
|
await renew();
|
|
1511
|
+
let states = [];
|
|
1512
|
+
// a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
|
|
1513
|
+
const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
|
|
834
1514
|
if (policy === "all") {
|
|
835
1515
|
const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
|
|
836
1516
|
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
837
1517
|
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
838
1518
|
} else {
|
|
839
|
-
let states = [];
|
|
840
1519
|
try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
|
|
841
|
-
const
|
|
1520
|
+
const check = (n) => states.find((x) => x.name === n && current(x));
|
|
1521
|
+
const st = (n) => check(n)?.state ?? "pending";
|
|
842
1522
|
const failed = required.filter((n) => st(n) === "failure");
|
|
843
|
-
if (failed.length)
|
|
1523
|
+
if (failed.length) {
|
|
1524
|
+
const r = await mergeFixRound(failed.map((n) => ({ name: n, url: check(n)?.url ?? null })));
|
|
1525
|
+
if (r) return r;
|
|
1526
|
+
startedAt = now(); pollIndex = 0; // the fixed head gets the full wait budget
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
844
1529
|
if (required.every((n) => st(n) === "success")) break;
|
|
845
1530
|
}
|
|
1531
|
+
// #4266: a PR that turns conflicting gets no merge ref, so its pull_request workflows never start: base-sync now.
|
|
1532
|
+
const ms = await waitMergeState();
|
|
1533
|
+
if (ms === "conflict") { conflicted = true; break; }
|
|
1534
|
+
if (policy !== "all" && ms === "mergeable" && !notStartedLogged && now() - startedAt >= CI_NOT_STARTED_GRACE_MS
|
|
1535
|
+
&& required.every((n) => !states.some((x) => x.name === n && current(x)))) {
|
|
1536
|
+
notStartedLogged = true;
|
|
1537
|
+
const minutes = Math.round((now() - startedAt) / 60000);
|
|
1538
|
+
ev({ event: "CI_NOT_STARTED", pr, checks: required, minutes });
|
|
1539
|
+
T.anomalies.push(`Required check(s) ${required.join(", ")} not started on PR #${pr} after ${minutes} min (missing workflow trigger?)`);
|
|
1540
|
+
}
|
|
846
1541
|
if (now() - startedAt >= ciMaxMinutes * 60000) {
|
|
847
1542
|
// Slow CI is never a needs-human: leave the PR open and resumable.
|
|
848
1543
|
ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
|
|
@@ -852,6 +1547,10 @@ export async function runCore(o) {
|
|
|
852
1547
|
onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
|
|
853
1548
|
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
854
1549
|
}
|
|
1550
|
+
if (conflicted) {
|
|
1551
|
+
const r = await baseSync("PR became conflicting while waiting for CI"); if (r) return r;
|
|
1552
|
+
continue; // wait again on the synced head with a fresh budget
|
|
1553
|
+
}
|
|
855
1554
|
if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
|
|
856
1555
|
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);
|
|
857
1556
|
}
|
|
@@ -871,20 +1570,47 @@ export async function runCore(o) {
|
|
|
871
1570
|
await sleepFn(Math.min(60000, 1000 * 2 ** (mergeRetries - 1)) + Math.floor(random() * 250));
|
|
872
1571
|
continue;
|
|
873
1572
|
}
|
|
1573
|
+
if (await policyBlocked()) {
|
|
1574
|
+
// Policy block: wait and retry at the reviewed head; never base-sync, never base.conflict (#4158).
|
|
1575
|
+
if (blockStartedAt === null) {
|
|
1576
|
+
blockStartedAt = now();
|
|
1577
|
+
T.decisions.push("merge blocked by base branch policy (ruleset/required checks); waiting");
|
|
1578
|
+
T.anomalies.push(`gh pr merge blocked by base branch policy: ${msg.split("\n")[0].slice(0, 120)}`);
|
|
1579
|
+
}
|
|
1580
|
+
if (now() - blockStartedAt >= ciMaxMinutes * 60000) {
|
|
1581
|
+
T.merge = [policyText, "blocked by base branch policy"];
|
|
1582
|
+
return await finish("ci-pending", `PR #${pr} blocked by base branch policy after ${ciMaxMinutes} min (checks pending or ruleset requirement unmet)`, { pr });
|
|
1583
|
+
}
|
|
1584
|
+
onProgress({ event: "phase_paused", phase: "merge", detail: `PR #${pr} blocked by base branch policy` });
|
|
1585
|
+
await sleep(ciPollDelayMs(blockPollIndex++, random));
|
|
1586
|
+
await renew();
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
874
1589
|
if (!MERGE_CONFLICT_RE.test(msg)) throw e;
|
|
875
1590
|
const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
|
|
876
1591
|
}
|
|
877
1592
|
}
|
|
1593
|
+
try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
|
|
1594
|
+
await deleteMergedBranch();
|
|
878
1595
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
879
1596
|
if (closing.tracked.length) {
|
|
880
1597
|
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 */ }
|
|
881
1598
|
}
|
|
1599
|
+
if (isSplit && split.rest) { // #4211: the rest of a large split is the next PR of the chain, planned already (no grouping call)
|
|
1600
|
+
const mod = o.refactorModule ?? (await import("./refactor.mjs"));
|
|
1601
|
+
const c = mod.renderChainIssue({ parent: issue, target: split.target, packageName: split.packageName, rest: split.rest, testCmd: mod.parseTestCmd(iss.body ?? "", []) });
|
|
1602
|
+
try {
|
|
1603
|
+
const out = String(await ghw(["issue", "create", "-R", repo, "--title", c.title, "--body", c.body, ...c.labels.flatMap((l) => ["--label", l]), ...(milestone ? ["--milestone", milestone] : [])]));
|
|
1604
|
+
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)}`);
|
|
1605
|
+
} 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)}`); }
|
|
1606
|
+
}
|
|
882
1607
|
await label("merged");
|
|
883
|
-
|
|
1608
|
+
// forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
|
|
1609
|
+
return await finish("merged", `PR #${pr}`, { pr, structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
|
|
884
1610
|
} catch (e) {
|
|
885
1611
|
if (e?.deferUsageLimit) {
|
|
886
1612
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
887
|
-
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);
|
|
1613
|
+
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);
|
|
888
1614
|
}
|
|
889
1615
|
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
|
|
890
1616
|
if (e?.laneCode) return await needsHuman(e.message, e.laneCode);
|