forgedock 1.28.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +29 -0
- package/bin/engine-cli.mjs +71 -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 +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +171 -35
- package/bin/lean/lean.test.mjs +413 -7
- package/bin/lean/orchestrate.mjs +80 -3
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/render.mjs +25 -5
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -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/orchestrate.md +52 -6
- 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/fixtures/test-map/go/.github/workflows/ci.yml +23 -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/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
package/bin/lean/effects.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
4
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
6
|
+
import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
|
|
8
8
|
import { promises as fsp } from "node:fs";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -14,6 +14,7 @@ import { makeProjector } from "../engine/projector.mjs";
|
|
|
14
14
|
import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
|
|
15
15
|
import { makeDepCache } from "./depcache.mjs";
|
|
16
16
|
import { makeHeavyIo } from "./heavy.mjs";
|
|
17
|
+
import { gatherContextFacts } from "./context.mjs";
|
|
17
18
|
|
|
18
19
|
const pexec = promisify(execFile);
|
|
19
20
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
@@ -87,6 +88,8 @@ export function procsInDir(dir, { procRoot = "/proc", self = process.pid } = {})
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
|
|
91
|
+
/** Timeout of the Checks API POST that reports the forgedock/verify check run (#4270). */
|
|
92
|
+
export const CHECK_RUN_TIMEOUT_MS = 60000;
|
|
90
93
|
export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
|
|
91
94
|
const OWNER = ".forge-run-owner";
|
|
92
95
|
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code !== "ESRCH"; } };
|
|
@@ -251,6 +254,16 @@ export function makeLeanIo({ repo }) {
|
|
|
251
254
|
return { name: c.name, state, url: c.url ?? null };
|
|
252
255
|
});
|
|
253
256
|
},
|
|
257
|
+
/**
|
|
258
|
+
* #4270 verify.check_run: one completed check run on `sha` (`gh api -X POST repos/<repo>/check-runs`, JSON on stdin).
|
|
259
|
+
* Needs a token allowed to create check runs (GitHub App, checks:write). Resolves {id, html_url}; throws on failure.
|
|
260
|
+
*/
|
|
261
|
+
async createCheckRun({ sha, name, conclusion, title, summary, text }) {
|
|
262
|
+
const body = JSON.stringify({ name, head_sha: sha, status: "completed", conclusion, output: { title, summary, text } });
|
|
263
|
+
const out = await runWithInput("gh", ["api", "-X", "POST", `repos/${repo}/check-runs`, "--input", "-"], body, CHECK_RUN_TIMEOUT_MS);
|
|
264
|
+
const j = JSON.parse(out || "{}");
|
|
265
|
+
return { id: j.id ?? null, html_url: j.html_url ?? null };
|
|
266
|
+
},
|
|
254
267
|
/** Failed-job logs of an Actions run (`gh run view --log-failed`), tail-capped; "" when unreadable. */
|
|
255
268
|
async failedLogs(runId) {
|
|
256
269
|
try { const out = String(await run("gh")(["run", "view", String(runId), "-R", repo, "--log-failed"])); return out.length > 64000 ? out.slice(-64000) : out; } catch { return ""; }
|
|
@@ -286,6 +299,8 @@ export function makeLeanIo({ repo }) {
|
|
|
286
299
|
async structureDelta({ cwd, base }) {
|
|
287
300
|
try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
|
|
288
301
|
},
|
|
302
|
+
/** forge#4275: the context pack's facts for one issue (bin/lean/context.mjs), from gh and git only; sources fail independently. */
|
|
303
|
+
contextFacts: (args) => gatherContextFacts({ ...args, io: { gh: run("gh"), git: run("git"), exists: async (p) => existsSync(p) } }),
|
|
289
304
|
/** forge#4195: scripts/hotspots.sh facts for `paths` in cwd (growth since base when given); null on any failure. */
|
|
290
305
|
async hotspots({ cwd, base = null, paths = [] }) {
|
|
291
306
|
const args = [script("hotspots.sh"), ...(base ? ["--base", base] : []), "--", ...paths];
|
|
@@ -324,6 +339,9 @@ export function makeLeanIo({ repo }) {
|
|
|
324
339
|
stat: async (p) => { try { return { mtimeMs: statSync(p).mtimeMs }; } catch { return null; } },
|
|
325
340
|
append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
|
|
326
341
|
write: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); },
|
|
342
|
+
rename: async (from, to) => renameSync(from, to),
|
|
343
|
+
list: async (d) => { try { return readdirSync(d); } catch { return []; } },
|
|
344
|
+
rm: async (p) => rmSync(p, { force: true }),
|
|
327
345
|
},
|
|
328
346
|
};
|
|
329
347
|
}
|
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, 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
24
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } 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 } 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,9 @@ 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}: 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
|
|
119
134
|
*/
|
|
120
135
|
export async function runCore(o) {
|
|
121
136
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
@@ -128,6 +143,8 @@ export async function runCore(o) {
|
|
|
128
143
|
const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
|
|
129
144
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
130
145
|
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
146
|
+
// #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
|
|
147
|
+
const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
|
|
131
148
|
const branch = `feat/issue-${issue}-lean`;
|
|
132
149
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
133
150
|
const t0 = now();
|
|
@@ -174,17 +191,23 @@ export async function runCore(o) {
|
|
|
174
191
|
let leaseState = null, leaseTimer = null;
|
|
175
192
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
176
193
|
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) {
|
|
194
|
+
async function releaseLease(reason, code = null) {
|
|
178
195
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
179
196
|
if (!io.projector || !leaseState) return;
|
|
180
197
|
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
181
|
-
|
|
198
|
+
// forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
|
|
199
|
+
const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
|
|
200
|
+
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
|
|
182
201
|
}
|
|
183
202
|
|
|
184
203
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
185
204
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
205
|
+
const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
|
|
206
|
+
const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
|
|
207
|
+
let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
|
|
186
208
|
let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
|
|
187
209
|
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
210
|
+
let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
|
|
188
211
|
const runId = `${issue}-${t0}`;
|
|
189
212
|
let runTmp = null;
|
|
190
213
|
let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
|
|
@@ -293,9 +316,19 @@ export async function runCore(o) {
|
|
|
293
316
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
294
317
|
// forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
|
|
295
318
|
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 } : {})
|
|
319
|
+
conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
|
|
320
|
+
context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}) }; // forge#4275: the on/off A/B groups runs by these
|
|
297
321
|
ev(term);
|
|
298
322
|
await emit(term);
|
|
323
|
+
// #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
|
|
324
|
+
// decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
|
|
325
|
+
// (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
|
|
326
|
+
if (recoverUnlabelled && reason === "ci-pending") {
|
|
327
|
+
try {
|
|
328
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${recoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
|
|
329
|
+
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
330
|
+
} catch { /* best effort */ }
|
|
331
|
+
}
|
|
299
332
|
if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
|
|
300
333
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
301
334
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
@@ -306,17 +339,19 @@ export async function runCore(o) {
|
|
|
306
339
|
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
307
340
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
308
341
|
}
|
|
309
|
-
await releaseLease(reason);
|
|
342
|
+
await releaseLease(reason, code);
|
|
310
343
|
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
344
|
...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
|
|
312
345
|
return done;
|
|
313
346
|
}
|
|
314
|
-
|
|
347
|
+
/** #4297: what a recovery lane that did not merge tried and how it ended; the issue stays needs-human with this. */
|
|
348
|
+
const recoveryEvidence = (reason, code, detail) => `Recovery pass for \`${recover.code}\` (attempt ${recover.attempt}) did not merge: it ended \`${reason}\`${code ? ` (\`${code}\`)` : ""}${String(detail ?? "").trim() ? `: ${String(detail).trim().split("\n")[0].slice(0, 300)}` : ""}. The issue stays \`needs-human\`.`;
|
|
349
|
+
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
|
|
315
350
|
try {
|
|
316
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
|
|
351
|
+
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${recoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
317
352
|
await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
318
353
|
} catch { /* best effort */ }
|
|
319
|
-
return finish("needs-human", why, { code }, code);
|
|
354
|
+
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
320
355
|
}
|
|
321
356
|
|
|
322
357
|
// ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
|
|
@@ -435,6 +470,7 @@ export async function runCore(o) {
|
|
|
435
470
|
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
436
471
|
}
|
|
437
472
|
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.";
|
|
473
|
+
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
474
|
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
475
|
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
476
|
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 +501,8 @@ export async function runCore(o) {
|
|
|
465
501
|
}
|
|
466
502
|
|
|
467
503
|
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
468
|
-
|
|
504
|
+
let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
|
|
505
|
+
const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
|
|
469
506
|
let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
|
|
470
507
|
const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
|
|
471
508
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
@@ -654,7 +691,7 @@ export async function runCore(o) {
|
|
|
654
691
|
}
|
|
655
692
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
656
693
|
try {
|
|
657
|
-
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]));
|
|
694
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
|
|
658
695
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
659
696
|
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
660
697
|
|
|
@@ -662,6 +699,16 @@ export async function runCore(o) {
|
|
|
662
699
|
// ---- 1. claim ----
|
|
663
700
|
await label("investigating");
|
|
664
701
|
await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
|
|
702
|
+
if (recover) {
|
|
703
|
+
// #4267: the batch decided to recover this issue itself; the label goes so the issue is live again.
|
|
704
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", "needs-human"]); } catch { /* best effort */ }
|
|
705
|
+
recoverUnlabelled = true;
|
|
706
|
+
try {
|
|
707
|
+
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.`);
|
|
708
|
+
} catch { /* best effort */ }
|
|
709
|
+
T.decisions.push(`Recovery pass for ${recover.code} (attempt ${recover.attempt}): needs-human removed, PR resumed`);
|
|
710
|
+
ev({ event: "RECOVERY_START", code: recover.code, attempt: recover.attempt });
|
|
711
|
+
}
|
|
665
712
|
|
|
666
713
|
// ---- 2. worktree (resume an open PR / remote branch) ----
|
|
667
714
|
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
@@ -832,18 +879,45 @@ export async function runCore(o) {
|
|
|
832
879
|
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
833
880
|
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
834
881
|
});
|
|
835
|
-
|
|
836
|
-
|
|
882
|
+
// #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
|
|
883
|
+
const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
|
|
884
|
+
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
|
|
885
|
+
let trustedComments = trustedOf(iss), issueEdited = false;
|
|
837
886
|
let issueText = textOf(iss, trustedComments);
|
|
887
|
+
let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
|
|
888
|
+
ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
|
|
889
|
+
/**
|
|
890
|
+
* forge#4275: compile (or read from the cache) the context pack, log CONTEXT_PACK and post FORGE:CONTEXT. Off, no
|
|
891
|
+
* io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
|
|
892
|
+
*/
|
|
893
|
+
async function contextPackStep() {
|
|
894
|
+
if (ctxCfg.mode === "off" || !io.contextFacts) return null;
|
|
895
|
+
try {
|
|
896
|
+
const headSha = String(await git(["rev-parse", base])).trim();
|
|
897
|
+
const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { updatedAt: iss.updatedAt ?? null, attempts: priorAttempts(iss.body, trustedComments) },
|
|
898
|
+
gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
|
|
899
|
+
const p = r.pack;
|
|
900
|
+
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,
|
|
901
|
+
hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
|
|
902
|
+
ev(rec); await emit(rec);
|
|
903
|
+
const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
|
|
904
|
+
if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
|
|
905
|
+
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"}`);
|
|
906
|
+
return p;
|
|
907
|
+
} catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
|
|
908
|
+
}
|
|
838
909
|
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
839
910
|
async function refreshIssue(stage) {
|
|
840
911
|
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; }
|
|
912
|
+
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]))); } catch { return; }
|
|
842
913
|
if (!next || typeof next !== "object") return;
|
|
843
914
|
const tc = trustedOf(next), text = textOf(next, tc);
|
|
844
|
-
|
|
915
|
+
if (contractHash(issueText) === contractHash(text)) return;
|
|
916
|
+
// #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
|
|
917
|
+
const old = scopeHash, now = contractHash(scopeText(next, tc));
|
|
918
|
+
iss = next; trustedComments = tc; issueText = text; scopeHash = now;
|
|
845
919
|
if (old === now) return;
|
|
846
|
-
|
|
920
|
+
issueEdited = true;
|
|
847
921
|
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
848
922
|
T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
|
|
849
923
|
}
|
|
@@ -875,16 +949,21 @@ export async function runCore(o) {
|
|
|
875
949
|
}
|
|
876
950
|
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
877
951
|
}
|
|
878
|
-
/**
|
|
952
|
+
/**
|
|
953
|
+
* The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
|
|
954
|
+
* (#4265): a trusted comment that the engine did not post came after it.
|
|
955
|
+
*/
|
|
879
956
|
async function readFrozenContract() {
|
|
880
957
|
let out = "";
|
|
881
958
|
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
959
|
let found = null;
|
|
883
960
|
for (const l of out.split("\n")) {
|
|
884
961
|
let c; try { c = JSON.parse(l); } catch { continue; }
|
|
885
|
-
if (!
|
|
886
|
-
const
|
|
887
|
-
if (
|
|
962
|
+
if (!isTrustedComment(c)) continue;
|
|
963
|
+
const b = String(c?.body ?? "");
|
|
964
|
+
if (!b.startsWith("<!-- FORGE:CONTRACT -->")) { if (found && b.trim() && !b.trimStart().startsWith("<!-- FORGE:")) found.humanAfter = true; continue; }
|
|
965
|
+
const fc = parseContractComment(b);
|
|
966
|
+
if (fc && contractHash(fc.text) === fc.hash) found = { ...fc, humanAfter: false };
|
|
888
967
|
}
|
|
889
968
|
return found;
|
|
890
969
|
}
|
|
@@ -953,13 +1032,25 @@ export async function runCore(o) {
|
|
|
953
1032
|
} else {
|
|
954
1033
|
if (resumed) {
|
|
955
1034
|
const fc = await readFrozenContract();
|
|
956
|
-
if (fc) {
|
|
1035
|
+
if (fc) {
|
|
1036
|
+
// #4265: a contract planned from an older issue scope is stale: re-plan and freeze a new one instead of judging against it
|
|
1037
|
+
await refreshIssue("rerun contract check");
|
|
1038
|
+
const gone = (Array.isArray(fc.contract?.requirements) ? fc.contract.requirements : []).filter((r) => r?.disposition === "deliver" && !inIssue(r.quote, issueText)).map((r) => r.id);
|
|
1039
|
+
const stale = fc.scope ? (fc.scope !== scopeHash ? `issue scope ${fc.scope} -> ${scopeHash}` : null)
|
|
1040
|
+
: fc.humanAfter ? "a human comment was posted after it" : gone.length ? `quote(s) no longer in the issue: ${gone.join(", ")}` : null;
|
|
1041
|
+
if (stale) {
|
|
1042
|
+
ev({ event: "CONTRACT_STALE", hash: fc.hash, scope: fc.scope, now: scopeHash, reason: stale });
|
|
1043
|
+
T.decisions.push(`Rerun: frozen contract ${fc.hash} is stale (${stale}); re-planning`);
|
|
1044
|
+
} else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
1045
|
+
}
|
|
957
1046
|
}
|
|
958
1047
|
await label("building");
|
|
959
1048
|
hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
|
|
960
|
-
|
|
1049
|
+
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1050
|
+
const ctxPack = frozen ? null : await contextPackStep();
|
|
1051
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
961
1052
|
if (!frozen) {
|
|
962
|
-
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, planPrompt);
|
|
1053
|
+
await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
|
|
963
1054
|
planned = true;
|
|
964
1055
|
result = await readResult();
|
|
965
1056
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
@@ -987,11 +1078,11 @@ export async function runCore(o) {
|
|
|
987
1078
|
vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
988
1079
|
}
|
|
989
1080
|
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);
|
|
1081
|
+
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
1082
|
frozen = contract;
|
|
992
1083
|
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
|
|
1084
|
+
let body = renderContract(frozen, hash, { scope: scopeHash });
|
|
1085
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
|
|
995
1086
|
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
996
1087
|
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
997
1088
|
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
@@ -1001,7 +1092,7 @@ export async function runCore(o) {
|
|
|
1001
1092
|
}
|
|
1002
1093
|
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
1003
1094
|
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) : "");
|
|
1095
|
+
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
1096
|
liveKeys = ["implementation"];
|
|
1006
1097
|
stage = "implement";
|
|
1007
1098
|
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
@@ -1022,17 +1113,28 @@ export async function runCore(o) {
|
|
|
1022
1113
|
// the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
|
|
1023
1114
|
// verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
|
|
1024
1115
|
let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
|
|
1025
|
-
|
|
1026
|
-
|
|
1116
|
+
// #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
|
|
1117
|
+
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
1118
|
+
// #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
|
|
1119
|
+
const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
|
|
1120
|
+
const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return m.length ? { commands: m, source: "test-map" } : { commands: learnedCommands, source: "learned" }; };
|
|
1121
|
+
let picked = pickChecks();
|
|
1122
|
+
// #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
|
|
1123
|
+
const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
|
|
1124
|
+
if (picked.source === "test-map") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.commands.length} of ${testMap.entries.length} entries`);
|
|
1125
|
+
if (picked.commands.length && io.runCommand) {
|
|
1027
1126
|
for (let round = 0; ; round++) {
|
|
1028
1127
|
await renew();
|
|
1128
|
+
if (round) picked = pickChecks();
|
|
1129
|
+
const precheckCommands = picked.commands;
|
|
1029
1130
|
const results = [];
|
|
1030
1131
|
for (const command of precheckCommands) {
|
|
1031
1132
|
let r;
|
|
1032
1133
|
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
1134
|
results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
|
|
1034
1135
|
}
|
|
1035
|
-
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })) });
|
|
1136
|
+
ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source === "test-map" ? { source: "test-map" } : {}) });
|
|
1137
|
+
verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
|
|
1036
1138
|
const failed = results.filter((r) => r.rc !== 0);
|
|
1037
1139
|
if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
|
|
1038
1140
|
const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
|
|
@@ -1074,6 +1176,7 @@ export async function runCore(o) {
|
|
|
1074
1176
|
structure = (await structureOf()) ?? structure;
|
|
1075
1177
|
edges = undeclared();
|
|
1076
1178
|
}
|
|
1179
|
+
verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
|
|
1077
1180
|
if (edges.length) {
|
|
1078
1181
|
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
1182
|
}
|
|
@@ -1090,6 +1193,7 @@ export async function runCore(o) {
|
|
|
1090
1193
|
result = (await readResult()) ?? result;
|
|
1091
1194
|
docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
|
|
1092
1195
|
}
|
|
1196
|
+
verifyFacts.docs = docs;
|
|
1093
1197
|
if (docs && !docs.ok) {
|
|
1094
1198
|
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
1199
|
}
|
|
@@ -1211,6 +1315,7 @@ export async function runCore(o) {
|
|
|
1211
1315
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
1212
1316
|
const hotspots = await hotspotsOf({ base, paths: files });
|
|
1213
1317
|
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
|
|
1318
|
+
Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
|
|
1214
1319
|
const pre = [...pc.failures, ...amendProblems];
|
|
1215
1320
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
1216
1321
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
@@ -1238,6 +1343,29 @@ export async function runCore(o) {
|
|
|
1238
1343
|
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
1239
1344
|
return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
1240
1345
|
}
|
|
1346
|
+
const verifyReported = new Set();
|
|
1347
|
+
/**
|
|
1348
|
+
* #4270 verify.check_run: one `forgedock/verify` check run per pushed head, reporting the gates' last results (no gate
|
|
1349
|
+
* logic, never blocks). Off: no call, no event. A missing seam or a Checks API failure is an anomaly, never a lane failure.
|
|
1350
|
+
*/
|
|
1351
|
+
async function reportVerify() {
|
|
1352
|
+
if (vcfg.checkRun !== true) return;
|
|
1353
|
+
let head = "";
|
|
1354
|
+
try { head = String(await git(["rev-parse", "HEAD"])).trim(); } catch { return; }
|
|
1355
|
+
if (!head || verifyReported.has(head)) return;
|
|
1356
|
+
verifyReported.add(head);
|
|
1357
|
+
const r = verifyReport({ head, ...verifyFacts, contract: effective, result });
|
|
1358
|
+
let posted = false, url = null, error = null;
|
|
1359
|
+
try {
|
|
1360
|
+
if (!io.createCheckRun) throw new Error("no Checks API seam");
|
|
1361
|
+
const c = await io.createCheckRun({ sha: head, name: VERIFY_CHECK_NAME, conclusion: r.conclusion, title: r.title, summary: r.summary, text: r.text });
|
|
1362
|
+
posted = true; url = c?.html_url ?? null;
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
error = String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 200);
|
|
1365
|
+
T.anomalies.push(`${VERIFY_CHECK_NAME} check run not posted for ${head.slice(0, 12)}: ${error}`);
|
|
1366
|
+
}
|
|
1367
|
+
ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
|
|
1368
|
+
}
|
|
1241
1369
|
/** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
|
|
1242
1370
|
const doReview = async () => {
|
|
1243
1371
|
const d = await gate();
|
|
@@ -1254,8 +1382,10 @@ export async function runCore(o) {
|
|
|
1254
1382
|
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
1255
1383
|
const outcome = await gate();
|
|
1256
1384
|
verdict = outcome;
|
|
1385
|
+
for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
|
|
1257
1386
|
if (outcome.state === "conforms") break;
|
|
1258
|
-
const
|
|
1387
|
+
const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
|
|
1388
|
+
const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
1259
1389
|
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
1260
1390
|
if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
|
|
1261
1391
|
degraded = { state: outcome.state, missing: outcome.missing ?? [] };
|
|
@@ -1266,8 +1396,10 @@ export async function runCore(o) {
|
|
|
1266
1396
|
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
1397
|
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
1398
|
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
|
-
|
|
1399
|
+
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`);
|
|
1400
|
+
// A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
|
|
1401
|
+
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 */ } }
|
|
1402
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode, lane), sessionId);
|
|
1271
1403
|
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
1272
1404
|
}
|
|
1273
1405
|
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
@@ -1322,6 +1454,7 @@ export async function runCore(o) {
|
|
|
1322
1454
|
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
1323
1455
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
1324
1456
|
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1457
|
+
await reportVerify();
|
|
1325
1458
|
|
|
1326
1459
|
// ---- 6. merge policy: required (default) | all | none ----
|
|
1327
1460
|
let policy = mergeWait, required = [];
|
|
@@ -1419,6 +1552,7 @@ export async function runCore(o) {
|
|
|
1419
1552
|
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
1420
1553
|
}
|
|
1421
1554
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1555
|
+
await reportVerify();
|
|
1422
1556
|
syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
|
|
1423
1557
|
T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
|
|
1424
1558
|
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 +1585,7 @@ export async function runCore(o) {
|
|
|
1451
1585
|
if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
1452
1586
|
await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
|
|
1453
1587
|
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
1588
|
+
await reportVerify();
|
|
1454
1589
|
return null;
|
|
1455
1590
|
}
|
|
1456
1591
|
/** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
|
|
@@ -1512,7 +1647,8 @@ export async function runCore(o) {
|
|
|
1512
1647
|
// a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
|
|
1513
1648
|
const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
|
|
1514
1649
|
if (policy === "all") {
|
|
1515
|
-
|
|
1650
|
+
// #4270: the lane's own report-only check never gates its own merge
|
|
1651
|
+
const w = await io.waitCiGreen(pr, repo, 0, ...(vcfg.checkRun === true ? [{ ignore: [VERIFY_CHECK_NAME] }] : [])).catch(() => ({ rc: 2, text: "" }));
|
|
1516
1652
|
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
1517
1653
|
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
1518
1654
|
} else {
|