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.
Files changed (69) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +29 -8
  3. package/bin/engine-cli.mjs +84 -24
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.mjs +9 -3
  8. package/bin/lean/batch.test.mjs +134 -3
  9. package/bin/lean/budget.mjs +7 -1
  10. package/bin/lean/budget.test.mjs +9 -1
  11. package/bin/lean/conformance.mjs +20 -7
  12. package/bin/lean/conformance.test.mjs +29 -2
  13. package/bin/lean/depcache.mjs +23 -3
  14. package/bin/lean/depcache.test.mjs +42 -1
  15. package/bin/lean/effects.mjs +29 -3
  16. package/bin/lean/execution.mjs +106 -14
  17. package/bin/lean/execution.test.mjs +101 -1
  18. package/bin/lean/hostkill.mjs +8 -0
  19. package/bin/lean/lean.mjs +65 -24
  20. package/bin/lean/lean.test.mjs +221 -10
  21. package/bin/lean/lightlane.mjs +38 -13
  22. package/bin/lean/lightlane.test.mjs +35 -1
  23. package/bin/lean/orchestrate.mjs +93 -26
  24. package/bin/lean/prompts.mjs +17 -7
  25. package/bin/lean/recovery.test.mjs +26 -0
  26. package/bin/lean/retry.mjs +15 -1
  27. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  28. package/bin/lean/sandbox-remote.mjs +40 -7
  29. package/bin/lean/schedd.mjs +50 -14
  30. package/bin/lean/schedd.test.mjs +133 -21
  31. package/bin/lean/seqlease.mjs +49 -2
  32. package/bin/lean/seqlease.test.mjs +14 -0
  33. package/bin/lean/sequence.mjs +54 -6
  34. package/bin/lean/sequence.test.mjs +83 -1
  35. package/bin/pool/agent.mjs +435 -0
  36. package/bin/pool/cli.mjs +110 -0
  37. package/bin/pool/docker.mjs +56 -0
  38. package/bin/pool/http.mjs +55 -0
  39. package/bin/pool/router.mjs +299 -0
  40. package/bin/pool/store.mjs +25 -0
  41. package/bin/pool/version.mjs +44 -0
  42. package/bin/runner.mjs +1 -0
  43. package/bin/stats/aggregate.mjs +6 -0
  44. package/bin/stats/index.mjs +14 -2
  45. package/bin/stats/local-source.mjs +1 -1
  46. package/bin/stats/pool.mjs +51 -0
  47. package/bin/stats/pool.test.mjs +39 -0
  48. package/bin/stats/query.mjs +2 -2
  49. package/bin/stats/stats.test.mjs +15 -1
  50. package/commands/orchestrate.md +3 -3
  51. package/docs/CONFIG.md +23 -9
  52. package/docs/OUTCOME-CODES.md +5 -3
  53. package/docs/STATS.md +7 -0
  54. package/package.json +2 -1
  55. package/pool/README.md +13 -0
  56. package/pool/image/Dockerfile +46 -0
  57. package/pool/image/claude-shim +8 -0
  58. package/pool/image/cs-cred +12 -0
  59. package/pool/image/entrypoint.sh +61 -0
  60. package/pool/image/gh-shim +6 -0
  61. package/pool/install/install-agent-linux.sh +53 -0
  62. package/pool/install/install-agent.ps1 +225 -0
  63. package/pool/install/install-agent.sh +101 -0
  64. package/pool/install/install-router.sh +67 -0
  65. package/pool/install/keepalive.ps1 +36 -0
  66. package/pool/install/uninstall-agent.ps1 +20 -0
  67. package/scripts/hotspots.sh +33 -21
  68. package/scripts/hotspots.test.sh +22 -3
  69. 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
- const ghc = (args) => io.gh(args);
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(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
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 (skips structure/hotspot gates); summed session time
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 === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
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
- if (!(e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
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 needs-human `budget.exceeded`.
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 = light ? null : await structureOf(); // #4451: no structure delta on a light lane
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
- const a = applyAmendments(frozen, result?.amendments, issueText);
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 ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1927
- const hotspots = light ? null : await hotspotsOf({ base, paths: files }); // #4451
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 = (light ? null : await structureOf()) ?? {}; // #4451
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);