forgedock 1.32.0 → 1.33.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 +7 -3
- package/bin/engine/phases.mjs +29 -8
- package/bin/engine-cli.mjs +84 -24
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.mjs +9 -3
- package/bin/lean/batch.test.mjs +134 -3
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +29 -3
- package/bin/lean/execution.mjs +106 -14
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +65 -24
- package/bin/lean/lean.test.mjs +221 -10
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +93 -26
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/retry.mjs +15 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/schedd.mjs +50 -14
- package/bin/lean/schedd.test.mjs +133 -21
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
- package/scripts/refresh-bot-token.test.sh +56 -0
package/bin/lean/lean.mjs
CHANGED
|
@@ -25,15 +25,16 @@ import { prepareToolchain } from "./toolchain.mjs";
|
|
|
25
25
|
import { toolStats } from "./toolstats.mjs";
|
|
26
26
|
import { readTestMap, readTestMapAtRef, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
27
27
|
import { runChecks, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
|
|
28
|
-
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
28
|
+
import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
29
29
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
30
30
|
import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
|
|
31
31
|
import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
32
|
+
import { hostCapabilityBlock } from "./execution.mjs";
|
|
32
33
|
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, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt } from "./prompts.mjs";
|
|
33
34
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
34
35
|
import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
|
|
35
36
|
import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
|
|
36
|
-
import { makeTurnBudget, laneSize, wrapupGrant, budgetDigest } from "./budget.mjs";
|
|
37
|
+
import { makeTurnBudget, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
|
|
37
38
|
import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog } from "./pitfalls.mjs";
|
|
38
39
|
import { unjustified } from "./enumerating.mjs";
|
|
39
40
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
@@ -46,6 +47,7 @@ import { priorityFromLabels } from "./sched.mjs";
|
|
|
46
47
|
import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
|
|
47
48
|
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
48
49
|
import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
|
|
50
|
+
import { isExternalKill } from "./hostkill.mjs";
|
|
49
51
|
import { buildResumeDigest } from "./resume.mjs";
|
|
50
52
|
import { lightEligibility } from "./light.mjs";
|
|
51
53
|
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
@@ -61,6 +63,7 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
61
63
|
[OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
|
|
62
64
|
[OUTCOME_CODES.BUDGET_EXCEEDED]: "an earlier run spent the implement turn budget for this issue's size; its digest is in the earlier run's detail and the branch holds its checkpointed work. Continue from the checkpoint against the frozen contract without starting over; if the remaining work cannot fit the budget, stop with a typed split-required block naming the sub-issues instead of grinding",
|
|
63
65
|
[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",
|
|
66
|
+
[OUTCOME_CODES.ENV_CAPABILITY_REMOTE]: "the earlier run ran in a remote sandbox that lacks a host-only capability (SOPS/age key, ops credentials, read-only prod monitor SSH); this host has it. Do the work that needed it now, with the host's own tools and credentials, and never print or commit a secret in the clear",
|
|
64
67
|
[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",
|
|
65
68
|
});
|
|
66
69
|
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
@@ -181,6 +184,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
181
184
|
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
182
185
|
* @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
|
|
183
186
|
* @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
|
|
187
|
+
* @param {object} [o.hostCaps] {hostCapabilities, hostCapabilityPatterns}: forge.yaml execution.host_capabilities (#4542); a remote credentials block naming one is env.capability_remote
|
|
184
188
|
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
185
189
|
* @param {object} [o.explore] {mode: on|off, model}: forge.yaml lean.explore / lean.explore_model (#4391, default off): the explorer subagent
|
|
186
190
|
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
@@ -216,10 +220,11 @@ export async function runCore(o) {
|
|
|
216
220
|
|
|
217
221
|
const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
|
|
218
222
|
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
219
|
-
|
|
223
|
+
let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
|
|
224
|
+
const ghc = (args) => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache);
|
|
220
225
|
const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
221
226
|
/** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
|
|
222
|
-
const ghw = (args) => retrying(() =>
|
|
227
|
+
const ghw = (args) => retrying(() => ghc(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
|
|
223
228
|
const git = (args) => io.git(args, { cwd: wt });
|
|
224
229
|
|
|
225
230
|
const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
|
|
@@ -289,7 +294,7 @@ export async function runCore(o) {
|
|
|
289
294
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
290
295
|
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
291
296
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
292
|
-
let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (
|
|
297
|
+
let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (the same structure/hotspot gates as the full lane, #4502); summed session time
|
|
293
298
|
let lightVerdict = null, lightUpgrade = null; // #4453: the eligibility verdict and the upgrade reason codes, on the run result for the bench
|
|
294
299
|
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
295
300
|
const runId = `${issue}-${t0}`;
|
|
@@ -327,6 +332,7 @@ export async function runCore(o) {
|
|
|
327
332
|
}
|
|
328
333
|
// Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
|
|
329
334
|
const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
|
|
335
|
+
try { ghCache?.dispose?.(); } catch { /* best effort */ }
|
|
330
336
|
if (runTmp) {
|
|
331
337
|
try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
|
|
332
338
|
runTmp = null;
|
|
@@ -399,6 +405,7 @@ export async function runCore(o) {
|
|
|
399
405
|
async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
|
|
400
406
|
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
401
407
|
seqLease = null;
|
|
408
|
+
try { await io.sequenceLease?.unreserve?.({ repo, lane, issue }); } catch { /* fail-open (#4526) */ }
|
|
402
409
|
if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
|
|
403
410
|
try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
|
|
404
411
|
}
|
|
@@ -416,7 +423,7 @@ export async function runCore(o) {
|
|
|
416
423
|
// decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
|
|
417
424
|
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
|
|
418
425
|
// either launches the next attempt or relabels in settle.
|
|
419
|
-
if (recoverUnlabelled && recover.final && reason === "ci-pending") {
|
|
426
|
+
if (recoverUnlabelled && recover.final && (reason === "ci-pending" || reason === "budget-exceeded")) { // #4505: budget-exceeded too
|
|
420
427
|
try {
|
|
421
428
|
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
|
|
422
429
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
@@ -426,8 +433,9 @@ export async function runCore(o) {
|
|
|
426
433
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
427
434
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
428
435
|
}
|
|
429
|
-
if (["merged", "invalid", "needs-human"].includes(reason)) {
|
|
430
|
-
if (reason === "
|
|
436
|
+
if (["merged", "invalid", "needs-human", "budget-exceeded"].includes(reason)) {
|
|
437
|
+
if (reason === "budget-exceeded") T.close = ["left open", "recoverable budget.exceeded"]; // #4505: not a human escalation
|
|
438
|
+
else if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
|
|
431
439
|
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
432
440
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
433
441
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
@@ -652,13 +660,14 @@ export async function runCore(o) {
|
|
|
652
660
|
p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
|
|
653
661
|
continue;
|
|
654
662
|
}
|
|
655
|
-
|
|
663
|
+
const hostKilled = isExternalKill(e); // #4518: SIGTERM/143 we did not send restarts like a timeout, and never ends engine-error
|
|
664
|
+
if (!(hostKilled || e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
|
|
656
665
|
const idle = e.code === "CLI_IDLE";
|
|
657
|
-
const reason = idle ? "idle timeout" : "timeout";
|
|
658
|
-
ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
|
|
666
|
+
const reason = hostKilled ? "host kill" : idle ? "idle timeout" : "timeout";
|
|
667
|
+
ev({ event: hostKilled ? "CLI_HOST_KILL" : idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
|
|
659
668
|
...(idle ? { heavyQueue: e.heavyQueue ?? null, extended: e.extended === true } : {}) });
|
|
660
669
|
await checkpoint(reason);
|
|
661
|
-
if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
|
|
670
|
+
if (timeoutRetries++ >= 1) { e.deferCode = hostKilled ? OUTCOME_CODES.CLI_HOST_KILLED : idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
|
|
662
671
|
T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
|
|
663
672
|
T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
|
|
664
673
|
p = restart(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "timeout_restart";
|
|
@@ -720,7 +729,8 @@ export async function runCore(o) {
|
|
|
720
729
|
const contextRestartNote = async () => contextRestartPrompt({ ...await restartFacts(), ceiling: contextCeilingTokens });
|
|
721
730
|
/**
|
|
722
731
|
* #4392: the implement turn budget is spent. Checkpoint, then one fresh session of `wrapupGrant` turns either finishes
|
|
723
|
-
* (its result flows on as usual) or stops with a digest; a stop or an overrun ends
|
|
732
|
+
* (its result flows on as usual) or stops with a digest; a stop or an overrun ends the non-human `budget-exceeded` reason
|
|
733
|
+
* (#4505: no label, no NEEDS_HUMAN comment; the batch recovers it at the next size), needs-human only in a final budget.exceeded recovery.
|
|
724
734
|
*/
|
|
725
735
|
async function budgetWrapup(n, opts) {
|
|
726
736
|
const size = tb.size, budget = tb.budget(), used = tb.record().build, grant = wrapupGrant(budget);
|
|
@@ -792,6 +802,11 @@ export async function runCore(o) {
|
|
|
792
802
|
async function endBlocked(b, summary) {
|
|
793
803
|
const s = summary ?? "(no summary)";
|
|
794
804
|
const refs = (ns) => ns.map((n) => `#${n}`).join(", ");
|
|
805
|
+
if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote") {
|
|
806
|
+
// #4542: the sandbox relays none of the host's capabilities; a block naming only declared ones is an environment error the host recovers.
|
|
807
|
+
const caps = hostCapabilityBlock(s, o.hostCaps);
|
|
808
|
+
if (caps.length) return await needsHuman(`Remote sandbox lacks host capability ${caps.join(", ")} (Builder is blocked (credentials)): ${s}\n\nThe dispatching host has it; the lane is recovered locally.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
|
|
809
|
+
}
|
|
795
810
|
if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
|
|
796
811
|
if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
|
|
797
812
|
if (b.kind === "dependency") {
|
|
@@ -890,6 +905,7 @@ export async function runCore(o) {
|
|
|
890
905
|
try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
|
|
891
906
|
if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
|
|
892
907
|
}
|
|
908
|
+
ghCache = io.ghCache?.(runTmp) ?? null;
|
|
893
909
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
894
910
|
try {
|
|
895
911
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
@@ -1381,12 +1397,12 @@ export async function runCore(o) {
|
|
|
1381
1397
|
let lightDone = false, lightCommits = false;
|
|
1382
1398
|
if (lightTry) {
|
|
1383
1399
|
liveKeys = ["implementation"]; stage = "implement";
|
|
1384
|
-
tb.setSize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }));
|
|
1385
|
-
const lr = await runLightStep({ title: iss.title, issueText, riskPaths, cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
|
|
1400
|
+
tb.setSize(recoverySize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4505
|
|
1401
|
+
const lr = await runLightStep({ title: iss.title, issueText, riskPaths, predictedFiles: namedPaths(iss.body ?? ""), cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
|
|
1386
1402
|
build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
|
|
1387
1403
|
readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
|
|
1388
|
-
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1389
|
-
run: ({ command, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
|
|
1404
|
+
runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
|
|
1405
|
+
run: ({ command, timeoutMs, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, timeoutMs, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
|
|
1390
1406
|
fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
|
|
1391
1407
|
apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
|
|
1392
1408
|
stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
|
|
@@ -1482,7 +1498,7 @@ export async function runCore(o) {
|
|
|
1482
1498
|
T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
|
|
1483
1499
|
}
|
|
1484
1500
|
T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
|
|
1485
|
-
tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
|
|
1501
|
+
tb.setSize(recoverySize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }), recover)); // #4392, #4505: next size on a budget recovery
|
|
1486
1502
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1487
1503
|
// #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
|
|
1488
1504
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
|
|
@@ -1582,7 +1598,7 @@ export async function runCore(o) {
|
|
|
1582
1598
|
const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
|
|
1583
1599
|
return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
|
|
1584
1600
|
};
|
|
1585
|
-
let structure =
|
|
1601
|
+
let structure = await structureOf(); // #4502: a light lane gets the same structure gate
|
|
1586
1602
|
if (structure) {
|
|
1587
1603
|
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
1588
1604
|
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
@@ -1652,7 +1668,7 @@ export async function runCore(o) {
|
|
|
1652
1668
|
ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
|
|
1653
1669
|
const taken = collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ");
|
|
1654
1670
|
if (io.sequenceRenumber) {
|
|
1655
|
-
const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
|
|
1671
|
+
const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null, reserve: io.sequenceLease?.reserve ? (x) => io.sequenceLease.reserve({ repo, lane, issue, ...x }) : null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
|
|
1656
1672
|
if (det.ok && det.moves?.length) {
|
|
1657
1673
|
ev({ event: "SEQUENCE_RENUMBERED", trigger, mode: "deterministic", moves: det.moves });
|
|
1658
1674
|
T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; renumbered deterministically (no builder): ${det.moves.map((m) => `${m.from} → ${m.to}`).join(", ")}`);
|
|
@@ -1807,8 +1823,10 @@ export async function runCore(o) {
|
|
|
1807
1823
|
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
|
|
1808
1824
|
const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
|
|
1809
1825
|
const triggerSkipNote = "## Conformance Review\n**Skipped by `review.trigger: enforce`:** no risk path, package edge or hotspot in the diff, and every deterministic gate passed; no reviewer ran (#4273).";
|
|
1826
|
+
let genPaths = null; // forge#4519: maintenance.generated_paths from hotspots.sh (plan facts; one config-only call without them, never on light lanes)
|
|
1810
1827
|
async function amend() {
|
|
1811
|
-
|
|
1828
|
+
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
1829
|
+
const a = applyAmendments(frozen, result?.amendments, issueText, { generatedPaths: genPaths });
|
|
1812
1830
|
const sig = JSON.stringify(a.applied);
|
|
1813
1831
|
if (a.applied.length && sig !== lastAmend) {
|
|
1814
1832
|
lastAmend = sig;
|
|
@@ -1923,8 +1941,9 @@ export async function runCore(o) {
|
|
|
1923
1941
|
T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
|
|
1924
1942
|
}
|
|
1925
1943
|
const amendProblems = await amend();
|
|
1926
|
-
const
|
|
1927
|
-
const
|
|
1944
|
+
const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1945
|
+
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
|
|
1946
|
+
const hotspots = await hotspotsOf({ base, paths: files }); // #4502
|
|
1928
1947
|
const validateRuns = await rerunFailedValidates(effective);
|
|
1929
1948
|
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
1930
1949
|
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
|
|
@@ -1934,6 +1953,12 @@ export async function runCore(o) {
|
|
|
1934
1953
|
const note = `Scope-check validate ${pc.superseded.map((x) => x.id).join(", ")} superseded by the scope gate (amended: ${[...new Set(pc.superseded.flatMap((x) => x.files))].join(", ")})`;
|
|
1935
1954
|
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1936
1955
|
}
|
|
1956
|
+
// #4527: name the validate ids CI verified instead of a local run
|
|
1957
|
+
if (pc.ciCovered?.length) {
|
|
1958
|
+
ev({ event: "VALIDATE_CI_VERIFIED", ids: pc.ciCovered, head: gateHead });
|
|
1959
|
+
const note = `Validate ${pc.ciCovered.join(", ")} verified by CI at ${gateHead.slice(0, 12)} (no local result)`;
|
|
1960
|
+
if (!T.decisions.includes(note)) T.decisions.push(note);
|
|
1961
|
+
}
|
|
1937
1962
|
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1938
1963
|
// #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
|
|
1939
1964
|
// never a conformance failure, never a builder resume
|
|
@@ -2133,6 +2158,10 @@ export async function runCore(o) {
|
|
|
2133
2158
|
}
|
|
2134
2159
|
return "unknown";
|
|
2135
2160
|
};
|
|
2161
|
+
/** #4507: true only when the PR reads MERGED; unreadable counts as not merged. */
|
|
2162
|
+
const prMerged = async () => {
|
|
2163
|
+
try { return String(JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "state"])) || "{}").state ?? "").toUpperCase() === "MERGED"; } catch { return false; }
|
|
2164
|
+
};
|
|
2136
2165
|
/** #4158: MERGEABLE but BLOCKED is a base branch policy block (ruleset / required checks pending), not a conflict. */
|
|
2137
2166
|
const policyBlocked = async () => {
|
|
2138
2167
|
let j;
|
|
@@ -2220,6 +2249,8 @@ export async function runCore(o) {
|
|
|
2220
2249
|
await fixRound("review", conformanceResumePrompt(rv.failures, vcfg.mode), rv.failures, "review", failureLog(rv.failures));
|
|
2221
2250
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
2222
2251
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
2252
|
+
// #4527: the fix round's head is CI-verified before the retry review, as in the main loop
|
|
2253
|
+
if (remoteVerify) { const v = await ciVerify("base-sync"); if (v.done) return v.done; }
|
|
2223
2254
|
rv = await doReview();
|
|
2224
2255
|
if (rv.env) return await envNeedsHuman(rv.env);
|
|
2225
2256
|
}
|
|
@@ -2286,7 +2317,7 @@ export async function runCore(o) {
|
|
|
2286
2317
|
* only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
|
|
2287
2318
|
*/
|
|
2288
2319
|
async function recordStructure(pr) {
|
|
2289
|
-
const d = (
|
|
2320
|
+
const d = (await structureOf()) ?? {};
|
|
2290
2321
|
const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
|
|
2291
2322
|
const top_growth = numstat.split("\n").map((l) => l.split("\t")).filter((x) => x.length >= 3 && /^\d+$/.test(x[0]) && Number(x[0]) > 0 && !x[2].startsWith(`${LEAN_DIR}/`))
|
|
2292
2323
|
.map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
|
|
@@ -2424,6 +2455,12 @@ export async function runCore(o) {
|
|
|
2424
2455
|
break;
|
|
2425
2456
|
} catch (e) {
|
|
2426
2457
|
const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
|
|
2458
|
+
if (await prMerged()) { // #4507: the merge went through despite the error
|
|
2459
|
+
T.decisions.push(`gh pr merge errored but PR #${pr} is MERGED: recorded merged (${msg.split("\n")[0].slice(0, 120)})`);
|
|
2460
|
+
try { seqLease?.release(); } catch { /* fail-open */ }
|
|
2461
|
+
seqLease = null;
|
|
2462
|
+
break;
|
|
2463
|
+
}
|
|
2427
2464
|
if (MERGE_TRANSIENT_RE.test(msg) && !/not mergeable|merge conflict/i.test(msg)) {
|
|
2428
2465
|
// "Base branch was modified", GraphQL and 5xx are transient: back off, then the loop top re-reads mergeable and the head.
|
|
2429
2466
|
if (++mergeRetries > maxMergeRetries) return await finish("deferred", `gh pr merge kept failing transiently (${mergeRetries - 1} retries): ${msg.slice(0, 300)}`, { pr, code: OUTCOME_CODES.MERGE_TRANSIENT }, OUTCOME_CODES.MERGE_TRANSIENT);
|
|
@@ -2478,6 +2515,10 @@ export async function runCore(o) {
|
|
|
2478
2515
|
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env) }, OUTCOME_CODES.RATE_LIMITED);
|
|
2479
2516
|
}
|
|
2480
2517
|
if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
|
|
2518
|
+
// #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
|
|
2519
|
+
if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
|
|
2520
|
+
return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);
|
|
2521
|
+
}
|
|
2481
2522
|
if (e?.laneCode) return await needsHuman(e.message, e.laneCode);
|
|
2482
2523
|
if (e?.code === "CLI_SPAWN_FAILED" || e?.spawnDeterministic === true) { // #4129: certain to fail again, so not engine-error (orchestrate would relaunch it)
|
|
2483
2524
|
return await needsHuman(`The claude CLI could not be started (${e.spawnErrno ?? "spawn error"}); a rerun fails the same way: ${String(e.message).slice(0, 400)}`, OUTCOME_CODES.CLI_SPAWN_FAILED);
|