forgedock 1.42.0 → 1.43.1
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 +1 -0
- package/bin/engine-cli.mjs +91 -19
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +11 -3
- package/bin/lean/batch.test.mjs +66 -2
- package/bin/lean/conformance.mjs +140 -9
- package/bin/lean/conformance.test.mjs +164 -2
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lean.mjs +80 -24
- package/bin/lean/lean.test.mjs +119 -7
- package/bin/lean/orchestrate-blocked.test.mjs +16 -0
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/probe.mjs +1 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +116 -19
- package/bin/lean/schedd.test.mjs +201 -2
- package/bin/lean/selftest-live.mjs +1 -1
- package/bin/lean/selftest-nightly.mjs +1 -1
- package/bin/lean/signoff.mjs +11 -1
- package/bin/lean/signoff.test.mjs +24 -1
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/pool/agent.mjs +1 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/OUTCOME-CODES.md +1 -0
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
package/bin/lean/lean.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
|
|
6
6
|
* severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
|
|
7
7
|
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
8
9
|
import { join, dirname, basename, delimiter } from "node:path";
|
|
9
10
|
import { hostname as osHostname } from "node:os";
|
|
10
11
|
import { appendEvent } from "../engine/runlog.mjs";
|
|
@@ -14,12 +15,12 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
|
|
|
14
15
|
import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
15
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
16
17
|
import { otherLaneOwner } from "./lanelock.mjs";
|
|
17
|
-
import { TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
18
|
+
import { resolveForgedockPaths, describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
18
19
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
19
20
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
20
21
|
import { checksAt } from "./postdeploy-check.mjs";
|
|
21
22
|
import { readRollouts, detectNewOffKeys } from "./activation.mjs";
|
|
22
|
-
import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
23
|
+
import { testRunnerProblems, preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
23
24
|
import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
|
|
24
25
|
import { splitGate, rationaleLine } from "./decompose.mjs";
|
|
25
26
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
@@ -37,7 +38,7 @@ import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
|
|
|
37
38
|
const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
|
|
38
39
|
/** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
|
|
39
40
|
export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
|
|
40
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
|
|
41
|
+
import { CHECKS_CLI, FORGEDOCK_CLI, REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, investigationVerdict, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, unrunnableValidateNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
|
|
41
42
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
42
43
|
import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
|
|
43
44
|
import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
|
|
@@ -156,6 +157,9 @@ export function classifyBlock(result, issue) {
|
|
|
156
157
|
}
|
|
157
158
|
if (b.kind === "human") {
|
|
158
159
|
const remainder = (Array.isArray(b.remainder) ? b.remainder : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string").slice(0, MAX_SUBISSUES).map((x) => ({ title: x.title.trim(), body: x.body })); // #4730
|
|
160
|
+
if (b.why === "product-decision" && describesUnrunnableCommand(`${result?.summary ?? ""} ${b.reason ?? ""} ${b.detail ?? ""}`)) { // #4857
|
|
161
|
+
return { kind: "untyped", problem: "an unrunnable validate command is an environment defect, never a product-decision: run an equivalent through the repo's toolchain and record it as a substitute-validate amendment" };
|
|
162
|
+
}
|
|
159
163
|
return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why, ...(remainder.length ? { remainder } : {}) }
|
|
160
164
|
: { kind: "untyped", problem: `"${String(b.why ?? "").slice(0, 80)}" is not a human-only reason (${HUMAN_BLOCK_REASONS.join(", ")})` };
|
|
161
165
|
}
|
|
@@ -202,6 +206,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
202
206
|
* @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
|
|
203
207
|
* @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
|
|
204
208
|
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
209
|
+
* @param {object} [o.testRunners] forge.yaml verification.test_runners (#4857): {path prefix: runner command prefix}
|
|
205
210
|
* @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
|
|
206
211
|
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
207
212
|
* @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
|
|
@@ -217,7 +222,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
217
222
|
*/
|
|
218
223
|
export async function runCore(o) {
|
|
219
224
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
220
|
-
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
225
|
+
toolchainOverride = null, testRunners = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
221
226
|
const now = io.now ?? (() => Date.now());
|
|
222
227
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
223
228
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
@@ -352,7 +357,7 @@ export async function runCore(o) {
|
|
|
352
357
|
}
|
|
353
358
|
async function heavyStep(phase, fn) { const g = await heavyAcquire(phase); try { return await fn(); } finally { try { g?.release?.(); } catch { /* fail-open */ } } }
|
|
354
359
|
// ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
|
|
355
|
-
let ledger = null, deleteLocalBranch = false;
|
|
360
|
+
let ledger = null, deleteLocalBranch = false, laneHead = null; // #4958: laneHead = {branch, sha, pushedTo}
|
|
356
361
|
const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
|
|
357
362
|
const protectedBranch = (b) => isProtectedLaneBranch(b, { issue, lane, branches, defaults });
|
|
358
363
|
/** Teardown from the ledger (#4179), on every exit path: builder pids, checkpoint of unpushed work, run tmp, worktree, branch, docker. */
|
|
@@ -362,6 +367,7 @@ export async function runCore(o) {
|
|
|
362
367
|
}
|
|
363
368
|
// Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
|
|
364
369
|
const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
|
|
370
|
+
await reportLaneHead(reason);
|
|
365
371
|
try { ghCache?.dispose?.(); } catch { /* best effort */ }
|
|
366
372
|
if (runTmp) {
|
|
367
373
|
try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
|
|
@@ -445,7 +451,7 @@ export async function runCore(o) {
|
|
|
445
451
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
446
452
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
447
453
|
conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
448
|
-
context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, ...(guardAddedMs ? { guard_added_ms: guardAddedMs } : {}), model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
454
|
+
...(laneHead && reason !== "merged" ? { lane_branch: laneHead.pushedTo ?? "unpushed", lane_sha: laneHead.sha } : {}), context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, ...(guardAddedMs ? { guard_added_ms: guardAddedMs } : {}), model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
|
|
449
455
|
...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
|
|
450
456
|
const phases = phaseLedger.snapshot(); // #4908: run log gets the nested ledger, FORGE:TELEMETRY the flattened scalars
|
|
451
457
|
ev({ ...term, phases });
|
|
@@ -473,7 +479,7 @@ export async function runCore(o) {
|
|
|
473
479
|
}
|
|
474
480
|
await releaseLease(reason, code, extra.mergedTo ?? null);
|
|
475
481
|
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, model_ms: modelMs, phases: phaseLedger.snapshot(),
|
|
476
|
-
light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
482
|
+
...(laneHead && reason !== "merged" ? { laneHead: { ...laneHead } } : {}), light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
477
483
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
478
484
|
...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
|
|
479
485
|
...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
|
|
@@ -637,10 +643,10 @@ export async function runCore(o) {
|
|
|
637
643
|
try {
|
|
638
644
|
const r = await ensureSignedOff({ git, lane, branch });
|
|
639
645
|
if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten }); }
|
|
640
|
-
} catch (e) { throw Object.assign(e, { laneCode: OUTCOME_CODES.CI_DCO }); }
|
|
646
|
+
} catch (e) { throw Object.assign(e, e?.noIdentity === true ? { laneCode: OUTCOME_CODES.CI_DCO } : { engineCode: OUTCOME_CODES.ENGINE_DCO_REWRITE }); } // #4997: a failed rewrite is mechanical (engine-error, recoverable), only a missing identity is a human block
|
|
641
647
|
};
|
|
642
648
|
const gitPush = async (args) => {
|
|
643
|
-
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
|
|
649
|
+
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
|
|
644
650
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
645
651
|
catch (e) {
|
|
646
652
|
if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
@@ -655,10 +661,33 @@ export async function runCore(o) {
|
|
|
655
661
|
const dirty = String(await git(["status", "--porcelain"])).trim();
|
|
656
662
|
if (dirty) { await git(["add", "-A"]); if (await unstageToolState(git)) await git(["commit", "--no-verify", "-m", `wip(forge): checkpoint after ${reason}`]); } // #4590: tool state is never staged
|
|
657
663
|
const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
|
|
658
|
-
if (ahead) await gitPush(["push", "-u", "origin", branch]);
|
|
664
|
+
if (ahead) { laneHead = { branch, sha: String(await git(["rev-parse", "HEAD"])).trim(), pushedTo: null }; await gitPush(["push", "-u", "origin", branch]); laneHead.pushedTo = branch; }
|
|
659
665
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
660
666
|
return true;
|
|
661
|
-
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
667
|
+
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return laneHead ? await rescuePush(reason, e?.pushRejected) : false; }
|
|
668
|
+
}
|
|
669
|
+
/** #4958: the normal push failed (sign-off rewrite, rejection): push HEAD as is to the lane branch, else to a salvage branch, so a torn-down remote sandbox never takes the commits with it. */
|
|
670
|
+
async function rescuePush(reason, rejected) {
|
|
671
|
+
// #5014: gitPush untracks tool state and rewrites unpushed commits for DCO before it pushes, so HEAD may have moved since the capture
|
|
672
|
+
try { const now = String(await git(["rev-parse", "HEAD"])).trim(); if (now) laneHead.sha = now; } catch { /* keep the captured sha */ }
|
|
673
|
+
// a rejected lane-branch push (protected branch, permissions) would be rejected again: go straight to the salvage branch
|
|
674
|
+
for (const ref of [...(rejected ? [] : [branch]), `forge-salvage/${issue}-${laneHead.sha.slice(0, 10)}`]) {
|
|
675
|
+
try {
|
|
676
|
+
await git(["push", "origin", `${laneHead.sha}:refs/heads/${ref}`]);
|
|
677
|
+
laneHead.pushedTo = ref;
|
|
678
|
+
ev({ event: "CHECKPOINT_RESCUED", reason, ref, sha: laneHead.sha });
|
|
679
|
+
return !String(await git(["status", "--porcelain"])).trim(); // an uncommitted remainder keeps the worktree
|
|
680
|
+
} catch (e) { ev({ event: "CHECKPOINT_RESCUE_FAILED", reason, ref, error: String(e?.message ?? e).slice(0, 300) }); }
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
/** #4958: where the lane's commits ended up, for the terminal comment, RUN_TERMINAL and the returned result. */
|
|
685
|
+
async function reportLaneHead(reason) {
|
|
686
|
+
if (!laneHead || reason === "merged") return;
|
|
687
|
+
const { branch: b, sha, pushedTo } = laneHead;
|
|
688
|
+
const line = pushedTo ? `Lane commits pushed: branch \`${pushedTo}\` at ${sha.slice(0, 10)}; a rerun or --requeue resumes from it` : `Lane commits NOT pushed: ${sha.slice(0, 10)} on \`${b}\` exists only in this lane's worktree and is lost if its sandbox is torn down`;
|
|
689
|
+
T.anomalies.push(line);
|
|
690
|
+
if (!["invalid", "needs-human", "budget-exceeded"].includes(reason)) { try { await post("FORGE:LANE_BRANCH", `## Lane branch\n${line}`); } catch { /* best effort */ } }
|
|
662
691
|
}
|
|
663
692
|
const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
|
|
664
693
|
const recoverNote = () => (recover ? `\n\nRecovery pass (${recover.code}): ${(RECOVER_HOW[recover.code] ?? "an earlier run ended needs-human for a mechanical reason; resolve it").replaceAll("BASE", lane)}.` : "");
|
|
@@ -825,6 +854,7 @@ export async function runCore(o) {
|
|
|
825
854
|
const digest = budgetDigest({ size, budget, used: tb.record().build, digest: res?.status === "budget-exceeded" ? res.digest : null, stat: facts.stat, check: facts.check });
|
|
826
855
|
throw Object.assign(new Error(digest), { laneCode: OUTCOME_CODES.BUDGET_EXCEEDED });
|
|
827
856
|
}
|
|
857
|
+
const keepPaths = async () => ((await readContract())?.deliverables ?? []).map((d) => d?.path).filter((x) => typeof x === "string"); // #4959: deliverables are never untracked as generated files
|
|
828
858
|
const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
829
859
|
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
830
860
|
/** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
|
|
@@ -1049,6 +1079,10 @@ export async function runCore(o) {
|
|
|
1049
1079
|
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
|
|
1050
1080
|
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
1051
1081
|
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
|
|
1082
|
+
if (!prs.length && !remote) { // #4958: commits the exit rescue could only push to a salvage branch resume from there
|
|
1083
|
+
const salv = String(await io.git(["ls-remote", "--heads", "origin", `refs/heads/forge-salvage/${issue}-*`], { cwd })).split("\n").map((l) => l.trim().split(/\s+/)[1]).filter(Boolean).sort().at(-1);
|
|
1084
|
+
if (salv) { await io.git(["fetch", "origin", `${salv}:refs/remotes/origin/${branch}`], { cwd }); resumed = { pr: null, base: null }; ev({ event: "RESUME_FROM_SALVAGE", ref: salv }); }
|
|
1085
|
+
}
|
|
1052
1086
|
} catch { /* no resume info: start fresh */ }
|
|
1053
1087
|
// An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
|
|
1054
1088
|
// #4593: a shipped milestone lane (fully merged into staging) cannot take new work; follow the open PR's live base.
|
|
@@ -1546,7 +1580,7 @@ export async function runCore(o) {
|
|
|
1546
1580
|
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1547
1581
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1548
1582
|
let ctxPack = frozen || lightTry ? null : await contextPackStep();
|
|
1549
|
-
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + invariantsPlanNote(vcfg.invariants) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1583
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + invariantsPlanNote(vcfg.invariants) + reproPlanNote(reproOn()) + unrunnableValidateNote(testRunners) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1550
1584
|
let lightDone = false, lightCommits = false;
|
|
1551
1585
|
if (lightTry) {
|
|
1552
1586
|
liveKeys = ["implementation"]; stage = "implement"; phaseLedger.enter("implement");
|
|
@@ -1622,8 +1656,8 @@ export async function runCore(o) {
|
|
|
1622
1656
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1623
1657
|
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
1624
1658
|
let misses = missed;
|
|
1625
|
-
if ((!vc.ok || missed.length) && sessionId) {
|
|
1626
|
-
const problems = [...vc.problems, ...preflightProblems(missed)];
|
|
1659
|
+
if ((!vc.ok || missed.length || testRunnerProblems(contract, testRunners).length) && sessionId) {
|
|
1660
|
+
const problems = [...vc.problems, ...preflightProblems(missed), ...testRunnerProblems(contract, testRunners)];
|
|
1627
1661
|
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1628
1662
|
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
|
|
1629
1663
|
contract = dropScope(await readContract());
|
|
@@ -1876,6 +1910,7 @@ export async function runCore(o) {
|
|
|
1876
1910
|
}
|
|
1877
1911
|
{ const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
|
|
1878
1912
|
/** #4406: same-app duplicate exports (verify.duplicate_exports): one "rename or reuse" round, then an anomaly. True when a fix round ran. */
|
|
1913
|
+
let engineWhy = null; // the reason an amendment gets when the engine demanded the change (a blank builder why is not its fault)
|
|
1879
1914
|
async function duplicateGate(trigger) {
|
|
1880
1915
|
if (!io.duplicateExports || isSplit || vcfg.duplicateExports === false) return false;
|
|
1881
1916
|
const found = async () => (await io.duplicateExports({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
@@ -1884,6 +1919,7 @@ export async function runCore(o) {
|
|
|
1884
1919
|
const text = dups.map((d) => `duplicate export \`${d.symbol}\`: ${d.path} and ${(d.others ?? []).join(", ")}`).join("\n");
|
|
1885
1920
|
ev({ event: "DUPLICATE_EXPORTS", trigger, count: dups.length });
|
|
1886
1921
|
T.decisions.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path} vs ${(d.others ?? []).join(", ")}`).join("; ")}; builder resumed once`);
|
|
1922
|
+
engineWhy = `engine-demanded rename: duplicate export(s) ${dups.slice(0, 5).map((d) => d.symbol).join(", ")} (${trigger})`;
|
|
1887
1923
|
await fixRound("duplicate", duplicateExportsPrompt(dups, vcfg.mode), [], "duplicate", text);
|
|
1888
1924
|
result = (await readResult()) ?? result;
|
|
1889
1925
|
files = await changedFiles();
|
|
@@ -1975,7 +2011,7 @@ export async function runCore(o) {
|
|
|
1975
2011
|
async function ciVerify(trigger) {
|
|
1976
2012
|
let fixes = 0;
|
|
1977
2013
|
for (;;) {
|
|
1978
|
-
await untrackToolState({ git, base, ev }); // #4621: gitPush's untrack commit would move HEAD after `head` was read; do it first so head is the pushed tip
|
|
2014
|
+
await untrackToolState({ git, base, ev, keep: await keepPaths() }); // #4621: gitPush's untrack commit would move HEAD after `head` was read; do it first so head is the pushed tip
|
|
1979
2015
|
await signOffUnpushed(); // #4651: likewise the sign-off rewrite
|
|
1980
2016
|
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1981
2017
|
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
@@ -2081,10 +2117,11 @@ export async function runCore(o) {
|
|
|
2081
2117
|
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
2082
2118
|
const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
|
|
2083
2119
|
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\n").filter(Boolean) : null;
|
|
2120
|
+
const addedPaths = wantsDecision ? String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").filter(Boolean) : [];
|
|
2084
2121
|
const own = [...(Array.isArray(result?.amendments) ? result.amendments : []), ...impliedDocs];
|
|
2085
2122
|
const ownKeys = new Set(own.map((x) => JSON.stringify(x)));
|
|
2086
2123
|
const all = [...priorAmendments.filter((x) => !ownKeys.has(JSON.stringify(x))), ...own];
|
|
2087
|
-
const a = applyAmendments(frozen, all, issueText, { generatedPaths: genPaths, baseFiles: baseTree?.length ? baseTree : null });
|
|
2124
|
+
const a = applyAmendments(frozen, all, issueText, { generatedPaths: genPaths, baseFiles: baseTree?.length ? baseTree : null, addedPaths, defaultWhy: engineWhy });
|
|
2088
2125
|
const sig = JSON.stringify(a.applied);
|
|
2089
2126
|
if (a.applied.length && sig !== lastAmend) {
|
|
2090
2127
|
lastAmend = sig;
|
|
@@ -2115,7 +2152,7 @@ export async function runCore(o) {
|
|
|
2115
2152
|
await renew();
|
|
2116
2153
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
2117
2154
|
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
2118
|
-
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
|
|
2155
|
+
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
|
|
2119
2156
|
await renew();
|
|
2120
2157
|
await attempt("review", ++reviewN, rr, null, "review");
|
|
2121
2158
|
const parsed = parseReview(rr?.text);
|
|
@@ -2173,7 +2210,8 @@ export async function runCore(o) {
|
|
|
2173
2210
|
}
|
|
2174
2211
|
const verifierSection = () => { const n = renderVerifierNote(verifierRun); return n ? `\n\n${n}` : ""; };
|
|
2175
2212
|
/** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
|
|
2176
|
-
|
|
2213
|
+
/** #5005: a frozen forgedock entry-point path missing on this host is replaced by this install's own. */
|
|
2214
|
+
const forgedockResolved = (c) => resolveForgedockPaths(String(c), { exists: existsSync, checksCli: CHECKS_CLI, forgedockCli: FORGEDOCK_CLI });
|
|
2177
2215
|
async function rerunFailedValidatesRaw(contract) {
|
|
2178
2216
|
if (!io.runCommand) return null;
|
|
2179
2217
|
const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
|
|
@@ -2182,11 +2220,11 @@ export async function runCore(o) {
|
|
|
2182
2220
|
const r = runs.find((x) => x?.id === v.id);
|
|
2183
2221
|
if (!r || r.exit === undefined || r.exit === 0) continue;
|
|
2184
2222
|
let x;
|
|
2185
|
-
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command:
|
|
2223
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: forgedockResolved(v.command), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2186
2224
|
// #4761: a runner usage error (vitest -q) is the frozen command's defect: try the normalised command once
|
|
2187
2225
|
const fixed = cliUsageError(x ?? {}) ? normalizeRunnerCommand(v.command) : null;
|
|
2188
2226
|
if (fixed && fixed !== String(v.command)) {
|
|
2189
|
-
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: fixed, cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2227
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: forgedockResolved(fixed), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2190
2228
|
const note = `Validate ${v.id} hit a runner usage error; reran normalised (\`${fixed}\`): exit ${x?.rc}`;
|
|
2191
2229
|
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
2192
2230
|
}
|
|
@@ -2195,6 +2233,20 @@ export async function runCore(o) {
|
|
|
2195
2233
|
if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
2196
2234
|
return out;
|
|
2197
2235
|
}
|
|
2236
|
+
/** #4979 SPEC-2: rerun the frozen command of every substituted validate, so precheck accepts a substitute only for a command that cannot run. */
|
|
2237
|
+
async function rerunSubstitutedOriginals(contract) {
|
|
2238
|
+
const out = {};
|
|
2239
|
+
if (!io.runCommand) return out;
|
|
2240
|
+
const env = laneEnv();
|
|
2241
|
+
for (const v of contract.validate ?? []) {
|
|
2242
|
+
if (!v.substituted?.from) continue;
|
|
2243
|
+
let x;
|
|
2244
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: forgedockResolved(v.substituted.from), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2245
|
+
out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
|
|
2246
|
+
}
|
|
2247
|
+
if (Object.keys(out).length) ev({ event: "SUBSTITUTE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
2248
|
+
return out;
|
|
2249
|
+
}
|
|
2198
2250
|
/** #4693: select the guards this diff intersects and rerun their base test files; zero cost when none is selected. Best effort: an error skips the gate. */
|
|
2199
2251
|
let guardSel = [], guardNoted = ""; let guardEv = null; // #4705: this pass's guard_run event, emitted by the caller once the added wall time is known
|
|
2200
2252
|
async function guardGate(files) {
|
|
@@ -2204,7 +2256,7 @@ export async function runCore(o) {
|
|
|
2204
2256
|
const picked = guards.length ? await selectGuards({ guards, files, git: (a) => git(a), base }) : { selected: [], mergeBase: null };
|
|
2205
2257
|
sel = picked.selected; guardSel = sel;
|
|
2206
2258
|
if (!sel.length) return [];
|
|
2207
|
-
r = await runGuards({ selected: sel, mergeBase: picked.mergeBase, contract: effective, git: (a) => git(a), runCommand: (a) => io.runCommand(a), cwd: wt, env: laneEnv(), parallelism: Number(process.env.FORGE_HOST_PARALLELISM) || 2 });
|
|
2259
|
+
r = await runGuards({ selected: sel, mergeBase: picked.mergeBase, contract: effective, guardCommand: vcfg.guardCommand, git: (a) => git(a), runCommand: (a) => io.runCommand(a), cwd: wt, env: laneEnv(), parallelism: Number(process.env.FORGE_HOST_PARALLELISM) || 2 });
|
|
2208
2260
|
} catch (e) { T.anomalies.push(`Guard gate skipped: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}`); guardEv = { event: "guard_run", selected: sel.map((x) => x.guard.issue), ran: 0, broken: 0, skipped: 0, wall_ms: 0, error: true }; return []; }
|
|
2209
2261
|
const note = `Guard gate selected ${sel.map((x) => `#${x.guard.issue} (${x.reason})`).join("; ")}${r.skipped.length ? `; skipped: ${r.skipped.join("; ")}` : ""}${r.failures.length && shadow ? "; shadow: would block" : ""}`;
|
|
2210
2262
|
if (note !== guardNoted) { guardNoted = note; T.decisions.push(note); }
|
|
@@ -2235,15 +2287,18 @@ export async function runCore(o) {
|
|
|
2235
2287
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
|
|
2236
2288
|
const hotspots = await hotspotsOf({ base, paths: files }); // #4502
|
|
2237
2289
|
const gateT0 = now(); let validateMs = 0;
|
|
2238
|
-
|
|
2290
|
+
// #4693: guards run beside the validate rerun; SPEC-11: the substituted-original rerun shares the one validate phase
|
|
2291
|
+
const validatePhase = phaseLedger.during("validate", async () => ({ runs: await rerunFailedValidatesRaw(effective), subs: await rerunSubstitutedOriginals(effective) })).finally(() => { validateMs = now() - gateT0; });
|
|
2292
|
+
const [{ runs: validateRuns, subs: substitutedRuns }, guardFailures] = await Promise.all([validatePhase, guardGate(files)]);
|
|
2239
2293
|
// #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
|
|
2240
2294
|
const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
|
|
2241
2295
|
guardAddedMs += guardRunMs;
|
|
2242
2296
|
if (guardEv) ev({ ...guardEv, added_wall_ms: guardRunMs });
|
|
2243
|
-
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
2297
|
+
const repoHas = (validateRuns && Object.keys(validateRuns).length) || Object.keys(substitutedRuns ?? {}).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
2244
2298
|
// #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
|
|
2245
2299
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2246
|
-
const
|
|
2300
|
+
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2301
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths });
|
|
2247
2302
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2248
2303
|
if (pc.superseded?.length) {
|
|
2249
2304
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
@@ -2913,6 +2968,7 @@ export async function runCore(o) {
|
|
|
2913
2968
|
if (owner) return finish("deferred", `git push was rejected (non-fast-forward): ${owner.detail}; another lane owns ${branch}`, { code: OUTCOME_CODES.LEASE_HELD, branch }, OUTCOME_CODES.LEASE_HELD);
|
|
2914
2969
|
}
|
|
2915
2970
|
if (e?.pushRejected) return await needsHuman(`git push was rejected (no retry): ${String(e?.stderr ?? e?.message ?? e).slice(0, 400)}`);
|
|
2971
|
+
if (e?.engineCode) return finish("engine-error", e?.message ?? String(e), { code: e.engineCode }, e.engineCode); // #4997
|
|
2916
2972
|
return finish("engine-error", e?.message ?? String(e));
|
|
2917
2973
|
}
|
|
2918
2974
|
}
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { parseVerify } from "./verify.mjs";
|
|
|
15
15
|
import { worktreeFingerprint } from "./precheck.mjs";
|
|
16
16
|
import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderStructure, renderContract, renderAmendments, parseAmendmentsComment, contractJson, parseContractComment, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
17
17
|
import { orchestrate, parseDeps, formatTable, pickNext } from "./orchestrate.mjs";
|
|
18
|
-
import { REVIEWER_SYSTEM, REVIEW_RUBRIC, approxTokens, builderPlanPrompt, builderImplementPrompt, reviewerPrompt, decisionNote, hotspotPlanNote, hotspotBuildRule } from "./prompts.mjs";
|
|
18
|
+
import { REVIEWER_SYSTEM, REVIEW_RUBRIC, approxTokens as rawTokens, FORGEDOCK_CLI, builderPlanPrompt, builderImplementPrompt, reviewerPrompt, investigationVerdict, decisionNote, hotspotPlanNote, hotspotBuildRule } from "./prompts.mjs";
|
|
19
19
|
import { parseReview, unnamedEdges, structureNote, applyAmendments, validateContract, scopeText, DECISION_SOURCES } from "./conformance.mjs";
|
|
20
20
|
import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
21
21
|
import { readLog as readRepoLog } from "../engine/runlog.mjs";
|
|
@@ -30,6 +30,10 @@ import { readAdmission, readConformanceMode, readReviewIncomplete, readEnvConfig
|
|
|
30
30
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
31
31
|
import { isExternalKill } from "./hostkill.mjs";
|
|
32
32
|
import { runFromCli, orchestrateFromCli, ENGINE_PATHS, readMaintenance } from "../engine-cli.mjs";
|
|
33
|
+
// #4969: prompt budgets are measured with the install root as a fixed placeholder, so they never depend on the checkout location
|
|
34
|
+
const INSTALL_ROOT = FORGEDOCK_CLI.replace(/[\\/]bin[\\/]forgedock\.mjs$/, "");
|
|
35
|
+
const INSTALL_PATH_RE = /\S*?[\\/](?=bin[\\/][\w.\\/-]+\.mjs)/g;
|
|
36
|
+
const approxTokens = (s) => rawTokens(String(s).replaceAll(INSTALL_ROOT, "<root>").replace(INSTALL_PATH_RE, "<root>/"));
|
|
33
37
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
34
38
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("lean-sched-");
|
|
35
39
|
|
|
@@ -573,6 +577,14 @@ describe("core work-on", () => {
|
|
|
573
577
|
assert.ok(w.comments.some((c) => /FORGE:DECOMPOSED/.test(c) && /size: 40 files, 4000 lines .* oversize yes/.test(c)));
|
|
574
578
|
});
|
|
575
579
|
|
|
580
|
+
it("#4857 a product-decision block over an unrunnable validate is untyped (one correction resume), a real one stays human", () => {
|
|
581
|
+
const u = classifyBlock({ summary: "V2 cannot run: poetry found no pyproject at the repo root", block: { kind: "human", why: "product-decision" } }, 1);
|
|
582
|
+
assert.equal(u.kind, "untyped");
|
|
583
|
+
assert.match(u.problem, /substitute-validate/);
|
|
584
|
+
assert.equal(classifyBlock({ summary: "needs a pricing decision", block: { kind: "human", why: "product-decision" } }, 1).kind, "human");
|
|
585
|
+
assert.equal(classifyBlock({ summary: "V1 needs the prod key", block: { kind: "human", why: "credentials" } }, 1).kind, "human");
|
|
586
|
+
});
|
|
587
|
+
|
|
576
588
|
it("#4385 split siblings that declare a shared hub file are serialised (chained, no duplicate transitive edge); disjoint or undeclared files stay parallel", () => {
|
|
577
589
|
const hub = ["src/domain/index.ts", "./src/domain/events.ts"];
|
|
578
590
|
const BIG = { files: 40, lines: 2000 }; // #4396: shared or undeclared files are admitted only over the size budget
|
|
@@ -1354,6 +1366,24 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1354
1366
|
assert.match(nhBody(w), /timed out \(exit 124\) even after one longer retry/);
|
|
1355
1367
|
}
|
|
1356
1368
|
});
|
|
1369
|
+
it("#4979 the engine reruns a substituted validate's frozen command: a runnable failing one blocks, an unrunnable one lets the substitute stand", async () => {
|
|
1370
|
+
const amendments = [{ kind: "substitute-validate", validate: "V1", command: "node --test test/foo2.test.js", why: "the frozen path does not exist" }];
|
|
1371
|
+
const validation = [{ id: "V1", command: "node --test test/foo2.test.js", exit: 0 }];
|
|
1372
|
+
const lane = (frozenOut) => {
|
|
1373
|
+
const w = world({ validation, amendments, resumeValidation: validation, resumeAmendments: amendments }), ran = [];
|
|
1374
|
+
w.io.runCommand = async (a) => { ran.push(a); return a.command === "node --test test/foo.test.js" ? frozenOut : { rc: 0, output: "" }; };
|
|
1375
|
+
return { w, ran };
|
|
1376
|
+
};
|
|
1377
|
+
const dir = tmpDir("subst-");
|
|
1378
|
+
const bad = lane({ rc: 1, output: "not ok 1 - foo handles null\nAssertionError: expected 1" });
|
|
1379
|
+
const rb = await run(bad.w, { conformance: "enforce", dir });
|
|
1380
|
+
assert.equal(rb.terminalReason, "needs-human"); assert.equal(bad.w.merges.length, 0);
|
|
1381
|
+
assert.match(nhBody(bad.w), /frozen command runs and fails \(exit 1\)/);
|
|
1382
|
+
assert.ok(bad.ran.some((a) => a.command === "node --test test/foo.test.js" && a.cwd === WT && a.env), "the frozen command is rerun in the worktree with the lane env");
|
|
1383
|
+
assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "SUBSTITUTE_RERUN").map((e) => e.runs), [[{ id: "V1", rc: 1 }], [{ id: "V1", rc: 1 }]]);
|
|
1384
|
+
const good = lane({ rc: 1, output: "no tests ran: no test files matched test/foo.test.js" });
|
|
1385
|
+
assert.equal((await run(good.w, { conformance: "enforce" })).terminalReason, "merged");
|
|
1386
|
+
});
|
|
1357
1387
|
it("#4323 a rerun that fails for a repo reason stays conformance.unimplemented; one that passes merges", async () => {
|
|
1358
1388
|
const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
|
|
1359
1389
|
const w = world({ validation: v, resumeValidation: v });
|
|
@@ -1784,7 +1814,7 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
|
|
|
1784
1814
|
const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
|
|
1785
1815
|
// 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line). 2050: #4724 (deferred-row bucket rule).
|
|
1786
1816
|
assert.ok(approxTokens(plan) <= 2150, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
|
|
1787
|
-
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1650); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each; 1650: #4726 (e2e rule)
|
|
1817
|
+
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1650, `implement prompt with the hotspot rule is ${approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([])))} of 1650 tokens`); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each; 1650: #4726 (e2e rule)
|
|
1788
1818
|
});
|
|
1789
1819
|
|
|
1790
1820
|
it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
|
|
@@ -2220,7 +2250,7 @@ describe("comment templates", () => {
|
|
|
2220
2250
|
it("plan and implement prompts carry the stage templates and the contract schema within budget", () => {
|
|
2221
2251
|
const plan = builderPlanPrompt(7), impl = builderImplementPrompt();
|
|
2222
2252
|
assert.ok(approxTokens(plan) <= 2050, `plan prompt is ${approxTokens(plan)} tokens`); // 2050: #4769 (activation-row rule); 2000: #4730 (owner-step remainder); 1950: #4724 (deferred-row bucket rule)
|
|
2223
|
-
assert.ok(approxTokens(impl) <= 1650, `implement prompt is ${approxTokens(impl)} tokens`); // 1650: #4726 e2e rule; 1550: #4596 i18n translate rule
|
|
2253
|
+
assert.ok(approxTokens(impl) <= 1650, `implement prompt is ${approxTokens(impl)} of 1650 tokens`); // 1650: #4726 e2e rule; 1550: #4596 i18n translate rule
|
|
2224
2254
|
for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
|
|
2225
2255
|
"investigation.md", "contract.json", "PLANNING ONLY", '"requirements"', '"deliverables"', '"preserve"', '"validate"', "verbatim", "defer", "post-deploy"]) assert.ok(plan.includes(h), h);
|
|
2226
2256
|
for (const h of ["### Approach", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes", "implementation.md", "result.json",
|
|
@@ -3789,7 +3819,7 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3789
3819
|
const w = world({ pushErrors: Array(10).fill("fatal: the remote end hung up unexpectedly\nerror: RPC failed; HTTP 503") });
|
|
3790
3820
|
const r = await run(w);
|
|
3791
3821
|
assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "push.transient");
|
|
3792
|
-
assert.equal(w.pushes,
|
|
3822
|
+
assert.equal(w.pushes, 10, "4 for the PR push, 4 by the exit checkpoint that never discards unpushed work (#4179), 1 lane-branch rescue, 1 salvage rescue (#4958)");
|
|
3793
3823
|
assert.equal(w.prs.length, 0);
|
|
3794
3824
|
});
|
|
3795
3825
|
|
|
@@ -3797,12 +3827,63 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
3797
3827
|
for (const msg of ["remote: error: GH006: Protected branch update failed for refs/heads/x", "fatal: unable to access: The requested URL returned error: 403", "! [remote rejected] x -> x (pre-receive hook declined)"]) {
|
|
3798
3828
|
const w = world({ pushErrors: [msg, msg, msg] });
|
|
3799
3829
|
const r = await run(w);
|
|
3800
|
-
assert.equal(w.pushes,
|
|
3830
|
+
assert.equal(w.pushes, 3, `${msg}: one rejected PR push, one rejected exit checkpoint (#4179), no retries; the third error rejects the salvage rescue (#4958)`);
|
|
3801
3831
|
assert.equal(r.terminalReason, "needs-human");
|
|
3802
3832
|
assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "remove").length, 1, "the unpushed worktree is kept: only the setup remove ran");
|
|
3803
3833
|
}
|
|
3804
3834
|
});
|
|
3805
3835
|
|
|
3836
|
+
it("#4958 a failed exit push is rescued to the lane branch by sha and the branch and sha are reported", async () => {
|
|
3837
|
+
const odd = "fatal: unexpected sandbox git failure";
|
|
3838
|
+
const w = world({ pushErrors: [odd, odd] });
|
|
3839
|
+
const r = await run(w);
|
|
3840
|
+
assert.equal(w.pushes, 3, "PR push, failed exit checkpoint, rescue push");
|
|
3841
|
+
const rescue = w.calls.filter((c) => c[0] === "git" && c[1] === "push").at(-1);
|
|
3842
|
+
assert.match(rescue.join(" "), /origin [0-9a-z]+:refs\/heads\/feat\/issue-7-lean$/);
|
|
3843
|
+
assert.equal(r.laneHead.pushedTo, "feat/issue-7-lean"); assert.ok(r.laneHead.sha);
|
|
3844
|
+
assert.ok(w.telemetry.some((t) => t.lane_branch === "feat/issue-7-lean" && t.lane_sha === r.laneHead.sha));
|
|
3845
|
+
});
|
|
3846
|
+
|
|
3847
|
+
it("#5014 the rescue pushes and reports the head after the pre-push sign-off rewrite, not the sha captured before it", async () => {
|
|
3848
|
+
const odd = "fatal: unexpected sandbox git failure";
|
|
3849
|
+
const w = world({ pushErrors: [odd, odd] });
|
|
3850
|
+
const g = w.io.git;
|
|
3851
|
+
let pushes = 0;
|
|
3852
|
+
w.io.git = async (a, o2) => { if (a[0] === "push" && ++pushes === 2) w.head = "rewritten0000000000000000000000000000000"; return g(a, o2); }; // the exit push rewrote HEAD (DCO) then failed
|
|
3853
|
+
const r = await run(w);
|
|
3854
|
+
const rescue = w.calls.filter((c) => c[0] === "git" && c[1] === "push").at(-1);
|
|
3855
|
+
assert.match(rescue.join(" "), /origin rewritten0+:refs\/heads\/feat\/issue-7-lean$/);
|
|
3856
|
+
assert.equal(r.laneHead.sha, "rewritten0000000000000000000000000000000");
|
|
3857
|
+
assert.ok(w.telemetry.some((t) => t.lane_sha === "rewritten0000000000000000000000000000000"));
|
|
3858
|
+
});
|
|
3859
|
+
|
|
3860
|
+
it("#4958 a rejected lane-branch push is rescued to a forge-salvage branch and reported as pushed there", async () => {
|
|
3861
|
+
const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
|
|
3862
|
+
const w = world({ pushErrors: [msg, msg] });
|
|
3863
|
+
const r = await run(w);
|
|
3864
|
+
assert.equal(w.pushes, 3, "PR push, rejected exit checkpoint, one salvage push (no second lane-branch attempt)");
|
|
3865
|
+
assert.match(w.calls.filter((c) => c[0] === "git" && c[1] === "push").at(-1).join(" "), /:refs\/heads\/forge-salvage\/7-/);
|
|
3866
|
+
assert.match(r.laneHead.pushedTo, /^forge-salvage\/7-/);
|
|
3867
|
+
assert.ok(traj(w).some((c) => /Lane commits pushed: branch `forge-salvage\/7-/.test(c)));
|
|
3868
|
+
});
|
|
3869
|
+
|
|
3870
|
+
it("#4958 when every rescue push is rejected the head is reported as NOT pushed", async () => {
|
|
3871
|
+
const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
|
|
3872
|
+
const w = world({ pushErrors: [msg, msg, msg, msg] });
|
|
3873
|
+
const r = await run(w);
|
|
3874
|
+
assert.equal(r.laneHead.pushedTo, null);
|
|
3875
|
+
assert.ok(traj(w).some((c) => /Lane commits NOT pushed/.test(c)));
|
|
3876
|
+
});
|
|
3877
|
+
|
|
3878
|
+
it("#4958 a rerun with only a forge-salvage branch on origin resumes from it", async () => {
|
|
3879
|
+
const w = world({ prList: [] });
|
|
3880
|
+
const g = w.io.git;
|
|
3881
|
+
w.io.git = async (a, o2) => (a[0] === "ls-remote" && a.at(-1) === "refs/heads/forge-salvage/7-*" ? "abc\trefs/heads/forge-salvage/7-1234567890\n" : g(a, o2));
|
|
3882
|
+
await run(w);
|
|
3883
|
+
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "fetch" && c.includes("refs/heads/forge-salvage/7-1234567890:refs/remotes/origin/feat/issue-7-lean")));
|
|
3884
|
+
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "worktree" && c[2] === "add" && c.includes("origin/feat/issue-7-lean")), "the worktree is built from the salvaged head");
|
|
3885
|
+
});
|
|
3886
|
+
|
|
3806
3887
|
it("#4118 GitHub's transient '[remote rejected] ... (failed)' and pack write errors are retried, then merge", async () => {
|
|
3807
3888
|
for (const msg of ["To github.com:o/r.git\n ! [remote rejected] feat/issue-7-lean -> feat/issue-7-lean (failed)",
|
|
3808
3889
|
"remote: error: unable to write file ./objects/pack/pack-1.pack: No such file or directory\n ! [remote rejected] x -> x (failed)"]) {
|
|
@@ -4144,7 +4225,7 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
4144
4225
|
const prior = { body: `<!-- FORGE:AMENDMENT -->\n${renderAmendments([{ kind: "supersedes", target: "#4785", why: sup.why }], {}, { contract: contractHash(contractJson(CONTRACT)), amendments: [sup] })}`, author_association: "OWNER", user: { type: "User", login: "op" } };
|
|
4145
4226
|
const dir = tmpDir("guard-recover-");
|
|
4146
4227
|
const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", issueComments: [frozenComment(), prior], baseFiles: { ":src/foo.js": "old" } });
|
|
4147
|
-
w.io.runCommand = async () => ({ rc: 1, output: "guard test failed" });
|
|
4228
|
+
w.io.runCommand = async (a) => ({ rc: String(a?.cwd).includes("guardbase-") ? 0 : 1, output: "guard test failed" }); // #4968: the base tree passes, so the lane tree is what broke
|
|
4148
4229
|
await run(w, { dir, worktreeRoot: dir, recover: { code: "ci.red", attempt: 1 }, loadGuards: async () => [mkGuard(4785), mkGuard(9)] });
|
|
4149
4230
|
const text = JSON.stringify([w.comments, w.calls.filter((c) => c[0] === "runner").map((c) => c[4])]);
|
|
4150
4231
|
assert.match(text, /conformance\.guard_broken.{0,400}?guard\\":\\"#9\\"/, "precheck reports #9 as guard_broken");
|
|
@@ -6780,7 +6861,8 @@ describe("#4395 lane profile", () => {
|
|
|
6780
6861
|
assert.match(p, /Issue independent read-only calls \(reads, greps, git log\/blame, gh\) in the same turn or one shell call\./);
|
|
6781
6862
|
assert.match(p, /Read each file's ranges once .* and keep notes; don't re-read it\./);
|
|
6782
6863
|
}
|
|
6783
|
-
assert.ok(approxTokens(builderImplementPrompt()) <= 1650
|
|
6864
|
+
assert.ok(approxTokens(builderImplementPrompt()) <= 1650, `implement prompt is ${approxTokens(builderImplementPrompt())} of 1650 tokens`);
|
|
6865
|
+
assert.ok(approxTokens(builderPlanPrompt(7)) <= 2050, `plan prompt is ${approxTokens(builderPlanPrompt(7))} of 2050 tokens`);
|
|
6784
6866
|
});
|
|
6785
6867
|
|
|
6786
6868
|
it("runCliBackendAsync reports the first stream turn's context as baselineTokens; makeLeanRunner drops it on resume", async () => {
|
|
@@ -7068,3 +7150,33 @@ describe("mutation check (engine.conformance_mutation, #4913)", () => {
|
|
|
7068
7150
|
assert.equal(r.code, "conformance.mutation_survived"); assert.equal(w.merges.length, 0);
|
|
7069
7151
|
});
|
|
7070
7152
|
});
|
|
7153
|
+
|
|
7154
|
+
describe("investigation verdict for the reviewer (#4959)", () => {
|
|
7155
|
+
it("investigationVerdict reads the Verdict line", () => {
|
|
7156
|
+
assert.equal(investigationVerdict("**Verdict**: validated\n**Confidence**: high"), "VALIDATED");
|
|
7157
|
+
assert.equal(investigationVerdict("**Verdict**: CONFIRMED"), "CONFIRMED");
|
|
7158
|
+
assert.equal(investigationVerdict("no verdict"), null);
|
|
7159
|
+
assert.equal(investigationVerdict(null), null);
|
|
7160
|
+
});
|
|
7161
|
+
it("reviewerPrompt carries the verdict block and rubric rule, and is unchanged without it", () => {
|
|
7162
|
+
const args = { issue: { number: 7, title: "t", body: "b" }, contract: {}, result: {}, files: [], diff: "x" };
|
|
7163
|
+
assert.ok(reviewerPrompt({ ...args, investigationVerdict: "VALIDATED" }).includes("<investigation_verdict>VALIDATED</investigation_verdict>"));
|
|
7164
|
+
assert.ok(!reviewerPrompt(args).includes("</investigation_verdict>"));
|
|
7165
|
+
assert.ok(reviewerPrompt({ ...args, investigationVerdict: "VALIDATED" }).includes("is not a failing row"));
|
|
7166
|
+
assert.ok(!REVIEW_RUBRIC.includes("is not a failing row"));
|
|
7167
|
+
});
|
|
7168
|
+
});
|
|
7169
|
+
|
|
7170
|
+
describe("prompt budgets (#4969)", () => {
|
|
7171
|
+
it("do not depend on the install path length", () => {
|
|
7172
|
+
const impl = builderImplementPrompt();
|
|
7173
|
+
assert.ok(impl.includes(INSTALL_ROOT), "the implement prompt names files under the install root");
|
|
7174
|
+
const deep = `/${"very-long-checkout-directory/".repeat(10)}forgedock`;
|
|
7175
|
+
assert.ok(deep.length >= 200);
|
|
7176
|
+
const moved = impl.replaceAll(INSTALL_ROOT, deep);
|
|
7177
|
+
assert.notEqual(moved, impl);
|
|
7178
|
+
assert.equal(approxTokens(moved), approxTokens(impl));
|
|
7179
|
+
assert.equal(approxTokens(impl.replaceAll(INSTALL_ROOT, "C:\\fd")), approxTokens(impl));
|
|
7180
|
+
assert.ok(approxTokens(moved) <= 1650, `implement prompt from a deep checkout is ${approxTokens(moved)} of 1650 tokens`);
|
|
7181
|
+
});
|
|
7182
|
+
});
|
|
@@ -69,3 +69,19 @@ describe("#4773: Blocked by edges and the blocked label", () => {
|
|
|
69
69
|
assert.deepEqual(started, [1]);
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
describe("#4917: a deferred issue still waiting when the last running lane ends", () => {
|
|
74
|
+
it("stays pending in the saved state (future notBefore) while the batch stops on the breaker", async () => {
|
|
75
|
+
let failLast; const gate = new Promise((r) => { failLast = r; });
|
|
76
|
+
let saved = null;
|
|
77
|
+
const store = { load: () => null, save: (st) => { saved = st; } };
|
|
78
|
+
await orchestrate({ issues: [1, 2], parallel: 2, pollMs: 5, readBody: async () => "", breaker: { errors: 1, window_min: 30 }, maxDeferrals: 3, store,
|
|
79
|
+
runOne: async (n) => {
|
|
80
|
+
if (n === 1) { setTimeout(failLast, 20); return { terminalReason: "deferred", code: "run.interrupted", retryInSeconds: 3600, cost_usd: 0 }; }
|
|
81
|
+
await gate; return { terminalReason: "engine-error", detail: "boom" };
|
|
82
|
+
} });
|
|
83
|
+
assert.ok(saved.pending.includes(1), JSON.stringify(saved.pending));
|
|
84
|
+
assert.ok(Number(saved.notBefore?.[1]) > Date.now(), JSON.stringify(saved.notBefore));
|
|
85
|
+
assert.deepEqual(saved.running, {});
|
|
86
|
+
});
|
|
87
|
+
});
|