forgedock 1.29.1 → 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 +94 -36
- 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 +17 -12
- package/bin/lean/context.test.mjs +36 -5
- 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 +189 -43
- package/bin/lean/lean.test.mjs +305 -6
- package/bin/lean/orchestrate.mjs +104 -85
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +21 -4
- package/bin/lean/prefilter.test.mjs +24 -1
- 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 +75 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/orchestrate.md +3 -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/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
package/bin/lean/lean.mjs
CHANGED
|
@@ -10,20 +10,23 @@ 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,
|
|
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
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";
|
|
@@ -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)
|
|
@@ -135,7 +144,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
135
144
|
*/
|
|
136
145
|
export async function runCore(o) {
|
|
137
146
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
138
|
-
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
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;
|
|
139
148
|
const now = io.now ?? (() => Date.now());
|
|
140
149
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
141
150
|
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
@@ -174,7 +183,8 @@ export async function runCore(o) {
|
|
|
174
183
|
const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
|
|
175
184
|
return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
176
185
|
};
|
|
177
|
-
|
|
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; } };
|
|
178
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`;
|
|
179
189
|
|
|
180
190
|
// ---- lease (same FORGE:STATE lease the full path uses) ----
|
|
@@ -218,6 +228,7 @@ export async function runCore(o) {
|
|
|
218
228
|
const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
|
|
219
229
|
let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
|
|
220
230
|
const waits = {};
|
|
231
|
+
let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
|
|
221
232
|
const addWait = (phase, ms, queued = 0) => {
|
|
222
233
|
if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
|
|
223
234
|
if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
|
|
@@ -343,7 +354,7 @@ export async function runCore(o) {
|
|
|
343
354
|
}
|
|
344
355
|
await releaseLease(reason, code);
|
|
345
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,
|
|
346
|
-
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
357
|
+
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}), ...extra };
|
|
347
358
|
return done;
|
|
348
359
|
}
|
|
349
360
|
/** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
|
|
@@ -425,18 +436,25 @@ export async function runCore(o) {
|
|
|
425
436
|
if (!io.host?.cores) return null;
|
|
426
437
|
try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
|
|
427
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
|
+
}
|
|
428
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). */
|
|
429
450
|
async function callRunner(args) {
|
|
430
451
|
let pauses = 0;
|
|
431
452
|
for (;;) {
|
|
432
453
|
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
433
454
|
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
434
|
-
const par = verifyParallelism(); // #4182
|
|
435
|
-
let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
|
|
436
|
-
try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
|
|
437
455
|
// #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
|
|
438
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()) } : {};
|
|
439
|
-
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 } }); }
|
|
440
458
|
catch (e) {
|
|
441
459
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
442
460
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -506,7 +524,8 @@ export async function runCore(o) {
|
|
|
506
524
|
let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
|
|
507
525
|
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
508
526
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
509
|
-
|
|
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);
|
|
510
529
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
511
530
|
let timeoutRetries = 0, p = prompt;
|
|
512
531
|
for (;;) {
|
|
@@ -580,11 +599,13 @@ export async function runCore(o) {
|
|
|
580
599
|
try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
|
|
581
600
|
return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
582
601
|
}
|
|
583
|
-
|
|
584
|
-
//
|
|
585
|
-
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;
|
|
586
607
|
const recs = [];
|
|
587
|
-
if (!subs) recs.push([d, v]);
|
|
608
|
+
if (!subs) recs.push([d, { ...v, state: "OPEN" }]);
|
|
588
609
|
else for (const n of subs) {
|
|
589
610
|
try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
|
|
590
611
|
return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
@@ -629,8 +650,16 @@ export async function runCore(o) {
|
|
|
629
650
|
}
|
|
630
651
|
} catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
|
|
631
652
|
T.decisions.push(`Builder block: split into ${refs(filed)}`);
|
|
632
|
-
|
|
633
|
-
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
|
+
}
|
|
634
663
|
return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
|
|
635
664
|
}
|
|
636
665
|
return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
|
|
@@ -695,7 +724,13 @@ export async function runCore(o) {
|
|
|
695
724
|
try {
|
|
696
725
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
697
726
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
698
|
-
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
|
+
}
|
|
699
734
|
|
|
700
735
|
try {
|
|
701
736
|
// ---- 1. claim ----
|
|
@@ -830,7 +865,7 @@ export async function runCore(o) {
|
|
|
830
865
|
if (typeof envSetup === "string" && envSetup.trim()) {
|
|
831
866
|
let r;
|
|
832
867
|
if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
|
|
833
|
-
else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env:
|
|
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) }; } }
|
|
834
869
|
ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
|
|
835
870
|
if (r?.exit !== 0) {
|
|
836
871
|
const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
|
|
@@ -864,7 +899,7 @@ export async function runCore(o) {
|
|
|
864
899
|
if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
|
|
865
900
|
return null;
|
|
866
901
|
}
|
|
867
|
-
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);
|
|
868
903
|
const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
|
|
869
904
|
ev({ event: "REPRO_RED_GREEN", ...words });
|
|
870
905
|
T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
|
|
@@ -953,6 +988,23 @@ export async function runCore(o) {
|
|
|
953
988
|
}
|
|
954
989
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
955
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
|
+
}
|
|
956
1008
|
/**
|
|
957
1009
|
* The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
|
|
958
1010
|
* (#4265): a trusted comment that the engine did not post came after it.
|
|
@@ -1050,9 +1102,10 @@ export async function runCore(o) {
|
|
|
1050
1102
|
}
|
|
1051
1103
|
await label("building");
|
|
1052
1104
|
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
1105
|
+
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1053
1106
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1054
1107
|
const ctxPack = frozen ? null : await contextPackStep();
|
|
1055
|
-
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) : "");
|
|
1056
1109
|
if (!frozen) {
|
|
1057
1110
|
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
1058
1111
|
planned = true;
|
|
@@ -1074,12 +1127,23 @@ export async function runCore(o) {
|
|
|
1074
1127
|
let contract = await readContract();
|
|
1075
1128
|
await refreshIssue("contract validation");
|
|
1076
1129
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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);
|
|
1080
1136
|
contract = await readContract();
|
|
1081
1137
|
await refreshIssue("contract validation");
|
|
1082
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
|
+
}
|
|
1083
1147
|
}
|
|
1084
1148
|
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
1085
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 } : {});
|
|
@@ -1096,7 +1160,7 @@ export async function runCore(o) {
|
|
|
1096
1160
|
}
|
|
1097
1161
|
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1098
1162
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1099
|
-
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();
|
|
1100
1164
|
liveKeys = ["implementation"];
|
|
1101
1165
|
stage = "implement";
|
|
1102
1166
|
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
@@ -1121,24 +1185,29 @@ export async function runCore(o) {
|
|
|
1121
1185
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1122
1186
|
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1123
1187
|
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1124
|
-
// #
|
|
1125
|
-
const pickChecks = () => { const m =
|
|
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 }; };
|
|
1126
1190
|
let picked = pickChecks();
|
|
1127
1191
|
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1128
1192
|
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1129
|
-
if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
|
|
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` : ""}`);
|
|
1130
1194
|
if (picked.commands.length && io.runCommand) {
|
|
1195
|
+
precheckRan = true;
|
|
1131
1196
|
for (let round = 0; ; round++) {
|
|
1132
1197
|
await renew();
|
|
1133
1198
|
if (round) picked = pickChecks();
|
|
1134
1199
|
const precheckCommands = picked.commands;
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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 } : {}) });
|
|
1142
1211
|
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1143
1212
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1144
1213
|
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
@@ -1203,6 +1272,38 @@ export async function runCore(o) {
|
|
|
1203
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);
|
|
1204
1273
|
}
|
|
1205
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; }
|
|
1206
1307
|
const structureLine = structureNote(structure);
|
|
1207
1308
|
|
|
1208
1309
|
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
@@ -1292,7 +1393,12 @@ export async function runCore(o) {
|
|
|
1292
1393
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1293
1394
|
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
|
|
1294
1395
|
let d = null, first = null;
|
|
1295
|
-
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
|
+
}
|
|
1296
1402
|
// #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
|
|
1297
1403
|
const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
|
|
1298
1404
|
await renew();
|
|
@@ -1311,6 +1417,21 @@ export async function runCore(o) {
|
|
|
1311
1417
|
}
|
|
1312
1418
|
return d;
|
|
1313
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
|
+
}
|
|
1314
1435
|
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
1315
1436
|
async function gate() {
|
|
1316
1437
|
await refreshIssue("conformance");
|
|
@@ -1319,14 +1440,20 @@ export async function runCore(o) {
|
|
|
1319
1440
|
const amendProblems = await amend();
|
|
1320
1441
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1321
1442
|
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1322
|
-
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 });
|
|
1323
1446
|
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1324
|
-
|
|
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];
|
|
1325
1451
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
1326
1452
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
1327
1453
|
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
1328
1454
|
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
|
|
1329
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 };
|
|
1330
1457
|
// #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
|
|
1331
1458
|
if (triggerMode !== "off") {
|
|
1332
1459
|
let t = null;
|
|
@@ -1371,9 +1498,21 @@ export async function runCore(o) {
|
|
|
1371
1498
|
}
|
|
1372
1499
|
ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
|
|
1373
1500
|
}
|
|
1374
|
-
/**
|
|
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}. */
|
|
1375
1513
|
const doReview = async () => {
|
|
1376
1514
|
const d = await gate();
|
|
1515
|
+
if (d.state === "environment") return { env: d.failures };
|
|
1377
1516
|
if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
|
|
1378
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 };
|
|
1379
1518
|
};
|
|
@@ -1391,6 +1530,7 @@ export async function runCore(o) {
|
|
|
1391
1530
|
if (outcome.state === "conforms") break;
|
|
1392
1531
|
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1393
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);
|
|
1394
1534
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1395
1535
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1396
1536
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -1523,7 +1663,7 @@ export async function runCore(o) {
|
|
|
1523
1663
|
how = "clean merge of the base into the branch";
|
|
1524
1664
|
} else {
|
|
1525
1665
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
1526
|
-
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge
|
|
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.`;
|
|
1527
1667
|
await builder(++attemptN, prompt, sessionId ?? null);
|
|
1528
1668
|
if (await unresolved()) {
|
|
1529
1669
|
await gitOut(["merge", "--abort"]);
|
|
@@ -1533,6 +1673,9 @@ export async function runCore(o) {
|
|
|
1533
1673
|
const r2 = await readResult();
|
|
1534
1674
|
tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
|
|
1535
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"; }
|
|
1536
1679
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
1537
1680
|
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
1538
1681
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
@@ -1543,14 +1686,16 @@ export async function runCore(o) {
|
|
|
1543
1686
|
rereview = "refactor-verify re-run against the new base: ok";
|
|
1544
1687
|
}
|
|
1545
1688
|
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1546
|
-
if (!isSplit && (!clean || ciFixes)) {
|
|
1689
|
+
if (!isSplit && (!clean || sq?.renumbered || ciFixes)) {
|
|
1547
1690
|
let rv = await doReview();
|
|
1691
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1548
1692
|
if (rv?.fail) {
|
|
1549
1693
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
1550
1694
|
await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
|
|
1551
1695
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
1552
1696
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
1553
1697
|
rv = await doReview();
|
|
1698
|
+
if (rv.env) return await envNeedsHuman(rv.env);
|
|
1554
1699
|
}
|
|
1555
1700
|
if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
|
|
1556
1701
|
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
@@ -1587,6 +1732,7 @@ export async function runCore(o) {
|
|
|
1587
1732
|
}
|
|
1588
1733
|
const rv = await doReview(); // never merge an unreviewed head
|
|
1589
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);
|
|
1590
1736
|
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1591
1737
|
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1592
1738
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|