forgedock 1.29.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +4 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine-cli.mjs +102 -38
- package/bin/engine.mjs +3 -1
- package/bin/lean/batch.test.mjs +62 -1
- package/bin/lean/conformance.mjs +129 -8
- package/bin/lean/conformance.test.mjs +77 -3
- package/bin/lean/context.mjs +32 -4
- package/bin/lean/context.test.mjs +71 -12
- package/bin/lean/effects.mjs +20 -6
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/lean.mjs +207 -56
- package/bin/lean/lean.test.mjs +336 -9
- package/bin/lean/orchestrate.mjs +132 -83
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +96 -14
- package/bin/lean/prefilter.test.mjs +111 -2
- package/bin/lean/preflight.mjs +133 -0
- package/bin/lean/preflight.test.mjs +61 -0
- package/bin/lean/prompts.mjs +37 -3
- package/bin/lean/recovery.test.mjs +271 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +57 -3
- package/bin/lean/testmap.test.mjs +53 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +7 -4
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +8 -1
- package/docs/OUTCOME-CODES.md +6 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +186 -34
- package/scripts/test-map.test.sh +250 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- package/scripts/wait-ci-green.test.sh +4 -0
package/bin/lean/lean.mjs
CHANGED
|
@@ -10,27 +10,30 @@ 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, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
|
|
13
|
+
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
15
|
+
import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
16
16
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
17
17
|
import { namedPaths } from "./prefilter.mjs";
|
|
18
|
+
import { preflightProbes, preflightProblems } from "./preflight.mjs";
|
|
18
19
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
19
20
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
20
21
|
import { repoKey } from "../runner.mjs";
|
|
21
22
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
22
|
-
import { readTestMap, readTestMapAtRef, selectCommands, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
23
|
+
import { readTestMap, readTestMapAtRef, selectCommands, scopeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
24
|
+
import { runChecks } from "./precheck.mjs";
|
|
23
25
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
24
|
-
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
26
|
+
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
25
27
|
import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
26
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
|
|
28
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
|
|
29
|
+
import { unjustified } from "./enumerating.mjs";
|
|
27
30
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
28
31
|
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
|
|
29
32
|
import { laneBackendId } from "./limits.mjs";
|
|
30
33
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
31
34
|
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
32
35
|
import { priorityFromLabels } from "./sched.mjs";
|
|
33
|
-
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts } from "./context.mjs";
|
|
36
|
+
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp } from "./context.mjs";
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
@@ -41,7 +44,11 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
41
44
|
[OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
|
|
42
45
|
[OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
|
|
43
46
|
[OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
|
|
47
|
+
[OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
|
|
48
|
+
[OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
|
|
44
49
|
});
|
|
50
|
+
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
51
|
+
const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
|
|
45
52
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
46
53
|
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
47
54
|
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
@@ -120,7 +127,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
120
127
|
* @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
|
|
121
128
|
* @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
|
|
122
129
|
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
123
|
-
* @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
|
|
130
|
+
* @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
|
|
131
|
+
* maxMinutes: lean.precheck.max_minutes, the round budget (#4331, else verify.precheck_total_seconds)
|
|
132
|
+
* @param {boolean} [o.closeParent] forge.yaml decomposition.close_parent (#3736): close a decomposed parent at decomposition (default false)
|
|
124
133
|
* @param {"off"|"prefer"} [o.reproTest] forge.yaml lean.repro_test (#4271): prefer (default) freezes a red-green repro_test on bug lanes; off is today's contract
|
|
125
134
|
* @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
|
|
126
135
|
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
@@ -129,12 +138,13 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
129
138
|
* @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
|
|
130
139
|
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
131
140
|
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
132
|
-
* @param {object} [o.recover] {code, attempt}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
133
|
-
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note
|
|
141
|
+
* @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
142
|
+
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
|
|
143
|
+
* (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
|
|
134
144
|
*/
|
|
135
145
|
export async function runCore(o) {
|
|
136
146
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
137
|
-
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
147
|
+
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
138
148
|
const now = io.now ?? (() => Date.now());
|
|
139
149
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
140
150
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
@@ -143,8 +153,8 @@ export async function runCore(o) {
|
|
|
143
153
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
144
154
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
145
155
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
146
|
-
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
|
|
147
|
-
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
|
|
156
|
+
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
|
|
157
|
+
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1, final: o.recover.final !== false } : null;
|
|
148
158
|
const branch = `feat/issue-${issue}-lean`;
|
|
149
159
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
150
160
|
const t0 = now();
|
|
@@ -173,7 +183,8 @@ export async function runCore(o) {
|
|
|
173
183
|
const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
|
|
174
184
|
return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
175
185
|
};
|
|
176
|
-
|
|
186
|
+
/** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
|
|
187
|
+
const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); return true; } catch { /* labels are best effort */ return false; } };
|
|
177
188
|
const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
|
|
178
189
|
|
|
179
190
|
// ---- lease (same FORGE:STATE lease the full path uses) ----
|
|
@@ -217,6 +228,7 @@ export async function runCore(o) {
|
|
|
217
228
|
const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
|
|
218
229
|
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
219
230
|
const waits = {};
|
|
231
|
+
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
220
232
|
const addWait = (phase, ms, queued = 0) => {
|
|
221
233
|
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
222
234
|
if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
|
|
@@ -322,10 +334,11 @@ export async function runCore(o) {
|
|
|
322
334
|
await emit(term);
|
|
323
335
|
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
324
336
|
// decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
|
|
325
|
-
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
|
|
326
|
-
|
|
337
|
+
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
|
|
338
|
+
// either launches the next attempt or relabels in settle.
|
|
339
|
+
if (recoverUnlabelled && recover.final && reason === "ci-pending") {
|
|
327
340
|
try {
|
|
328
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${
|
|
341
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
|
|
329
342
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
330
343
|
} catch { /* best effort */ }
|
|
331
344
|
}
|
|
@@ -341,14 +354,14 @@ export async function runCore(o) {
|
|
|
341
354
|
}
|
|
342
355
|
await releaseLease(reason, code);
|
|
343
356
|
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
|
|
344
|
-
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
357
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}), ...extra };
|
|
345
358
|
return done;
|
|
346
359
|
}
|
|
347
|
-
/** #4297:
|
|
348
|
-
const
|
|
360
|
+
/** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
|
|
361
|
+
const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
|
|
349
362
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
350
|
-
try {
|
|
351
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${
|
|
363
|
+
if (!recover || recover.final) try {
|
|
364
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
352
365
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
353
366
|
} catch { /* best effort */ }
|
|
354
367
|
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
@@ -423,18 +436,25 @@ export async function runCore(o) {
|
|
|
423
436
|
if (!io.host?.cores) return null;
|
|
424
437
|
try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
|
|
425
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* #4325: the lane env every engine-run command shares with the builder: shared package-manager stores (#4220), the run
|
|
441
|
+
* TMPDIR (#4104), FORGE_HOST_PARALLELISM (#4182) and the pinned toolchain (#4163); the engine's own keys win over the stores.
|
|
442
|
+
*/
|
|
443
|
+
function laneEnv() {
|
|
444
|
+
let deps = {};
|
|
445
|
+
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
446
|
+
const par = verifyParallelism();
|
|
447
|
+
return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
|
|
448
|
+
}
|
|
426
449
|
/** 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). */
|
|
427
450
|
async function callRunner(args) {
|
|
428
451
|
let pauses = 0;
|
|
429
452
|
for (;;) {
|
|
430
453
|
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
431
454
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
432
|
-
const par = verifyParallelism(); // #4182
|
|
433
|
-
let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
|
|
434
|
-
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
435
455
|
// #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
|
|
436
456
|
const heavy = heavyEnv ? { ...heavyEnv, FORGE_LANE_PHASE: args.role === "builder" ? stage : String(args.role ?? "step"), FORGE_LANE_ISSUE: String(issue), FORGE_LANE_PRIORITY: String(lanePriority()) } : {};
|
|
437
|
-
try { return await io.runner({ ...args, env: { ...
|
|
457
|
+
try { return await io.runner({ ...args, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
|
|
438
458
|
catch (e) {
|
|
439
459
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
440
460
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -504,7 +524,8 @@ export async function runCore(o) {
|
|
|
504
524
|
let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
|
|
505
525
|
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
506
526
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
507
|
-
|
|
527
|
+
let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
|
|
528
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + CONTINUE_NOTE.replace("BASE", lane);
|
|
508
529
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
509
530
|
let timeoutRetries = 0, p = prompt;
|
|
510
531
|
for (;;) {
|
|
@@ -578,11 +599,13 @@ export async function runCore(o) {
|
|
|
578
599
|
try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
|
|
579
600
|
return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
580
601
|
}
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
const
|
|
602
|
+
// #4153 / #4337: a decomposed parent, open or closed, resolves to its open sub-issues; it is satisfied once they all
|
|
603
|
+
// close. Without a usable trail the parent itself is the dependency, even when closed (fail closed).
|
|
604
|
+
const decomposed = isDecomposedRecord(v) && ["OPEN", "CLOSED"].includes(String(v?.state).toUpperCase());
|
|
605
|
+
if (!decomposed && String(v?.state).toUpperCase() !== "OPEN") continue;
|
|
606
|
+
const subs = decomposed ? await decomposedSubissues(d, io) : null;
|
|
584
607
|
const recs = [];
|
|
585
|
-
if (!subs) recs.push([d, v]);
|
|
608
|
+
if (!subs) recs.push([d, { ...v, state: "OPEN" }]);
|
|
586
609
|
else for (const n of subs) {
|
|
587
610
|
try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
|
|
588
611
|
return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
@@ -627,8 +650,16 @@ export async function runCore(o) {
|
|
|
627
650
|
}
|
|
628
651
|
} catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
|
|
629
652
|
T.decisions.push(`Builder block: split into ${refs(filed)}`);
|
|
630
|
-
|
|
631
|
-
await
|
|
653
|
+
let marked = false;
|
|
654
|
+
try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); marked = true; } catch { /* best effort */ }
|
|
655
|
+
// #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
|
|
656
|
+
if (await label("decomposed") && marked && closeParent) {
|
|
657
|
+
try {
|
|
658
|
+
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned", "--comment",
|
|
659
|
+
`Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
|
|
660
|
+
T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
|
|
661
|
+
} catch { /* best effort: the parent stays open, as before #4337 */ }
|
|
662
|
+
}
|
|
632
663
|
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
|
|
633
664
|
}
|
|
634
665
|
return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
|
|
@@ -693,7 +724,13 @@ export async function runCore(o) {
|
|
|
693
724
|
try {
|
|
694
725
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
695
726
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
696
|
-
if (String(iss.state).toUpperCase() === "CLOSED")
|
|
727
|
+
if (String(iss.state).toUpperCase() === "CLOSED") {
|
|
728
|
+
// #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
|
|
729
|
+
// them). A trusted revocation reopens it and the run continues. An unreadable trail lists none (fail closed).
|
|
730
|
+
if (!isDecomposedRecord(iss)) return finish("invalid", "issue already closed");
|
|
731
|
+
if (await reopenRevokedDecomposition(issue, io)) iss.state = "OPEN";
|
|
732
|
+
else return finish("decomposed", "closed decomposed tracker; dependents wait on its sub-issues", { code: OUTCOME_CODES.DECOMPOSED, subissues: (await decomposedSubissues(issue, io)) ?? [] });
|
|
733
|
+
}
|
|
697
734
|
|
|
698
735
|
try {
|
|
699
736
|
// ---- 1. claim ----
|
|
@@ -828,7 +865,7 @@ export async function runCore(o) {
|
|
|
828
865
|
if (typeof envSetup === "string" && envSetup.trim()) {
|
|
829
866
|
let r;
|
|
830
867
|
if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
|
|
831
|
-
else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env:
|
|
868
|
+
else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: laneEnv() })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
|
|
832
869
|
ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
|
|
833
870
|
if (r?.exit !== 0) {
|
|
834
871
|
const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
|
|
@@ -862,7 +899,7 @@ export async function runCore(o) {
|
|
|
862
899
|
if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
|
|
863
900
|
return null;
|
|
864
901
|
}
|
|
865
|
-
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
|
|
902
|
+
const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
|
|
866
903
|
const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
867
904
|
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
868
905
|
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
@@ -894,7 +931,9 @@ export async function runCore(o) {
|
|
|
894
931
|
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
895
932
|
try {
|
|
896
933
|
const headSha = String(await git(["rev-parse", base])).trim();
|
|
897
|
-
|
|
934
|
+
// #4316: linked-issue states are part of the key; a failed lookup only drops them from it
|
|
935
|
+
const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
|
|
936
|
+
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
|
|
898
937
|
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
899
938
|
const p = r.pack;
|
|
900
939
|
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
@@ -949,6 +988,23 @@ export async function runCore(o) {
|
|
|
949
988
|
}
|
|
950
989
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
951
990
|
}
|
|
991
|
+
/** #4328: probe the host for the tools and modules a contract's validate commands need; the misses, [] without io.probeCommand. */
|
|
992
|
+
async function preflightValidates(contract) {
|
|
993
|
+
if (!io.probeCommand || !contract || typeof contract !== "object") return [];
|
|
994
|
+
const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
995
|
+
const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
|
|
996
|
+
if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
|
|
997
|
+
const env = laneEnv(), missing = new Set(), misses = []; // #4325: the env the validate commands run with
|
|
998
|
+
for (const p of probes) {
|
|
999
|
+
if (p.needs && missing.has(p.needs)) continue;
|
|
1000
|
+
let r;
|
|
1001
|
+
try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
|
|
1002
|
+
if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
|
|
1003
|
+
if (p.kind === "command") missing.add(p.tool);
|
|
1004
|
+
misses.push(p);
|
|
1005
|
+
}
|
|
1006
|
+
return misses;
|
|
1007
|
+
}
|
|
952
1008
|
/**
|
|
953
1009
|
* The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
|
|
954
1010
|
* (#4265): a trusted comment that the engine did not post came after it.
|
|
@@ -1046,9 +1102,10 @@ export async function runCore(o) {
|
|
|
1046
1102
|
}
|
|
1047
1103
|
await label("building");
|
|
1048
1104
|
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1105
|
+
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1049
1106
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1050
1107
|
const ctxPack = frozen ? null : await contextPackStep();
|
|
1051
|
-
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1108
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1052
1109
|
if (!frozen) {
|
|
1053
1110
|
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
1054
1111
|
planned = true;
|
|
@@ -1070,12 +1127,23 @@ export async function runCore(o) {
|
|
|
1070
1127
|
let contract = await readContract();
|
|
1071
1128
|
await refreshIssue("contract validation");
|
|
1072
1129
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1130
|
+
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
1131
|
+
let misses = missed;
|
|
1132
|
+
if ((!vc.ok || missed.length) && sessionId) {
|
|
1133
|
+
const problems = [...vc.problems, ...preflightProblems(missed)];
|
|
1134
|
+
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1135
|
+
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt);
|
|
1076
1136
|
contract = await readContract();
|
|
1077
1137
|
await refreshIssue("contract validation");
|
|
1078
1138
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1139
|
+
if (missed.length) misses = await preflightValidates(contract);
|
|
1140
|
+
}
|
|
1141
|
+
if (missed.length) {
|
|
1142
|
+
ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind })), corrected: misses.length === 0 });
|
|
1143
|
+
if (misses.length) {
|
|
1144
|
+
T.anomalies.push(`Validate preflight: the host lacks ${misses.map((m) => m.tool).join(", ")} after the contract correction (advisory; a failed rerun ends env.tool_missing)`);
|
|
1145
|
+
T.decisions.push(`Validate preflight left ${misses.length} miss(es) after one correction; contract frozen anyway`);
|
|
1146
|
+
}
|
|
1079
1147
|
}
|
|
1080
1148
|
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
1081
1149
|
if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
|
|
@@ -1092,7 +1160,7 @@ export async function runCore(o) {
|
|
|
1092
1160
|
}
|
|
1093
1161
|
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1094
1162
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1095
|
-
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
|
|
1163
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
|
|
1096
1164
|
liveKeys = ["implementation"];
|
|
1097
1165
|
stage = "implement";
|
|
1098
1166
|
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
@@ -1117,23 +1185,29 @@ export async function runCore(o) {
|
|
|
1117
1185
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1118
1186
|
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1119
1187
|
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1120
|
-
|
|
1188
|
+
// #4331: a learned command the map covers runs only when its globs match a changed file; unmapped ones always run (#4315)
|
|
1189
|
+
const pickChecks = () => { const m = scopeChecks(testMap, files, learnedCommands); return { ...m, selected: testMap ? selectCommands(testMap, files).length : 0 }; };
|
|
1121
1190
|
let picked = pickChecks();
|
|
1122
1191
|
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1123
1192
|
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1124
|
-
if (picked.source
|
|
1193
|
+
if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length - picked.dropped.length} learned` : ""}${picked.dropped.length ? `; ${picked.dropped.length} mapped learned command(s) not matching the changed files left to CI` : ""}`);
|
|
1125
1194
|
if (picked.commands.length && io.runCommand) {
|
|
1195
|
+
precheckRan = true;
|
|
1126
1196
|
for (let round = 0; ; round++) {
|
|
1127
1197
|
await renew();
|
|
1128
1198
|
if (round) picked = pickChecks();
|
|
1129
1199
|
const precheckCommands = picked.commands;
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1200
|
+
// #4318/#4331: one round has a total budget (lean.precheck.max_minutes, else verify.precheck_total_seconds); the
|
|
1201
|
+
// commands run concurrently up to the host worker cap under ONE heavy slot; the rest are deferred to CI, not failed
|
|
1202
|
+
const maxMin = Number(o.precheck?.maxMinutes), totalMs = (maxMin > 0 ? maxMin * 60 : Number(vcfg.precheckTotalSeconds) || 600) * 1000;
|
|
1203
|
+
const parallelism = Number(verifyParallelism()) || 1;
|
|
1204
|
+
const round$ = await heavyStep("precheck", () => runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: vcfg.precheckTimeoutSeconds * 1000, now,
|
|
1205
|
+
run: ({ command, timeoutMs }) => io.runCommand({ command, cwd: wt, timeoutMs, env: laneEnv() }) }));
|
|
1206
|
+
const { results, deferred } = round$;
|
|
1207
|
+
precheckMs += round$.ms; precheckDeferred += deferred.length;
|
|
1208
|
+
if (deferred.length) T.anomalies.push(`deferred-to-ci: pre-PR check budget ${totalMs / 1000} s reached; left to CI: ${deferred.map((c) => `\`${c}\``).join(", ")}`);
|
|
1209
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: [...results.map(({ command, rc, ms }) => ({ command, rc, ms })), ...deferred.map((command) => ({ command, status: "deferred-to-ci" }))],
|
|
1210
|
+
ms: round$.ms, parallelism, ...(picked.source !== "learned" ? { source: picked.source } : {}) });
|
|
1137
1211
|
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1138
1212
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1139
1213
|
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
@@ -1198,6 +1272,38 @@ export async function runCore(o) {
|
|
|
1198
1272
|
return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
|
|
1199
1273
|
}
|
|
1200
1274
|
}
|
|
1275
|
+
// ---- 3d. enumerating tests (#4322): new symbols that tests listing their siblings do not name; one resume, then an
|
|
1276
|
+
// anomaly (CI stays the gate). Sequence numbers the base already uses: renumbered at hand-off, still colliding = base.conflict.
|
|
1277
|
+
if (io.enumeratingMissing && !isSplit) {
|
|
1278
|
+
const missingNow = async () => unjustified((await io.enumeratingMissing({ cwd: wt, base }).catch(() => null)) ?? [], result?.not_members);
|
|
1279
|
+
let missing = await missingNow();
|
|
1280
|
+
if (missing.length && sessionId) {
|
|
1281
|
+
ev({ event: "ENUMERATING_MISSING", count: missing.length });
|
|
1282
|
+
T.decisions.push(`Enumerating test(s) miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}; builder resumed once`);
|
|
1283
|
+
await builder(++attemptN, missingEntriesPrompt(missing, vcfg.mode), sessionId);
|
|
1284
|
+
await upsertStage("implementation");
|
|
1285
|
+
result = (await readResult()) ?? result;
|
|
1286
|
+
missing = await missingNow();
|
|
1287
|
+
}
|
|
1288
|
+
if (missing.length) T.anomalies.push(`Enumerating test(s) still miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}`);
|
|
1289
|
+
}
|
|
1290
|
+
/** #4322: null (nothing to do), {done} (terminal), or {renumbered: true}. Fetches the lane, so the check sees its tip. */
|
|
1291
|
+
async function sequenceHandoff(trigger) {
|
|
1292
|
+
if (!io.sequenceCheck || isSplit) return null;
|
|
1293
|
+
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
1294
|
+
const collisions = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1295
|
+
if (!collisions.length) return null;
|
|
1296
|
+
const before = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1297
|
+
ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
|
|
1298
|
+
T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ")}; builder renumbers`);
|
|
1299
|
+
await builder(++attemptN, sequencePrompt(collisions, lane, vcfg.mode), sessionId ?? null);
|
|
1300
|
+
result = (await readResult()) ?? result;
|
|
1301
|
+
const v = (await io.sequenceVerify?.({ cwd: wt, base, before }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
|
|
1302
|
+
if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the builder renumbered (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
|
|
1303
|
+
files = await changedFiles();
|
|
1304
|
+
return { renumbered: true };
|
|
1305
|
+
}
|
|
1306
|
+
{ const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
|
|
1201
1307
|
const structureLine = structureNote(structure);
|
|
1202
1308
|
|
|
1203
1309
|
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
@@ -1287,7 +1393,12 @@ export async function runCore(o) {
|
|
|
1287
1393
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1288
1394
|
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
|
|
1289
1395
|
let d = null, first = null;
|
|
1290
|
-
for (let t = 0; t <
|
|
1396
|
+
for (let t = 0; t < 3; t++) {
|
|
1397
|
+
// #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
|
|
1398
|
+
if (t === 2) {
|
|
1399
|
+
if (d.state !== "review.incomplete" || !evidencePathOnly(d.failures.map((f) => f.detail))) break;
|
|
1400
|
+
T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
|
|
1401
|
+
}
|
|
1291
1402
|
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
1292
1403
|
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
1293
1404
|
await renew();
|
|
@@ -1306,6 +1417,21 @@ export async function runCore(o) {
|
|
|
1306
1417
|
}
|
|
1307
1418
|
return d;
|
|
1308
1419
|
}
|
|
1420
|
+
/** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
|
|
1421
|
+
async function rerunFailedValidates(contract) {
|
|
1422
|
+
if (!io.runCommand) return null;
|
|
1423
|
+
const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
|
|
1424
|
+
const env = laneEnv();
|
|
1425
|
+
for (const v of contract.validate ?? []) {
|
|
1426
|
+
const r = runs.find((x) => x?.id === v.id);
|
|
1427
|
+
if (!r || r.exit === undefined || r.exit === 0) continue;
|
|
1428
|
+
let x;
|
|
1429
|
+
try { x = await heavyStep("precheck", () => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env })); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1430
|
+
out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
|
|
1431
|
+
}
|
|
1432
|
+
if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
1433
|
+
return out;
|
|
1434
|
+
}
|
|
1309
1435
|
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
1310
1436
|
async function gate() {
|
|
1311
1437
|
await refreshIssue("conformance");
|
|
@@ -1314,14 +1440,20 @@ export async function runCore(o) {
|
|
|
1314
1440
|
const amendProblems = await amend();
|
|
1315
1441
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1316
1442
|
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1317
|
-
const
|
|
1443
|
+
const validateRuns = await rerunFailedValidates(effective);
|
|
1444
|
+
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
1445
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas });
|
|
1318
1446
|
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1319
|
-
|
|
1447
|
+
// #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
|
|
1448
|
+
// never a conformance failure, never a builder resume
|
|
1449
|
+
const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
|
|
1450
|
+
const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems];
|
|
1320
1451
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
1321
1452
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
1322
1453
|
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
1323
1454
|
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
|
|
1324
1455
|
if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
|
|
1456
|
+
if (envFails.length) return { state: "environment", by: "precheck", failures: envFails };
|
|
1325
1457
|
// #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
|
|
1326
1458
|
if (triggerMode !== "off") {
|
|
1327
1459
|
let t = null;
|
|
@@ -1366,9 +1498,21 @@ export async function runCore(o) {
|
|
|
1366
1498
|
}
|
|
1367
1499
|
ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
|
|
1368
1500
|
}
|
|
1369
|
-
/**
|
|
1501
|
+
/** #4323: a validate command the host could not run ends needs-human env.tool_missing (recoverable), never a builder resume. */
|
|
1502
|
+
// #4334: a repro test the harness could not run on head (and nothing else) ends env.repro_unrunnable (recoverable)
|
|
1503
|
+
async function envNeedsHuman(failures) {
|
|
1504
|
+
if (failures.every((f) => f.code === OUTCOME_CODES.ENV_REPRO_UNRUNNABLE)) {
|
|
1505
|
+
T.anomalies.push("Environment error: the repro harness could not run the repro test on head");
|
|
1506
|
+
return await needsHuman(`Environment error (not a conformance failure): the repro harness could not run the repro test on head; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nRun the repro command through the repo's own toolchain and installed dependencies, then rerun.`, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE);
|
|
1507
|
+
}
|
|
1508
|
+
const tools = [...new Set(failures.filter((f) => f.tool).map((f) => f.tool))].join(", ");
|
|
1509
|
+
T.anomalies.push(`Environment error: validate command(s) could not run, the host is missing ${tools}`);
|
|
1510
|
+
return await needsHuman(`Environment error (not a conformance failure): the host is missing ${tools}, so validate command(s) could not run; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nInstall it on the host or run the check through the repo's own toolchain, then rerun.`, OUTCOME_CODES.ENV_TOOL_MISSING);
|
|
1511
|
+
}
|
|
1512
|
+
/** Shape the base-sync path expects: {unusable: state} when no usable review, {env: failures} for a missing host tool, else {fail, verdict, reason, failures}. */
|
|
1370
1513
|
const doReview = async () => {
|
|
1371
1514
|
const d = await gate();
|
|
1515
|
+
if (d.state === "environment") return { env: d.failures };
|
|
1372
1516
|
if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
|
|
1373
1517
|
return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
|
|
1374
1518
|
};
|
|
@@ -1386,6 +1530,7 @@ export async function runCore(o) {
|
|
|
1386
1530
|
if (outcome.state === "conforms") break;
|
|
1387
1531
|
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1388
1532
|
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1533
|
+
if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
|
|
1389
1534
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1390
1535
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1391
1536
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -1518,7 +1663,7 @@ export async function runCore(o) {
|
|
|
1518
1663
|
how = "clean merge of the base into the branch";
|
|
1519
1664
|
} else {
|
|
1520
1665
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
1521
|
-
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
|
|
1666
|
+
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge.${sequenceSyncNote(files)} Do not push.`;
|
|
1522
1667
|
await builder(++attemptN, prompt, sessionId ?? null);
|
|
1523
1668
|
if (await unresolved()) {
|
|
1524
1669
|
await gitOut(["merge", "--abort"]);
|
|
@@ -1528,6 +1673,9 @@ export async function runCore(o) {
|
|
|
1528
1673
|
const r2 = await readResult();
|
|
1529
1674
|
tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
|
|
1530
1675
|
}
|
|
1676
|
+
const sq = await sequenceHandoff(`base-sync ${syncs}`); // #4322: a clean text merge can still bring a taken number
|
|
1677
|
+
if (sq?.done) return sq.done;
|
|
1678
|
+
if (sq?.renumbered) { how += "; builder renumbered sequence-numbered files"; }
|
|
1531
1679
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
1532
1680
|
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
1533
1681
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
@@ -1538,14 +1686,16 @@ export async function runCore(o) {
|
|
|
1538
1686
|
rereview = "refactor-verify re-run against the new base: ok";
|
|
1539
1687
|
}
|
|
1540
1688
|
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1541
|
-
if (!isSplit && (!clean || ciFixes)) {
|
|
1689
|
+
if (!isSplit && (!clean || sq?.renumbered || ciFixes)) {
|
|
1542
1690
|
let rv = await doReview();
|
|
1691
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1543
1692
|
if (rv?.fail) {
|
|
1544
1693
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
1545
1694
|
await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
|
|
1546
1695
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
1547
1696
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
1548
1697
|
rv = await doReview();
|
|
1698
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1549
1699
|
}
|
|
1550
1700
|
if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
|
|
1551
1701
|
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
@@ -1582,6 +1732,7 @@ export async function runCore(o) {
|
|
|
1582
1732
|
}
|
|
1583
1733
|
const rv = await doReview(); // never merge an unreviewed head
|
|
1584
1734
|
if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1735
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1585
1736
|
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1586
1737
|
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1587
1738
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|