forgedock 1.28.0 → 1.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +29 -0
- package/bin/engine-cli.mjs +77 -16
- package/bin/lean/batch.test.mjs +2 -2
- package/bin/lean/conformance.mjs +47 -2
- package/bin/lean/conformance.test.mjs +53 -1
- package/bin/lean/context.mjs +356 -0
- package/bin/lean/context.test.mjs +307 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +178 -37
- package/bin/lean/lean.test.mjs +441 -7
- package/bin/lean/orchestrate.mjs +112 -5
- package/bin/lean/prefilter.mjs +81 -16
- package/bin/lean/prefilter.test.mjs +91 -2
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +419 -0
- package/bin/lean/render.mjs +38 -5
- package/bin/lean/testmap.mjs +101 -0
- package/bin/lean/testmap.test.mjs +73 -0
- package/bin/lean/verify.mjs +48 -3
- package/bin/lean/verify.test.mjs +81 -3
- package/bin/stats/index.mjs +12 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +27 -0
- package/bin/stats/stats.test.mjs +41 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +54 -7
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +15 -0
- package/docs/OUTCOME-CODES.md +14 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +25 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +317 -0
- package/scripts/test-map.test.sh +353 -0
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +13 -0
package/bin/lean/lean.mjs
CHANGED
|
@@ -12,24 +12,36 @@ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mj
|
|
|
12
12
|
import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
|
|
13
13
|
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { TRACKED, isBugIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
15
|
+
import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
16
16
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
17
17
|
import { namedPaths } from "./prefilter.mjs";
|
|
18
18
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
19
19
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
20
20
|
import { repoKey } from "../runner.mjs";
|
|
21
21
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
22
|
+
import { readTestMap, readTestMapAtRef, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
|
|
22
23
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
23
|
-
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
24
|
+
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
|
|
24
25
|
import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
|
|
25
|
-
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, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
|
|
26
|
+
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, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
|
|
26
27
|
import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
|
|
27
|
-
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES } from "./verify.mjs";
|
|
28
|
+
import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
|
|
28
29
|
import { laneBackendId } from "./limits.mjs";
|
|
29
30
|
import { SPLIT_LABEL } from "./refactor.mjs";
|
|
30
31
|
import { writeHeavyShims, sumWaits } from "./heavy.mjs";
|
|
31
32
|
import { priorityFromLabels } from "./sched.mjs";
|
|
33
|
+
import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp } from "./context.mjs";
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
|
|
37
|
+
* with RECOVERABLE_CODES (bin/engine/outcome-codes.mjs); recovery.test.mjs checks it (#4298).
|
|
38
|
+
*/
|
|
39
|
+
export const RECOVER_HOW = Object.freeze({
|
|
40
|
+
[OUTCOME_CODES.CI_RED]: "the PR's required checks failed. Read the failed checks' logs (`gh run view <run-id> --log-failed`, or the repo's declared check commands), fix the causes, re-run those checks locally and push new commits only (never rewrite history)",
|
|
41
|
+
[OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
|
|
42
|
+
[OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
|
|
43
|
+
[OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
|
|
44
|
+
});
|
|
33
45
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
34
46
|
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
35
47
|
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
@@ -116,6 +128,10 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
116
128
|
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
117
129
|
* @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
|
|
118
130
|
* @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
|
|
131
|
+
* @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
|
|
132
|
+
* @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
|
|
133
|
+
* posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
|
|
134
|
+
* (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
|
|
119
135
|
*/
|
|
120
136
|
export async function runCore(o) {
|
|
121
137
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
@@ -128,6 +144,8 @@ export async function runCore(o) {
|
|
|
128
144
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
129
145
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
130
146
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
147
|
+
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
|
|
148
|
+
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1, final: o.recover.final !== false } : null;
|
|
131
149
|
const branch = `feat/issue-${issue}-lean`;
|
|
132
150
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
133
151
|
const t0 = now();
|
|
@@ -174,17 +192,23 @@ export async function runCore(o) {
|
|
|
174
192
|
let leaseState = null, leaseTimer = null;
|
|
175
193
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
176
194
|
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 */ } } };
|
|
177
|
-
async function releaseLease(reason) {
|
|
195
|
+
async function releaseLease(reason, code = null) {
|
|
178
196
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
179
197
|
if (!io.projector || !leaseState) return;
|
|
180
198
|
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
181
|
-
|
|
199
|
+
// forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
|
|
200
|
+
const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
|
|
201
|
+
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
|
|
182
202
|
}
|
|
183
203
|
|
|
184
204
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
185
205
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
206
|
+
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
207
|
+
const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
|
|
208
|
+
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
186
209
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
187
210
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
211
|
+
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
188
212
|
const runId = `${issue}-${t0}`;
|
|
189
213
|
let runTmp = null;
|
|
190
214
|
let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
|
|
@@ -293,9 +317,20 @@ export async function runCore(o) {
|
|
|
293
317
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
294
318
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
295
319
|
const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
|
|
296
|
-
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {})
|
|
320
|
+
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
321
|
+
context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}) }; // forge#4275: the on/off A/B groups runs by these
|
|
297
322
|
ev(term);
|
|
298
323
|
await emit(term);
|
|
324
|
+
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
325
|
+
// decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
|
|
326
|
+
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
|
|
327
|
+
// either launches the next attempt or relabels in settle.
|
|
328
|
+
if (recoverUnlabelled && recover.final && reason === "ci-pending") {
|
|
329
|
+
try {
|
|
330
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
|
|
331
|
+
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
332
|
+
} catch { /* best effort */ }
|
|
333
|
+
}
|
|
299
334
|
if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
|
|
300
335
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
301
336
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
@@ -306,17 +341,19 @@ export async function runCore(o) {
|
|
|
306
341
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
307
342
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
308
343
|
}
|
|
309
|
-
await releaseLease(reason);
|
|
344
|
+
await releaseLease(reason, code);
|
|
310
345
|
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,
|
|
311
346
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
312
347
|
return done;
|
|
313
348
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
349
|
+
/** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
|
|
350
|
+
const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
|
|
351
|
+
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
352
|
+
if (!recover || recover.final) try {
|
|
353
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
317
354
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
318
355
|
} catch { /* best effort */ }
|
|
319
|
-
return finish("needs-human", why, { code }, code);
|
|
356
|
+
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
320
357
|
}
|
|
321
358
|
|
|
322
359
|
// ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
|
|
@@ -435,6 +472,7 @@ export async function runCore(o) {
|
|
|
435
472
|
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
436
473
|
}
|
|
437
474
|
const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
|
|
475
|
+
const recoverNote = () => (recover ? `\n\nRecovery pass (${recover.code}): ${(RECOVER_HOW[recover.code] ?? "an earlier run ended needs-human for a mechanical reason; resolve it").replaceAll("BASE", lane)}.` : "");
|
|
438
476
|
const PLAN_RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD` to see what exists, but plan against the issue itself. This step is still planning only: do not edit code, do not commit, and do not write the result file unless the issue is invalid, already fixed or blocked.";
|
|
439
477
|
const SALVAGE_NOTE = "\n\nAn earlier run on this issue stopped with uncommitted work, saved at `REF` (not on the branch). Inspect it with `git diff HEAD REF --stat` and reuse what fits the contract (`git checkout REF -- <path>`) instead of rewriting it.";
|
|
440
478
|
const RERUN_NOTE = `\n\nThis is a rerun: the branch already holds commits from an earlier run. Read \`git log\` and \`git diff origin/BASE...HEAD\`, verify that work against the contract, complete what is missing, ${vcfg.mode === "ci" ? "re-run the light checks (the engine verifies on CI)" : "re-run every validate command"} and write the result file. Do not start over.`;
|
|
@@ -465,7 +503,8 @@ export async function runCore(o) {
|
|
|
465
503
|
}
|
|
466
504
|
|
|
467
505
|
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
468
|
-
|
|
506
|
+
let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
|
|
507
|
+
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
469
508
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
470
509
|
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
|
|
471
510
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
@@ -654,7 +693,7 @@ export async function runCore(o) {
|
|
|
654
693
|
}
|
|
655
694
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
656
695
|
try {
|
|
657
|
-
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]));
|
|
696
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
658
697
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
659
698
|
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
660
699
|
|
|
@@ -662,6 +701,16 @@ export async function runCore(o) {
|
|
|
662
701
|
// ---- 1. claim ----
|
|
663
702
|
await label("investigating");
|
|
664
703
|
await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
|
|
704
|
+
if (recover) {
|
|
705
|
+
// #4267: the batch decided to recover this issue itself; the label goes so the issue is live again.
|
|
706
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", "needs-human"]); } catch { /* best effort */ }
|
|
707
|
+
recoverUnlabelled = true;
|
|
708
|
+
try {
|
|
709
|
+
await post("FORGE:DECISION", `## Decision: recovery pass for \`${recover.code}\` (attempt ${recover.attempt})\n**What**: removed \`needs-human\` and resumed the open PR / branch on \`${lane}\` (${(RECOVER_HOW[recover.code] ?? "rerun").split(".")[0]}).\n**Why**: \`${recover.code}\` is a mechanical outcome an agent can fix without operator input.\n**Alternative**: leave the issue \`needs-human\` and skip its dependents until the operator relaunches it.`);
|
|
710
|
+
} catch { /* best effort */ }
|
|
711
|
+
T.decisions.push(`Recovery pass for ${recover.code} (attempt ${recover.attempt}): needs-human removed, PR resumed`);
|
|
712
|
+
ev({ event: "RECOVERY_START", code: recover.code, attempt: recover.attempt });
|
|
713
|
+
}
|
|
665
714
|
|
|
666
715
|
// ---- 2. worktree (resume an open PR / remote branch) ----
|
|
667
716
|
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
@@ -832,18 +881,47 @@ export async function runCore(o) {
|
|
|
832
881
|
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
833
882
|
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
834
883
|
});
|
|
835
|
-
|
|
836
|
-
|
|
884
|
+
// #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
|
|
885
|
+
const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
|
|
886
|
+
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
|
|
887
|
+
let trustedComments = trustedOf(iss), issueEdited = false;
|
|
837
888
|
let issueText = textOf(iss, trustedComments);
|
|
889
|
+
let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
|
|
890
|
+
ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
|
|
891
|
+
/**
|
|
892
|
+
* forge#4275: compile (or read from the cache) the context pack, log CONTEXT_PACK and post FORGE:CONTEXT. Off, no
|
|
893
|
+
* io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
|
|
894
|
+
*/
|
|
895
|
+
async function contextPackStep() {
|
|
896
|
+
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
897
|
+
try {
|
|
898
|
+
const headSha = String(await git(["rev-parse", base])).trim();
|
|
899
|
+
// #4316: linked-issue states are part of the key; a failed lookup only drops them from it
|
|
900
|
+
const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
|
|
901
|
+
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
|
|
902
|
+
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
903
|
+
const p = r.pack;
|
|
904
|
+
const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
|
|
905
|
+
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
906
|
+
ev(rec); await emit(rec);
|
|
907
|
+
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
908
|
+
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
909
|
+
T.decisions.push(`Context pack (${ctxCfg.mode}): ${p.items} item(s), ${p.bytes} bytes${p.truncated ? `, ${p.truncated} dropped` : ""}, ${ctxCfg.mode === "on" ? "given to the builder" : "recorded only"}`);
|
|
910
|
+
return p;
|
|
911
|
+
} catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
|
|
912
|
+
}
|
|
838
913
|
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
839
914
|
async function refreshIssue(stage) {
|
|
840
915
|
let next;
|
|
841
|
-
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]))); } catch { return; }
|
|
916
|
+
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]))); } catch { return; }
|
|
842
917
|
if (!next || typeof next !== "object") return;
|
|
843
918
|
const tc = trustedOf(next), text = textOf(next, tc);
|
|
844
|
-
|
|
919
|
+
if (contractHash(issueText) === contractHash(text)) return;
|
|
920
|
+
// #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
|
|
921
|
+
const old = scopeHash, now = contractHash(scopeText(next, tc));
|
|
922
|
+
iss = next; trustedComments = tc; issueText = text; scopeHash = now;
|
|
845
923
|
if (old === now) return;
|
|
846
|
-
|
|
924
|
+
issueEdited = true;
|
|
847
925
|
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
848
926
|
T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
|
|
849
927
|
}
|
|
@@ -875,16 +953,21 @@ export async function runCore(o) {
|
|
|
875
953
|
}
|
|
876
954
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
877
955
|
}
|
|
878
|
-
/**
|
|
956
|
+
/**
|
|
957
|
+
* The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
|
|
958
|
+
* (#4265): a trusted comment that the engine did not post came after it.
|
|
959
|
+
*/
|
|
879
960
|
async function readFrozenContract() {
|
|
880
961
|
let out = "";
|
|
881
962
|
try { out = String(await ghc(["api", "--paginate", `repos/${repo}/issues/${issue}/comments`, "--jq", ".[] | {body, author_association, user: {type: .user.type, login: .user.login}}"])); } catch { return null; }
|
|
882
963
|
let found = null;
|
|
883
964
|
for (const l of out.split("\n")) {
|
|
884
965
|
let c; try { c = JSON.parse(l); } catch { continue; }
|
|
885
|
-
if (!
|
|
886
|
-
const
|
|
887
|
-
if (
|
|
966
|
+
if (!isTrustedComment(c)) continue;
|
|
967
|
+
const b = String(c?.body ?? "");
|
|
968
|
+
if (!b.startsWith("<!-- FORGE:CONTRACT -->")) { if (found && b.trim() && !b.trimStart().startsWith("<!-- FORGE:")) found.humanAfter = true; continue; }
|
|
969
|
+
const fc = parseContractComment(b);
|
|
970
|
+
if (fc && contractHash(fc.text) === fc.hash) found = { ...fc, humanAfter: false };
|
|
888
971
|
}
|
|
889
972
|
return found;
|
|
890
973
|
}
|
|
@@ -953,13 +1036,25 @@ export async function runCore(o) {
|
|
|
953
1036
|
} else {
|
|
954
1037
|
if (resumed) {
|
|
955
1038
|
const fc = await readFrozenContract();
|
|
956
|
-
if (fc) {
|
|
1039
|
+
if (fc) {
|
|
1040
|
+
// #4265: a contract planned from an older issue scope is stale: re-plan and freeze a new one instead of judging against it
|
|
1041
|
+
await refreshIssue("rerun contract check");
|
|
1042
|
+
const gone = (Array.isArray(fc.contract?.requirements) ? fc.contract.requirements : []).filter((r) => r?.disposition === "deliver" && !inIssue(r.quote, issueText)).map((r) => r.id);
|
|
1043
|
+
const stale = fc.scope ? (fc.scope !== scopeHash ? `issue scope ${fc.scope} -> ${scopeHash}` : null)
|
|
1044
|
+
: fc.humanAfter ? "a human comment was posted after it" : gone.length ? `quote(s) no longer in the issue: ${gone.join(", ")}` : null;
|
|
1045
|
+
if (stale) {
|
|
1046
|
+
ev({ event: "CONTRACT_STALE", hash: fc.hash, scope: fc.scope, now: scopeHash, reason: stale });
|
|
1047
|
+
T.decisions.push(`Rerun: frozen contract ${fc.hash} is stale (${stale}); re-planning`);
|
|
1048
|
+
} else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
1049
|
+
}
|
|
957
1050
|
}
|
|
958
1051
|
await label("building");
|
|
959
1052
|
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
960
|
-
|
|
1053
|
+
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1054
|
+
const ctxPack = frozen ? null : await contextPackStep();
|
|
1055
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
961
1056
|
if (!frozen) {
|
|
962
|
-
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, planPrompt);
|
|
1057
|
+
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
963
1058
|
planned = true;
|
|
964
1059
|
result = await readResult();
|
|
965
1060
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
@@ -987,11 +1082,11 @@ export async function runCore(o) {
|
|
|
987
1082
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
988
1083
|
}
|
|
989
1084
|
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
990
|
-
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);
|
|
1085
|
+
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 } : {});
|
|
991
1086
|
frozen = contract;
|
|
992
1087
|
hash = contractHash(contractJson(frozen));
|
|
993
|
-
let body = renderContract(frozen, hash);
|
|
994
|
-
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
|
|
1088
|
+
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1089
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
|
|
995
1090
|
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
996
1091
|
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
997
1092
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
@@ -1001,7 +1096,7 @@ export async function runCore(o) {
|
|
|
1001
1096
|
}
|
|
1002
1097
|
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1003
1098
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
1004
|
-
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
|
|
1099
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
|
|
1005
1100
|
liveKeys = ["implementation"];
|
|
1006
1101
|
stage = "implement";
|
|
1007
1102
|
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
@@ -1022,17 +1117,29 @@ export async function runCore(o) {
|
|
|
1022
1117
|
// the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
|
|
1023
1118
|
// verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
|
|
1024
1119
|
let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
|
|
1025
|
-
|
|
1026
|
-
|
|
1120
|
+
// #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
|
|
1121
|
+
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1122
|
+
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1123
|
+
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1124
|
+
// #4315: the map's selection is merged into the learned commands (deduped): a partial map never skips a learned suite
|
|
1125
|
+
const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return { ...mergeChecks(m, learnedCommands), selected: m.length }; };
|
|
1126
|
+
let picked = pickChecks();
|
|
1127
|
+
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1128
|
+
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1129
|
+
if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
|
|
1130
|
+
if (picked.commands.length && io.runCommand) {
|
|
1027
1131
|
for (let round = 0; ; round++) {
|
|
1028
1132
|
await renew();
|
|
1133
|
+
if (round) picked = pickChecks();
|
|
1134
|
+
const precheckCommands = picked.commands;
|
|
1029
1135
|
const results = [];
|
|
1030
1136
|
for (const command of precheckCommands) {
|
|
1031
1137
|
let r;
|
|
1032
1138
|
try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
1033
1139
|
results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
|
|
1034
1140
|
}
|
|
1035
|
-
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })) });
|
|
1141
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source !== "learned" ? { source: picked.source } : {}) });
|
|
1142
|
+
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1036
1143
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1037
1144
|
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
1038
1145
|
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
@@ -1074,6 +1181,7 @@ export async function runCore(o) {
|
|
|
1074
1181
|
structure = (await structureOf()) ?? structure;
|
|
1075
1182
|
edges = undeclared();
|
|
1076
1183
|
}
|
|
1184
|
+
verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
|
|
1077
1185
|
if (edges.length) {
|
|
1078
1186
|
return await needsHuman(`The change adds dependencies between workspace packages that the frozen contract does not name${sessionId ? ", after one builder resume" : ""}; nothing merged:\n${edges.slice(0, 15).map((e) => `- ${e.from} → ${e.to} (${e.kind}, \`${e.manifest}\`)`).join("\n")}\n\nAdd each to the contract with a reason, or remove it.`, OUTCOME_CODES.STRUCTURE_UNDECLARED_EDGE);
|
|
1079
1187
|
}
|
|
@@ -1090,6 +1198,7 @@ export async function runCore(o) {
|
|
|
1090
1198
|
result = (await readResult()) ?? result;
|
|
1091
1199
|
docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1092
1200
|
}
|
|
1201
|
+
verifyFacts.docs = docs;
|
|
1093
1202
|
if (docs && !docs.ok) {
|
|
1094
1203
|
return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
|
|
1095
1204
|
}
|
|
@@ -1211,6 +1320,7 @@ export async function runCore(o) {
|
|
|
1211
1320
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1212
1321
|
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1213
1322
|
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
|
|
1323
|
+
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1214
1324
|
const pre = [...pc.failures, ...amendProblems];
|
|
1215
1325
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
1216
1326
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
@@ -1238,6 +1348,29 @@ export async function runCore(o) {
|
|
|
1238
1348
|
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
1239
1349
|
return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
1240
1350
|
}
|
|
1351
|
+
const verifyReported = new Set();
|
|
1352
|
+
/**
|
|
1353
|
+
* #4270 verify.check_run: one `forgedock/verify` check run per pushed head, reporting the gates' last results (no gate
|
|
1354
|
+
* logic, never blocks). Off: no call, no event. A missing seam or a Checks API failure is an anomaly, never a lane failure.
|
|
1355
|
+
*/
|
|
1356
|
+
async function reportVerify() {
|
|
1357
|
+
if (vcfg.checkRun !== true) return;
|
|
1358
|
+
let head = "";
|
|
1359
|
+
try { head = String(await git(["rev-parse", "HEAD"])).trim(); } catch { return; }
|
|
1360
|
+
if (!head || verifyReported.has(head)) return;
|
|
1361
|
+
verifyReported.add(head);
|
|
1362
|
+
const r = verifyReport({ head, ...verifyFacts, contract: effective, result });
|
|
1363
|
+
let posted = false, url = null, error = null;
|
|
1364
|
+
try {
|
|
1365
|
+
if (!io.createCheckRun) throw new Error("no Checks API seam");
|
|
1366
|
+
const c = await io.createCheckRun({ sha: head, name: VERIFY_CHECK_NAME, conclusion: r.conclusion, title: r.title, summary: r.summary, text: r.text });
|
|
1367
|
+
posted = true; url = c?.html_url ?? null;
|
|
1368
|
+
} catch (e) {
|
|
1369
|
+
error = String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 200);
|
|
1370
|
+
T.anomalies.push(`${VERIFY_CHECK_NAME} check run not posted for ${head.slice(0, 12)}: ${error}`);
|
|
1371
|
+
}
|
|
1372
|
+
ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
|
|
1373
|
+
}
|
|
1241
1374
|
/** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
|
|
1242
1375
|
const doReview = async () => {
|
|
1243
1376
|
const d = await gate();
|
|
@@ -1254,8 +1387,10 @@ export async function runCore(o) {
|
|
|
1254
1387
|
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
1255
1388
|
const outcome = await gate();
|
|
1256
1389
|
verdict = outcome;
|
|
1390
|
+
for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
|
|
1257
1391
|
if (outcome.state === "conforms") break;
|
|
1258
|
-
const
|
|
1392
|
+
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1393
|
+
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1259
1394
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1260
1395
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1261
1396
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -1266,8 +1401,10 @@ export async function runCore(o) {
|
|
|
1266
1401
|
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
1267
1402
|
if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
|
|
1268
1403
|
resumes++;
|
|
1269
|
-
T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
|
|
1270
|
-
|
|
1404
|
+
T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")} (${explained.slice(0, 5).map((f) => String(f.detail).slice(0, 160)).join("; ")}); builder resumed once`);
|
|
1405
|
+
// A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
|
|
1406
|
+
if (outcome.failures.some((f) => f?.kind === "validate_failed")) { try { await io.git(["fetch", "origin", lane], { cwd: wt }); } catch { /* offline: the resume uses the local ref */ } }
|
|
1407
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode, lane), sessionId);
|
|
1271
1408
|
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
1272
1409
|
}
|
|
1273
1410
|
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
@@ -1322,6 +1459,7 @@ export async function runCore(o) {
|
|
|
1322
1459
|
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
1323
1460
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
1324
1461
|
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1462
|
+
await reportVerify();
|
|
1325
1463
|
|
|
1326
1464
|
// ---- 6. merge policy: required (default) | all | none ----
|
|
1327
1465
|
let policy = mergeWait, required = [];
|
|
@@ -1419,6 +1557,7 @@ export async function runCore(o) {
|
|
|
1419
1557
|
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
1420
1558
|
}
|
|
1421
1559
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1560
|
+
await reportVerify();
|
|
1422
1561
|
syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
|
|
1423
1562
|
T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
|
|
1424
1563
|
await post("FORGE:BASE_SYNC", `## Base Sync\n**Trigger:** ${trigger}\n**Base merged:** \`${base}\` at \`${baseSha}\`\n**Result:** ${clean ? "clean merge" : `conflicts in ${files.map((f) => `\`${f}\``).join(", ") || "unknown files"}`}\n**Resolution:** ${how}\n**Tests run:** ${tests}\n**Re-review:** ${rereview}\n**New head:** \`${localHead.slice(0, 12)}\``);
|
|
@@ -1451,6 +1590,7 @@ export async function runCore(o) {
|
|
|
1451
1590
|
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1452
1591
|
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1453
1592
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1593
|
+
await reportVerify();
|
|
1454
1594
|
return null;
|
|
1455
1595
|
}
|
|
1456
1596
|
/** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
|
|
@@ -1512,7 +1652,8 @@ export async function runCore(o) {
|
|
|
1512
1652
|
// a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
|
|
1513
1653
|
const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
|
|
1514
1654
|
if (policy === "all") {
|
|
1515
|
-
|
|
1655
|
+
// #4270: the lane's own report-only check never gates its own merge
|
|
1656
|
+
const w = await io.waitCiGreen(pr, repo, 0, ...(vcfg.checkRun === true ? [{ ignore: [VERIFY_CHECK_NAME] }] : [])).catch(() => ({ rc: 2, text: "" }));
|
|
1516
1657
|
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
1517
1658
|
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
1518
1659
|
} else {
|