forgedock 1.35.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -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/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- 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 +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -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/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- 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 +15 -3
- 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 +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -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/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- 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,11 +11,12 @@ 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
|
+
import { checksAt } from "./postdeploy-check.mjs";
|
|
19
20
|
import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
20
21
|
import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
|
|
21
22
|
import { splitGate, rationaleLine } from "./decompose.mjs";
|
|
@@ -25,13 +26,19 @@ import { prepareToolchain } from "./toolchain.mjs";
|
|
|
25
26
|
import { toolStats } from "./toolstats.mjs";
|
|
26
27
|
import { readTestMap, readTestMapAtRef, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
27
28
|
import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
|
|
28
|
-
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";
|
|
29
30
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
30
31
|
import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
|
|
31
32
|
import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
32
33
|
import { hostCapabilityBlock } from "./execution.mjs";
|
|
33
|
-
|
|
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 ?? "")); }
|
|
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";
|
|
34
39
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
40
|
+
import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
|
|
41
|
+
import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
|
|
35
42
|
import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
|
|
36
43
|
import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
|
|
37
44
|
import { makeTurnBudget, TURN_BUDGET_DEFAULTS, scaledExtra, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
|
|
@@ -39,18 +46,21 @@ import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog
|
|
|
39
46
|
import { unjustified } from "./enumerating.mjs";
|
|
40
47
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
41
48
|
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
|
|
42
|
-
import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
|
|
49
|
+
import { e2eSpecs, e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
|
|
43
50
|
import { laneBackendId } from "./limits.mjs";
|
|
44
51
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
45
52
|
import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
|
|
46
53
|
import { priorityFromLabels } from "./sched.mjs";
|
|
54
|
+
import { assignArm } from "../experiment.mjs";
|
|
47
55
|
import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
|
|
48
56
|
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
57
|
+
import { readDecisionIndex, withDecisionRows, recordDecisions } from "./decisions.mjs";
|
|
49
58
|
import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
|
|
50
59
|
import { isExternalKill } from "./hostkill.mjs";
|
|
51
60
|
import { buildResumeDigest } from "./resume.mjs";
|
|
52
61
|
import { lightEligibility } from "./light.mjs";
|
|
53
62
|
import { untrackToolState, unstageToolState } from "./toolstate.mjs";
|
|
63
|
+
import { ensureSignedOff } from "./signoff.mjs";
|
|
54
64
|
import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -168,6 +178,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
168
178
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
169
179
|
* @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
|
|
170
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
|
|
171
182
|
* @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
|
|
172
183
|
* @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
|
|
173
184
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
@@ -221,13 +232,15 @@ export async function runCore(o) {
|
|
|
221
232
|
let sessionId = null;
|
|
222
233
|
|
|
223
234
|
const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
|
|
235
|
+
const expTag = o.experiment ? { experiment: o.experiment.id, arm: assignArm(o.experiment, issue) } : {}; // #4629: same arm for the whole run
|
|
224
236
|
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
225
237
|
let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
|
|
226
|
-
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 });
|
|
227
240
|
const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
228
241
|
/** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
|
|
229
242
|
const ghw = (args) => retrying(() => ghc(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
|
|
230
|
-
const git = (args) => io.git(args, { cwd: wt });
|
|
243
|
+
const git = (args, o = {}) => io.git(args, { cwd: wt, ...o });
|
|
231
244
|
|
|
232
245
|
const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
|
|
233
246
|
const impl = { cost: 0, models: new Set() }; // #4397: implement spend and reported models, for the verifier
|
|
@@ -238,7 +251,7 @@ export async function runCore(o) {
|
|
|
238
251
|
const c = sessionCostDelta(sessionCosts, { resume, sessionId: r?.sessionId ?? null, cost: num(r?.costUsd) });
|
|
239
252
|
const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, trigger, ...(restartOf ? { restart_of: restartOf } : {}), usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
|
|
240
253
|
cost_usd: num(r?.costUsd), cost_usd_delta: c.delta, cost_usd_session: c.session, model: r?.model ?? null, turns: num(r?.turns), ...(mode ? { mode } : {}),
|
|
241
|
-
...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
|
|
254
|
+
...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name, ...expTag }; // #4410, #4629
|
|
242
255
|
if (phase === "implement") { builderTriggers.push({ trigger, restart_of: restartOf }); impl.cost += c.delta ?? 0; if (r?.model && r.model !== "default") impl.models.add(r.model); tb.note(num(r?.turns), stage === "implement"); }
|
|
243
256
|
ev(rec); await emit(rec);
|
|
244
257
|
modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
|
|
@@ -247,9 +260,9 @@ export async function runCore(o) {
|
|
|
247
260
|
usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
|
|
248
261
|
usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
249
262
|
}
|
|
250
|
-
const post = async (marker, body) => {
|
|
263
|
+
const post = async (marker, body, on = issue) => {
|
|
251
264
|
const marks = (Array.isArray(marker) ? marker : [marker]).map((m) => `<!-- ${m} -->`).join("\n");
|
|
252
|
-
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();
|
|
253
266
|
return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
254
267
|
};
|
|
255
268
|
/** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
|
|
@@ -271,12 +284,12 @@ export async function runCore(o) {
|
|
|
271
284
|
let leaseState = null, leaseTimer = null;
|
|
272
285
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
273
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 */ } } };
|
|
274
|
-
async function releaseLease(reason, code = null) {
|
|
287
|
+
async function releaseLease(reason, code = null, mergedTo = null) {
|
|
275
288
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
276
289
|
if (!io.projector || !leaseState) return;
|
|
277
290
|
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
278
291
|
// forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
|
|
279
|
-
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
|
|
280
293
|
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
|
|
281
294
|
}
|
|
282
295
|
|
|
@@ -293,6 +306,7 @@ export async function runCore(o) {
|
|
|
293
306
|
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
294
307
|
let pitMatched = null; // #4375: pitfalls in the pack that match the issue's files; null when no pack was compiled
|
|
295
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
|
|
296
310
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
297
311
|
let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
|
|
298
312
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
@@ -417,7 +431,7 @@ export async function runCore(o) {
|
|
|
417
431
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
418
432
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
419
433
|
conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
420
|
-
context_pack: ctxCfg.mode, ...ctxAb, 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() } : {}),
|
|
421
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
|
|
422
436
|
ev(term);
|
|
423
437
|
await emit(term);
|
|
@@ -442,7 +456,7 @@ export async function runCore(o) {
|
|
|
442
456
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
443
457
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
444
458
|
}
|
|
445
|
-
await releaseLease(reason, code);
|
|
459
|
+
await releaseLease(reason, code, extra.mergedTo ?? null);
|
|
446
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,
|
|
447
461
|
light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
|
|
448
462
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
|
|
@@ -578,8 +592,15 @@ export async function runCore(o) {
|
|
|
578
592
|
}
|
|
579
593
|
|
|
580
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
|
+
};
|
|
581
602
|
const gitPush = async (args) => {
|
|
582
|
-
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
|
|
583
604
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
584
605
|
catch (e) {
|
|
585
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)}`; }
|
|
@@ -663,6 +684,13 @@ export async function runCore(o) {
|
|
|
663
684
|
p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
|
|
664
685
|
continue;
|
|
665
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
|
+
}
|
|
666
694
|
const hostKilled = isExternalKill(e); // #4518: SIGTERM/143 we did not send restarts like a timeout, and never ends engine-error
|
|
667
695
|
if (!(hostKilled || e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
|
|
668
696
|
const idle = e.code === "CLI_IDLE";
|
|
@@ -775,6 +803,7 @@ export async function runCore(o) {
|
|
|
775
803
|
const b = classifyBlock(r, issue);
|
|
776
804
|
if (b.kind !== "dependency") return b;
|
|
777
805
|
const open = [], blockers = [];
|
|
806
|
+
const lanePrs = lane ? laneMergedPrs(lane, io) : null;
|
|
778
807
|
for (const d of b.issues) {
|
|
779
808
|
let v;
|
|
780
809
|
try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
|
|
@@ -794,6 +823,9 @@ export async function runCore(o) {
|
|
|
794
823
|
}
|
|
795
824
|
for (const [n, rec] of recs) {
|
|
796
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
|
|
797
829
|
open.push(n);
|
|
798
830
|
if ((rec.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(n);
|
|
799
831
|
}
|
|
@@ -810,6 +842,10 @@ export async function runCore(o) {
|
|
|
810
842
|
const caps = hostCapabilityBlock(s, o.hostCaps);
|
|
811
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);
|
|
812
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
|
+
}
|
|
813
849
|
if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
|
|
814
850
|
if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
|
|
815
851
|
if (b.kind === "dependency") {
|
|
@@ -860,7 +896,9 @@ export async function runCore(o) {
|
|
|
860
896
|
}
|
|
861
897
|
// ---- lane (#4125): worked out from the issue before any lease, comment or worktree; fail closed ----
|
|
862
898
|
{
|
|
863
|
-
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
|
|
864
902
|
if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
|
|
865
903
|
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
|
|
866
904
|
if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
|
|
@@ -873,8 +911,13 @@ export async function runCore(o) {
|
|
|
873
911
|
// ---- admission: Depends-on refusal, then lease ----
|
|
874
912
|
{
|
|
875
913
|
let dep = null;
|
|
876
|
-
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}` }; }
|
|
877
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
|
+
}
|
|
878
921
|
if (dep?.open?.length) {
|
|
879
922
|
return { terminalReason: "dependency-open", code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: dep.open,
|
|
880
923
|
detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed` };
|
|
@@ -910,9 +953,13 @@ export async function runCore(o) {
|
|
|
910
953
|
}
|
|
911
954
|
ghCache = io.ghCache?.(runTmp) ?? null;
|
|
912
955
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
956
|
+
await emit({ event: "RUN_START", run: `core_${issue}_${t0}`, lane, path: "core" }); // #4630: lifecycle event stream
|
|
913
957
|
try {
|
|
914
958
|
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
915
|
-
} 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
|
+
}
|
|
916
963
|
const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
|
|
917
964
|
if (String(iss.state).toUpperCase() === "CLOSED") {
|
|
918
965
|
// #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
|
|
@@ -1205,9 +1252,9 @@ export async function runCore(o) {
|
|
|
1205
1252
|
async function track(reqs, why) {
|
|
1206
1253
|
const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
|
|
1207
1254
|
if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
|
|
1208
|
-
const items = followupItems(reqs);
|
|
1255
|
+
const items = followupItems(reqs, { now: now() });
|
|
1209
1256
|
let filed;
|
|
1210
|
-
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)}`); }
|
|
1211
1258
|
for (const [i, r] of reqs.entries()) {
|
|
1212
1259
|
const n = Number(filed?.[items[i].key]);
|
|
1213
1260
|
if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
|
|
@@ -1216,7 +1263,8 @@ export async function runCore(o) {
|
|
|
1216
1263
|
if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
|
|
1217
1264
|
r.tracked = `#${n}`;
|
|
1218
1265
|
}
|
|
1219
|
-
|
|
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})`);
|
|
1220
1268
|
}
|
|
1221
1269
|
/**
|
|
1222
1270
|
* #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
|
|
@@ -1325,6 +1373,8 @@ export async function runCore(o) {
|
|
|
1325
1373
|
sessionId = null;
|
|
1326
1374
|
return { ...r, contract };
|
|
1327
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; };
|
|
1328
1378
|
let frozen = null, hash = null, planned = false, files = [], split = null;
|
|
1329
1379
|
const pendingPits = []; // #4375: rework rounds whose gate has not passed again yet
|
|
1330
1380
|
async function notePitfall(trigger, text) {
|
|
@@ -1421,11 +1471,12 @@ export async function runCore(o) {
|
|
|
1421
1471
|
apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
|
|
1422
1472
|
stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
|
|
1423
1473
|
if (!lr.upgrade) {
|
|
1424
|
-
lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
|
|
1474
|
+
lightDone = true; light = true; frozen = historyRows(lr.contract); hash = contractHash(contractJson(frozen));
|
|
1425
1475
|
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1426
1476
|
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
|
|
1427
1477
|
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
1428
1478
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
|
|
1479
|
+
await emit({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
|
|
1429
1480
|
ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
|
|
1430
1481
|
T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
|
|
1431
1482
|
try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
|
|
@@ -1500,13 +1551,17 @@ export async function runCore(o) {
|
|
|
1500
1551
|
}
|
|
1501
1552
|
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
1502
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 } : {});
|
|
1503
|
-
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(", ")}`);
|
|
1504
1558
|
hash = contractHash(contractJson(frozen));
|
|
1505
1559
|
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1506
1560
|
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
|
|
1507
1561
|
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
1508
1562
|
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
1509
1563
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
1564
|
+
await emit({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
1510
1565
|
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
1511
1566
|
} else {
|
|
1512
1567
|
T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
|
|
@@ -1748,6 +1803,28 @@ export async function runCore(o) {
|
|
|
1748
1803
|
else await gatePassed("sequence");
|
|
1749
1804
|
}
|
|
1750
1805
|
await migrationGate("hand-off");
|
|
1806
|
+
/** #4596: keys added to a non-en locale catalog as English copies: one translate round, then an anomaly. True when a fix round ran. */
|
|
1807
|
+
async function i18nGate(trigger) {
|
|
1808
|
+
if (!io.englishCopies || isSplit) return false;
|
|
1809
|
+
const found = async () => (await io.englishCopies({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
1810
|
+
let cp = await found();
|
|
1811
|
+
const brief = () => cp.slice(0, 5).map((d) => `${d.path}:${d.key}`).join("; ");
|
|
1812
|
+
if (cp.length && sessionId) {
|
|
1813
|
+
ev({ event: "I18N_ENGLISH_COPIES", trigger, count: cp.length });
|
|
1814
|
+
T.decisions.push(`English copies in locale catalogs (${trigger}): ${brief()}; builder resumed once`);
|
|
1815
|
+
await fixRound("duplicate", i18nCopiesPrompt(cp, vcfg.mode), [], "duplicate", cp.map((d) => `english copy \`${d.key}\` in ${d.path}`).join("\n"));
|
|
1816
|
+
result = (await readResult()) ?? result;
|
|
1817
|
+
files = await changedFiles();
|
|
1818
|
+
const before = cp;
|
|
1819
|
+
cp = await found();
|
|
1820
|
+
if (cp.length) T.anomalies.push(`English copies remain in locale catalogs (${trigger}): ${brief()}`);
|
|
1821
|
+
else await gatePassed("i18n");
|
|
1822
|
+
return before.length > 0;
|
|
1823
|
+
}
|
|
1824
|
+
if (cp.length) T.anomalies.push(`English copies in locale catalogs (${trigger}): ${brief()}`);
|
|
1825
|
+
return false;
|
|
1826
|
+
}
|
|
1827
|
+
await i18nGate("hand-off");
|
|
1751
1828
|
/** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
|
|
1752
1829
|
async function postMergeGate(trigger) {
|
|
1753
1830
|
const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
|
|
@@ -1786,6 +1863,8 @@ export async function runCore(o) {
|
|
|
1786
1863
|
async function ciVerify(trigger) {
|
|
1787
1864
|
let fixes = 0;
|
|
1788
1865
|
for (;;) {
|
|
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
|
|
1789
1868
|
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1790
1869
|
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
1791
1870
|
files = await changedFiles();
|
|
@@ -1875,16 +1954,21 @@ export async function runCore(o) {
|
|
|
1875
1954
|
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
|
|
1876
1955
|
const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
|
|
1877
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
|
|
1878
1959
|
let genPaths = null; // forge#4519: maintenance.generated_paths from hotspots.sh (plan facts; one config-only call without them, never on light lanes)
|
|
1879
1960
|
async function amend() {
|
|
1880
1961
|
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
1881
|
-
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 });
|
|
1882
1965
|
const sig = JSON.stringify(a.applied);
|
|
1883
1966
|
if (a.applied.length && sig !== lastAmend) {
|
|
1884
1967
|
lastAmend = sig;
|
|
1885
1968
|
await post("FORGE:AMENDMENT", renderAmendments(a.applied));
|
|
1886
1969
|
T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
|
|
1887
1970
|
}
|
|
1971
|
+
pendingDecisions = a.decisions; // #4737: persisted only once the PR merges
|
|
1888
1972
|
effective = a.contract;
|
|
1889
1973
|
return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
|
|
1890
1974
|
}
|
|
@@ -1894,7 +1978,7 @@ export async function runCore(o) {
|
|
|
1894
1978
|
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
1895
1979
|
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
1896
1980
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
1897
|
-
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 } };
|
|
1898
1982
|
let d = null, first = null;
|
|
1899
1983
|
for (let t = 0; t < 3; t++) {
|
|
1900
1984
|
// #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
|
|
@@ -1979,6 +2063,23 @@ export async function runCore(o) {
|
|
|
1979
2063
|
if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
1980
2064
|
return out;
|
|
1981
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
|
+
}
|
|
1982
2083
|
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
1983
2084
|
async function gate() {
|
|
1984
2085
|
await refreshIssue("conformance");
|
|
@@ -1996,9 +2097,16 @@ export async function runCore(o) {
|
|
|
1996
2097
|
const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1997
2098
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
|
|
1998
2099
|
const hotspots = await hotspotsOf({ base, paths: files }); // #4502
|
|
1999
|
-
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 });
|
|
2000
2106
|
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
2001
|
-
|
|
2107
|
+
// #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
|
|
2108
|
+
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2109
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures });
|
|
2002
2110
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2003
2111
|
if (pc.superseded?.length) {
|
|
2004
2112
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
|
@@ -2170,6 +2278,7 @@ export async function runCore(o) {
|
|
|
2170
2278
|
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
2171
2279
|
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
2172
2280
|
ev({ event: "PR_OPENED", pr });
|
|
2281
|
+
await emit({ event: "PR_OPENED", pr });
|
|
2173
2282
|
if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
|
|
2174
2283
|
try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
|
|
2175
2284
|
catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
|
|
@@ -2290,6 +2399,7 @@ export async function runCore(o) {
|
|
|
2290
2399
|
if (pm.fixed) how += "; builder fixed post-merge check failures";
|
|
2291
2400
|
dupFixed = await duplicateGate(`base-sync ${syncs}`);
|
|
2292
2401
|
if (dupFixed) how += "; builder resolved duplicate exports";
|
|
2402
|
+
if (await i18nGate(`base-sync ${syncs}`)) how += "; builder translated English-copy keys";
|
|
2293
2403
|
}
|
|
2294
2404
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
2295
2405
|
if (soft && clean && after === before) { syncs--; sameBaseSyncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
@@ -2358,12 +2468,25 @@ export async function runCore(o) {
|
|
|
2358
2468
|
T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
2359
2469
|
ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
|
|
2360
2470
|
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
2471
|
+
const priorRows = Array.isArray(result?.validation) ? result.validation : [];
|
|
2361
2472
|
await fixRound("ci", ciFailurePrompt(failures, vcfg.mode), [], "merge_ci", failureLog(failures));
|
|
2362
2473
|
await upsertStage("implementation");
|
|
2363
2474
|
result = (await readResult()) ?? result;
|
|
2364
2475
|
if ((await gitOut(["rev-parse", "HEAD"])) === before) {
|
|
2365
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);
|
|
2366
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);
|
|
2367
2490
|
const rv = await doReview(); // never merge an unreviewed head
|
|
2368
2491
|
if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
2369
2492
|
if (rv.env) return await envNeedsHuman(rv.env);
|
|
@@ -2405,7 +2528,7 @@ export async function runCore(o) {
|
|
|
2405
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 })),
|
|
2406
2529
|
removed_edges: pairs.filter((x) => x?.files_removed > 0).map((x) => ({ ...edge(x), files: x.files_removed })),
|
|
2407
2530
|
top_growth,
|
|
2408
|
-
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 }))),
|
|
2409
2532
|
};
|
|
2410
2533
|
T.structure = s;
|
|
2411
2534
|
const rec = { event: "STRUCTURE_DELTA", phase: "merge", pr, ...(milestone ? { milestone } : {}), ...s,
|
|
@@ -2442,6 +2565,7 @@ export async function runCore(o) {
|
|
|
2442
2565
|
}
|
|
2443
2566
|
|
|
2444
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)
|
|
2445
2569
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
2446
2570
|
for (;;) {
|
|
2447
2571
|
const c0 = await prConflicting();
|
|
@@ -2464,6 +2588,7 @@ export async function runCore(o) {
|
|
|
2464
2588
|
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
2465
2589
|
} else {
|
|
2466
2590
|
try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
|
|
2591
|
+
lastStates = states;
|
|
2467
2592
|
const check = (n) => states.find((x) => x.name === n && current(x));
|
|
2468
2593
|
const st = (n) => check(n)?.state ?? "pending";
|
|
2469
2594
|
const failed = required.filter((n) => st(n) === "failure");
|
|
@@ -2524,6 +2649,10 @@ export async function runCore(o) {
|
|
|
2524
2649
|
}
|
|
2525
2650
|
}
|
|
2526
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);
|
|
2527
2656
|
T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
|
|
2528
2657
|
laneGuard(resumed?.base ?? lane, "merge");
|
|
2529
2658
|
try {
|
|
@@ -2584,15 +2713,25 @@ export async function runCore(o) {
|
|
|
2584
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)}`); }
|
|
2585
2714
|
}
|
|
2586
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 */ }
|
|
2587
2724
|
// forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
|
|
2588
2725
|
await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
|
|
2589
|
-
|
|
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 } : {}) });
|
|
2590
2729
|
} catch (e) {
|
|
2591
2730
|
if (e?.deferUsageLimit) {
|
|
2592
2731
|
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
2593
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);
|
|
2594
2733
|
}
|
|
2595
|
-
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);
|
|
2596
2735
|
// #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
|
|
2597
2736
|
if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
|
|
2598
2737
|
return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);
|