forgedock 1.36.0 → 1.37.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/lean.mjs CHANGED
@@ -11,9 +11,9 @@ import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { readSelfReview, compareSelfReview } from "./selfreview.mjs";
12
12
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
13
13
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
14
- import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
14
+ import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
15
15
  import { createHash } from "node:crypto";
16
- import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
17
17
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
18
18
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
19
19
  import { checksAt } from "./postdeploy-check.mjs";
@@ -26,13 +26,19 @@ import { prepareToolchain } from "./toolchain.mjs";
26
26
  import { toolStats } from "./toolstats.mjs";
27
27
  import { readTestMap, readTestMapAtRef, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
28
28
  import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
29
- import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
29
+ import { retrying, ghCacheRetry, refreshAuthOnce, runRefreshScript, GH_AUTH_RE, GH_AUTH_RETRY_SECONDS, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
30
30
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
31
31
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
32
32
  import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
33
33
  import { hostCapabilityBlock } from "./execution.mjs";
34
+
35
+ 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;
36
+ /** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
37
+ export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
34
38
  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, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
35
39
  import { postMergeCommands } from "./postmerge.mjs";
40
+ import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
41
+ import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
36
42
  import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
37
43
  import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
38
44
  import { makeTurnBudget, TURN_BUDGET_DEFAULTS, scaledExtra, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
@@ -40,7 +46,7 @@ import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog
40
46
  import { unjustified } from "./enumerating.mjs";
41
47
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
42
48
  import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
43
- import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
49
+ import { e2eSpecs, e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
44
50
  import { laneBackendId } from "./limits.mjs";
45
51
  import { SPLIT_LABEL } from "./refactor.mjs";
46
52
  import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
@@ -48,11 +54,13 @@ import { priorityFromLabels } from "./sched.mjs";
48
54
  import { assignArm } from "../experiment.mjs";
49
55
  import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
50
56
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
57
+ import { readDecisionIndex, withDecisionRows, recordDecisions } from "./decisions.mjs";
51
58
  import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
52
59
  import { isExternalKill } from "./hostkill.mjs";
53
60
  import { buildResumeDigest } from "./resume.mjs";
54
61
  import { lightEligibility } from "./light.mjs";
55
62
  import { untrackToolState, unstageToolState } from "./toolstate.mjs";
63
+ import { ensureSignedOff } from "./signoff.mjs";
56
64
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
57
65
 
58
66
  /**
@@ -170,6 +178,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
170
178
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
171
179
  * @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
172
180
  * @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
181
+ * @param {string} [o.followupsDefaultMilestone] forge.yaml followups.default_milestone (#4664): milestone of deferred follow-ups whose parent's milestone has shipped and has no next one
173
182
  * @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
174
183
  * @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
175
184
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
@@ -226,11 +235,12 @@ export async function runCore(o) {
226
235
  const expTag = o.experiment ? { experiment: o.experiment.id, arm: assignArm(o.experiment, issue) } : {}; // #4629: same arm for the whole run
227
236
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
228
237
  let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
229
- const ghc = (args) => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache);
238
+ const authState = { refreshed: false }; // #4662: one token refresh at a time, shared by every gh call of the run
239
+ const ghc = (args) => refreshAuthOnce(() => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache), { refresh: io.refreshAuth ?? runRefreshScript, state: authState });
230
240
  const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
231
241
  /** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
232
242
  const ghw = (args) => retrying(() => ghc(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
233
- const git = (args) => io.git(args, { cwd: wt });
243
+ const git = (args, o = {}) => io.git(args, { cwd: wt, ...o });
234
244
 
235
245
  const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
236
246
  const impl = { cost: 0, models: new Set() }; // #4397: implement spend and reported models, for the verifier
@@ -250,9 +260,9 @@ export async function runCore(o) {
250
260
  usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
251
261
  usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
252
262
  }
253
- const post = async (marker, body) => {
263
+ const post = async (marker, body, on = issue) => {
254
264
  const marks = (Array.isArray(marker) ? marker : [marker]).map((m) => `<!-- ${m} -->`).join("\n");
255
- const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
265
+ const out = String(await ghw(["issue", "comment", String(on), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
256
266
  return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
257
267
  };
258
268
  /** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
@@ -274,12 +284,12 @@ export async function runCore(o) {
274
284
  let leaseState = null, leaseTimer = null;
275
285
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
276
286
  const renew = async () => { if (io.projector && leaseState && !finishing) { /* never re-take a lease a finish (or signal) released */ try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
277
- async function releaseLease(reason, code = null) {
287
+ async function releaseLease(reason, code = null, mergedTo = null) {
278
288
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
279
289
  if (!io.projector || !leaseState) return;
280
290
  const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
281
291
  // forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
282
- const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
292
+ const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort(), ...(reason === "merged" && mergedTo ? { mergedTo } : {}) } : {}; // #4711: where the change landed
283
293
  try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
284
294
  }
285
295
 
@@ -296,6 +306,7 @@ export async function runCore(o) {
296
306
  let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
297
307
  let pitMatched = null; // #4375: pitfalls in the pack that match the issue's files; null when no pack was compiled
298
308
  let factsSeen = []; // #4401: trusted comment bodies on the issue, so an identical FORGE:FACTS is never posted twice
309
+ let guardAddedMs = 0; // #4705 (R12): wall time the guard gate added, summed over the lane, carried on RUN_TERMINAL
299
310
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
300
311
  let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
301
312
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
@@ -420,7 +431,7 @@ export async function runCore(o) {
420
431
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
421
432
  const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
422
433
  conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
423
- context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
434
+ 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() } : {}),
424
435
  ...(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
425
436
  ev(term);
426
437
  await emit(term);
@@ -445,7 +456,7 @@ export async function runCore(o) {
445
456
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
446
457
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
447
458
  }
448
- await releaseLease(reason, code);
459
+ await releaseLease(reason, code, extra.mergedTo ?? null);
449
460
  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,
450
461
  light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
451
462
  ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
@@ -581,8 +592,15 @@ export async function runCore(o) {
581
592
  }
582
593
 
583
594
  /** git push with transient retry (#4114): 4 attempts; permission/protected-branch rejections fail at once. */
595
+ /** #4651: the commits a push would add are signed off with the lane identity first (placeholder or unsigned ones are rewritten; pushed ones never). */
596
+ const signOffUnpushed = async () => {
597
+ try {
598
+ const r = await ensureSignedOff({ git, lane, branch });
599
+ 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 }); }
600
+ } catch (e) { throw Object.assign(e, { laneCode: OUTCOME_CODES.CI_DCO }); }
601
+ };
584
602
  const gitPush = async (args) => {
585
- if (!args.includes("--delete")) await untrackToolState({ git, base, ev }); // #4590: never push .forge-lean/ or tool caches this branch added
603
+ if (!args.includes("--delete")) { await untrackToolState({ git, base, ev }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
586
604
  try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
587
605
  catch (e) {
588
606
  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)}`; }
@@ -666,6 +684,13 @@ export async function runCore(o) {
666
684
  p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
667
685
  continue;
668
686
  }
687
+ if (e?.code === "CLI_LANE_LIMIT") { // #4690: the lane hit its own scope limit: a rerun hits it again, so typed needs-human (env.lane_limit), never engine-error
688
+ ev({ event: "CLI_LANE_LIMIT", limit: e.limit ?? null, unit: e.unit ?? null });
689
+ await checkpoint(`scope ${e.limit} limit`);
690
+ e.laneCode = OUTCOME_CODES.ENV_LANE_LIMIT;
691
+ e.message = `The lane hit its own ${e.limit === "memory" ? "MemoryMax (memory)" : "TasksMax (process count)"} scope limit and was stopped; work checkpointed to ${branch}. Raise engine.lane_limits.${e.limit === "memory" ? "memory_max" : "tasks_max"} in forge.yaml if the work is legitimate, or find the runaway step.`;
692
+ throw e;
693
+ }
669
694
  const hostKilled = isExternalKill(e); // #4518: SIGTERM/143 we did not send restarts like a timeout, and never ends engine-error
670
695
  if (!(hostKilled || e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
671
696
  const idle = e.code === "CLI_IDLE";
@@ -778,6 +803,7 @@ export async function runCore(o) {
778
803
  const b = classifyBlock(r, issue);
779
804
  if (b.kind !== "dependency") return b;
780
805
  const open = [], blockers = [];
806
+ const lanePrs = lane ? laneMergedPrs(lane, io) : null;
781
807
  for (const d of b.issues) {
782
808
  let v;
783
809
  try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
@@ -797,6 +823,9 @@ export async function runCore(o) {
797
823
  }
798
824
  for (const [n, rec] of recs) {
799
825
  if (String(rec?.state).toUpperCase() !== "OPEN" || n === Number(issue) || open.includes(n)) continue;
826
+ let full = null; // #4711: the REST record carries the body (FORGE:STATE mergedTo) and the author association
827
+ if (lane) try { full = JSON.parse(String(await io.gh(["api", `repos/{owner}/{repo}/issues/${n}`])) || "null"); } catch { /* the PR-ancestry rule still applies */ }
828
+ if (lane && await dependencyOnLane(n, lane, lanePrs ? await lanePrs : null, io, full)) continue; // #4686: its change is on the lane: neither open nor a needs-human blocker
800
829
  open.push(n);
801
830
  if ((rec.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(n);
802
831
  }
@@ -813,6 +842,10 @@ export async function runCore(o) {
813
842
  const caps = hostCapabilityBlock(s, o.hostCaps);
814
843
  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);
815
844
  }
845
+ if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote" && missingToolBlock(s)) {
846
+ // #4685: a tool the sandbox lacks (docker, promtool, a Python module) is an environment gap the host recovers, never a credential.
847
+ return await needsHuman(`Remote sandbox lacks a validate tool (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered where the tool exists.`, OUTCOME_CODES.ENV_TOOL_MISSING);
848
+ }
816
849
  if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
817
850
  if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
818
851
  if (b.kind === "dependency") {
@@ -863,7 +896,9 @@ export async function runCore(o) {
863
896
  }
864
897
  // ---- lane (#4125): worked out from the issue before any lease, comment or worktree; fail closed ----
865
898
  {
866
- const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
899
+ const laneIo = { ...io, gh: (a, opts) => refreshAuthOnce(() => io.gh(a, opts), { refresh: io.refreshAuth ?? runRefreshScript, state: authState }) }; // #4662
900
+ const r = await resolveLane({ issue, repo, io: laneIo, cwd, branches, requested: requestedLane, override: laneOverride });
901
+ if (!r.ok && GH_AUTH_RE.test(r.detail ?? "")) return { terminalReason: "deferred", code: OUTCOME_CODES.GH_AUTH, detail: r.detail, retryInSeconds: GH_AUTH_RETRY_SECONDS }; // #4662: a dead token is not a lane problem
867
902
  if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
868
903
  lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
869
904
  if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
@@ -876,8 +911,13 @@ export async function runCore(o) {
876
911
  // ---- admission: Depends-on refusal, then lease ----
877
912
  {
878
913
  let dep = null;
879
- try { dep = await openDependency(issue, io); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
914
+ try { dep = await openDependency(issue, io, { lane }); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
880
915
  if (dep?.unreadable) return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
916
+ if (dep?.satisfied?.length) { // #4686: open on GitHub, but its PR is already on this lane
917
+ const names = dep.satisfied.map((x) => `#${x.issue} (PR #${x.pr}, merge commit ${x.sha.slice(0, 12)} on origin/${lane})`).join(", ");
918
+ T.decisions.push(`Dependency satisfied by lane merge: ${names}`);
919
+ try { await post("FORGE:DEPENDENCY", `## Dependency satisfied\n${names} merged into ${lane}; the issue stays open until promotion, so the **Depends on** edge is met.`); } catch { /* best effort */ }
920
+ }
881
921
  if (dep?.open?.length) {
882
922
  return { terminalReason: "dependency-open", code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: dep.open,
883
923
  detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed` };
@@ -916,7 +956,10 @@ export async function runCore(o) {
916
956
  await emit({ event: "RUN_START", run: `core_${issue}_${t0}`, lane, path: "core" }); // #4630: lifecycle event stream
917
957
  try {
918
958
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
919
- } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
959
+ } catch (e) {
960
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode); // #4662
961
+ return finish("engine-error", `cannot read issue: ${e.message}`);
962
+ }
920
963
  const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
921
964
  if (String(iss.state).toUpperCase() === "CLOSED") {
922
965
  // #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
@@ -1209,9 +1252,9 @@ export async function runCore(o) {
1209
1252
  async function track(reqs, why) {
1210
1253
  const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
1211
1254
  if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
1212
- const items = followupItems(reqs);
1255
+ const items = followupItems(reqs, { now: now() });
1213
1256
  let filed;
1214
- try { filed = await io.fileFollowups({ parent: issue, repo, items }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1257
+ try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1215
1258
  for (const [i, r] of reqs.entries()) {
1216
1259
  const n = Number(filed?.[items[i].key]);
1217
1260
  if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
@@ -1220,7 +1263,8 @@ export async function runCore(o) {
1220
1263
  if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
1221
1264
  r.tracked = `#${n}`;
1222
1265
  }
1223
- T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
1266
+ const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1267
+ T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
1224
1268
  }
1225
1269
  /**
1226
1270
  * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
@@ -1329,6 +1373,8 @@ export async function runCore(o) {
1329
1373
  sessionId = null;
1330
1374
  return { ...r, contract };
1331
1375
  }
1376
+ /** #4694: the engine appends guard-derived H preserve rows from the local index (one file read; the same contract when none apply). */
1377
+ const historyRows = (c) => { const w = withHistoryRows(c, { repo, issue }); const n = (w.preserve ?? []).length - (c.preserve ?? []).length; if (w !== c && n > 0) T.decisions.push(`History preserve rows: ${w.preserve.filter((p) => p.source === "guard").map((p) => p.id).join(", ")} from guards (#4694)`); return w; };
1332
1378
  let frozen = null, hash = null, planned = false, files = [], split = null;
1333
1379
  const pendingPits = []; // #4375: rework rounds whose gate has not passed again yet
1334
1380
  async function notePitfall(trigger, text) {
@@ -1425,7 +1471,7 @@ export async function runCore(o) {
1425
1471
  apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
1426
1472
  stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
1427
1473
  if (!lr.upgrade) {
1428
- lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
1474
+ lightDone = true; light = true; frozen = historyRows(lr.contract); hash = contractHash(contractJson(frozen));
1429
1475
  let body = renderContract(frozen, hash, { scope: scopeHash });
1430
1476
  if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
1431
1477
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
@@ -1505,7 +1551,10 @@ export async function runCore(o) {
1505
1551
  }
1506
1552
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
1507
1553
  if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
1508
- frozen = contract;
1554
+ frozen = historyRows(contract); // #4694 guards
1555
+ frozen = withDecisionRows(frozen, readDecisionIndex(repo)); // #4695: anchored decisions on the deliverable paths; one index read
1556
+ const hRows = (frozen.preserve ?? []).filter((p) => p.source === "decision");
1557
+ if (hRows.length) T.decisions.push(`Anchored decisions bind this lane: ${hRows.map((p) => `${p.id} (${p.decision})`).join(", ")}`);
1509
1558
  hash = contractHash(contractJson(frozen));
1510
1559
  let body = renderContract(frozen, hash, { scope: scopeHash });
1511
1560
  if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
@@ -1815,6 +1864,7 @@ export async function runCore(o) {
1815
1864
  let fixes = 0;
1816
1865
  for (;;) {
1817
1866
  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
1867
+ await signOffUnpushed(); // #4651: likewise the sign-off rewrite
1818
1868
  const head = String(await git(["rev-parse", "HEAD"])).trim();
1819
1869
  if (head === ciVerifiedHead || ciFellBack) return { fixes };
1820
1870
  files = await changedFiles();
@@ -1904,16 +1954,21 @@ export async function runCore(o) {
1904
1954
  let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
1905
1955
  const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
1906
1956
  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).";
1957
+ let pendingDecisions = [];
1958
+ let baseTree = null; // #4695: the base file list, read once and only when a decision amendment needs it
1907
1959
  let genPaths = null; // forge#4519: maintenance.generated_paths from hotspots.sh (plan facts; one config-only call without them, never on light lanes)
1908
1960
  async function amend() {
1909
1961
  genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
1910
- const a = applyAmendments(frozen, result?.amendments, issueText, { generatedPaths: genPaths });
1962
+ const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
1963
+ baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\n").filter(Boolean) : null;
1964
+ const a = applyAmendments(frozen, result?.amendments, issueText, { generatedPaths: genPaths, baseFiles: baseTree?.length ? baseTree : null });
1911
1965
  const sig = JSON.stringify(a.applied);
1912
1966
  if (a.applied.length && sig !== lastAmend) {
1913
1967
  lastAmend = sig;
1914
1968
  await post("FORGE:AMENDMENT", renderAmendments(a.applied));
1915
1969
  T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
1916
1970
  }
1971
+ pendingDecisions = a.decisions; // #4737: persisted only once the PR merges
1917
1972
  effective = a.contract;
1918
1973
  return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
1919
1974
  }
@@ -1923,7 +1978,7 @@ export async function runCore(o) {
1923
1978
  const diff = String(await git(["diff", `${base}...HEAD`]));
1924
1979
  const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
1925
1980
  const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1926
- const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
1981
+ const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE, e2e: { checks: vcfg.e2e?.checks ?? [], isSpec: (x) => e2eSpecs([x], vcfg.e2e).length > 0 } };
1927
1982
  let d = null, first = null;
1928
1983
  for (let t = 0; t < 3; t++) {
1929
1984
  // #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
@@ -2008,6 +2063,23 @@ export async function runCore(o) {
2008
2063
  if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
2009
2064
  return out;
2010
2065
  }
2066
+ /** #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. */
2067
+ let guardSel = [], guardNoted = ""; let guardEv = null; // #4705: this pass's guard_run event, emitted by the caller once the added wall time is known
2068
+ async function guardGate(files) {
2069
+ let sel = [], r = null; guardEv = null;
2070
+ try {
2071
+ const guards = await (o.loadGuards ?? loadGuards)({ repo, gh: (a) => io.gh(a) });
2072
+ const picked = guards.length ? await selectGuards({ guards, files, git: (a) => git(a), base }) : { selected: [], mergeBase: null };
2073
+ sel = picked.selected; guardSel = sel;
2074
+ if (!sel.length) return [];
2075
+ 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 });
2076
+ } 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 []; }
2077
+ 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" : ""}`;
2078
+ if (note !== guardNoted) { guardNoted = note; T.decisions.push(note); }
2079
+ guardEv = { event: "guard_run", selected: sel.map((x) => x.guard.issue), ran: r.runs.length, broken: r.failures.length, skipped: r.skipped.length, wall_ms: r.ms };
2080
+ for (const f of r.failures) ev({ event: "guard_broken", guard: f.guard, test: f.test });
2081
+ return r.failures;
2082
+ }
2011
2083
  /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
2012
2084
  async function gate() {
2013
2085
  await refreshIssue("conformance");
@@ -2025,11 +2097,16 @@ export async function runCore(o) {
2025
2097
  const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
2026
2098
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
2027
2099
  const hotspots = await hotspotsOf({ base, paths: files }); // #4502
2028
- const validateRuns = await rerunFailedValidates(effective);
2100
+ const gateT0 = now(); let validateMs = 0;
2101
+ const [validateRuns, guardFailures] = await Promise.all([rerunFailedValidates(effective).finally(() => { validateMs = now() - gateT0; }), guardGate(files)]); // #4693: guards run beside the validate rerun
2102
+ // #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
2103
+ const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
2104
+ guardAddedMs += guardRunMs;
2105
+ if (guardEv) ev({ ...guardEv, added_wall_ms: guardRunMs });
2029
2106
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
2030
2107
  // #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
2031
2108
  const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
2032
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds });
2109
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures });
2033
2110
  // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
2034
2111
  if (pc.superseded?.length) {
2035
2112
  ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
@@ -2391,12 +2468,25 @@ export async function runCore(o) {
2391
2468
  T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
2392
2469
  ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
2393
2470
  const before = await gitOut(["rev-parse", "HEAD"]);
2471
+ const priorRows = Array.isArray(result?.validation) ? result.validation : [];
2394
2472
  await fixRound("ci", ciFailurePrompt(failures, vcfg.mode), [], "merge_ci", failureLog(failures));
2395
2473
  await upsertStage("implementation");
2396
2474
  result = (await readResult()) ?? result;
2397
2475
  if ((await gitOut(["rev-parse", "HEAD"])) === before) {
2398
2476
  return await needsHuman(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED);
2399
2477
  }
2478
+ // #4708: the fix session may rewrite result.json without the validate rows recorded earlier: carry those forward (marked) onto disk, which gate() re-reads
2479
+ const have = new Set((Array.isArray(result?.validation) ? result.validation : []).map((x) => x?.id)), carried = priorRows.filter((x) => x?.id && !have.has(x.id));
2480
+ if (carried.length && io.fs.write) {
2481
+ result = { ...result, validation: [...(Array.isArray(result?.validation) ? result.validation : []), ...carried.map((x) => ({ ...x, carried: true }))] };
2482
+ try { await io.fs.write(`${wt}/${RESULT_FILE}`, `${JSON.stringify(result)}\n`); } catch { /* the gate reads what is on disk */ }
2483
+ T.decisions.push(`Validate result(s) ${carried.map((x) => x.id).join(", ")} carried forward across the CI fix round`);
2484
+ }
2485
+ // #4708: the fix head is CI-verified before its review, as in the base-sync retry (#4527), so validates left to CI are covered
2486
+ if (remoteVerify) { const v = await ciVerify("merge"); if (v.done) return v.done; }
2487
+ const rows = Array.isArray(result?.validation) ? result.validation : [], headNow = await gitOut(["rev-parse", "HEAD"]);
2488
+ const lost = (effective.validate ?? []).filter((v) => !rows.some((x) => x?.id === v.id) && !(remoteVerify && ciVerifiedHead === headNow)).map((v) => v.id);
2489
+ if (lost.length) return await needsHuman(`Validate result(s) ${lost.join(", ")} missing after a CI fix round on PR #${pr}: the earlier results were lost and none could be carried forward; nothing merged.`, OUTCOME_CODES.ENGINE_VALIDATE_RESULT_LOST);
2400
2490
  const rv = await doReview(); // never merge an unreviewed head
2401
2491
  if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
2402
2492
  if (rv.env) return await envNeedsHuman(rv.env);
@@ -2438,7 +2528,7 @@ export async function runCore(o) {
2438
2528
  new_edges: pairs.filter((x) => x?.files_added > 0).map((x) => ({ ...edge(x), file: Array.isArray(x.files) ? x.files[0] ?? null : null, files: x.files_added })),
2439
2529
  removed_edges: pairs.filter((x) => x?.files_removed > 0).map((x) => ({ ...edge(x), files: x.files_removed })),
2440
2530
  top_growth,
2441
- decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })),
2531
+ decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })).concat((effective?.preserve ?? []).filter((p) => /^H\d+$/.test(p?.id) && p.source === "guard").map((p) => ({ id: p.id, source: p.source }))),
2442
2532
  };
2443
2533
  T.structure = s;
2444
2534
  const rec = { event: "STRUCTURE_DELTA", phase: "merge", pr, ...(milestone ? { milestone } : {}), ...s,
@@ -2475,6 +2565,7 @@ export async function runCore(o) {
2475
2565
  }
2476
2566
 
2477
2567
  // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
2568
+ let lastStates = null; // the last required-check poll, reused by the DCO gate (#4651)
2478
2569
  // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
2479
2570
  for (;;) {
2480
2571
  const c0 = await prConflicting();
@@ -2497,6 +2588,7 @@ export async function runCore(o) {
2497
2588
  if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
2498
2589
  } else {
2499
2590
  try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
2591
+ lastStates = states;
2500
2592
  const check = (n) => states.find((x) => x.name === n && current(x));
2501
2593
  const st = (n) => check(n)?.state ?? "pending";
2502
2594
  const failed = required.filter((n) => st(n) === "failure");
@@ -2557,6 +2649,10 @@ export async function runCore(o) {
2557
2649
  }
2558
2650
  }
2559
2651
  }
2652
+ // #4651: a failed DCO check blocks the merge even when it is not a required check on the lane branch (it would block the release PR)
2653
+ let dcoFailed = [];
2654
+ try { dcoFailed = ((lastStates ?? await io.checkStates?.(pr)) ?? []).filter((x) => /\bdco\b/i.test(String(x?.name ?? "")) && x.state === "failure" && (!x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head)))); } catch { dcoFailed = []; }
2655
+ if (dcoFailed.length) return await needsHuman(`DCO check failed on PR #${pr} (${[...new Set(dcoFailed.map((x) => x.name))].join(", ")}); a commit lacks a sign-off or has a placeholder author, which would block the release. Nothing merged.`, OUTCOME_CODES.CI_DCO);
2560
2656
  T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
2561
2657
  laneGuard(resumed?.base ?? lane, "merge");
2562
2658
  try {
@@ -2617,15 +2713,25 @@ export async function runCore(o) {
2617
2713
  } catch (e) { T.anomalies.push(`refactor:split chain issue not filed (the maintenance scan re-files the hotspot): ${String(e?.message ?? e).slice(0, 200)}`); }
2618
2714
  }
2619
2715
  await label("merged");
2716
+ try { // #4695/#4737: FORGE:DECISION records and supersession are persisted only after the merge
2717
+ const rec = await recordDecisions(pendingDecisions, { issue, repo, post });
2718
+ if (rec.length) T.decisions.push(`FORGE:DECISION: ${rec.map((d) => d.id).join(", ")}`);
2719
+ } catch (e) { T.anomalies.push(`decision records not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
2720
+ for (const sp of supersededIds(effective, guardSel)) { // #4693: the reviewer accepted the supersedes amendment; tell the guarded issue
2721
+ try { await ghw(["issue", "comment", sp.guard.slice(1), "-R", repo, "--body", supersessionComment({ guard: sp.guard, pr, why: sp.why })]); ev({ event: "supersedes_accepted", guard: sp.guard, pr }); T.decisions.push(`Guard ${sp.guard} superseded: ${sp.why}`); } catch (e) { T.anomalies.push(`supersession of ${sp.guard} not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
2722
+ }
2723
+ try { const g = await (o.guardAfterMerge ?? guardAfterMerge)({ io, repo, issue, pr, cwd: wt, base }); if (g?.posted) T.decisions.push(`FORGE:GUARD posted: ${g.guard.tests.length} test file(s), ${g.guard.hunks.length} hunk(s)`); else if (g?.skipped) ev({ event: "GUARD_SKIPPED", reason: g.reason }); } catch { /* the guard is best effort */ }
2620
2724
  // forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
2621
2725
  await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
2622
- return await finish("merged", `PR #${pr}`, { pr, ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
2726
+ let mergedTo = null; // #4711: FORGE:STATE records the lane and merge commit, so a dependent's check needs no PR search
2727
+ if (lane) try { const sha = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeCommit"])) || "{}").mergeCommit?.oid; if (typeof sha === "string" && sha) mergedTo = { lane, pr, sha }; } catch { /* the PR-ancestry rule still applies */ }
2728
+ return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
2623
2729
  } catch (e) {
2624
2730
  if (e?.deferUsageLimit) {
2625
2731
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
2626
2732
  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);
2627
2733
  }
2628
- if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
2734
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
2629
2735
  // #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
2630
2736
  if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
2631
2737
  return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);