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.
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- 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
|
|
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(
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|