forgedock 1.26.0 → 1.27.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 +3 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +78 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.mjs +76 -10
- package/bin/lean/conformance.test.mjs +32 -3
- package/bin/lean/effects.mjs +96 -11
- package/bin/lean/lean.mjs +326 -33
- package/bin/lean/lean.test.mjs +618 -10
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +42 -8
- package/bin/lean/render.mjs +1 -1
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +52 -3
- package/docs/OUTCOME-CODES.md +3 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +13 -1
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
package/bin/lean/lean.mjs
CHANGED
|
@@ -10,20 +10,28 @@ import { hostname as osHostname } from "node:os";
|
|
|
10
10
|
import { appendEvent } from "../engine/runlog.mjs";
|
|
11
11
|
import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
12
12
|
import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
|
|
13
|
-
import { openDependency, isTrustedComment } from "../engine/phases.mjs";
|
|
13
|
+
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking } from "./conformance.mjs";
|
|
15
|
+
import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking, reviewSkeleton, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
16
16
|
import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
17
17
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
18
|
+
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
18
19
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
19
20
|
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
20
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
|
|
21
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, ciFailurePrompt, ciUnavailablePrompt, syncChecks } from "./prompts.mjs";
|
|
22
|
+
import { parseVerify, runCiVerify, VERIFY_MODES } from "./verify.mjs";
|
|
21
23
|
|
|
22
24
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
23
25
|
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
24
26
|
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
25
27
|
/** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
|
|
26
28
|
const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
|
|
29
|
+
/**
|
|
30
|
+
* Precheck failures that block in shadow mode too (#4160): the builder's own validate command failed, or it reported a
|
|
31
|
+
* contracted requirement as not done. These are the precheck's form of "a requirement not done". Missing validate results
|
|
32
|
+
* (often CI-only steps), scope creep and amendment problems stay recorded-only in shadow.
|
|
33
|
+
*/
|
|
34
|
+
const SHADOW_PRE_BLOCKS = ["validate_failed", "requirement_unimplemented"];
|
|
27
35
|
export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
|
|
28
36
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
29
37
|
export const MAX_SUBISSUES = 8;
|
|
@@ -55,6 +63,15 @@ export function classifyBlock(result, issue) {
|
|
|
55
63
|
return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Never a branch-deletion target (#4179): anything but the issue's own `feat/issue-<n>-lean`, and never main, master,
|
|
68
|
+
* staging, the lane, forge.yaml's default/staging branch, `milestone/*` or a default branch of the repo.
|
|
69
|
+
*/
|
|
70
|
+
export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, defaults = [] } = {}) {
|
|
71
|
+
if (typeof b !== "string" || b !== `feat/issue-${issue}-lean`) return true;
|
|
72
|
+
return ["main", "master", "staging", lane, branches?.default, branches?.staging].includes(b) || /^milestone\//.test(b) || isDefaultLane(b, defaults);
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
/**
|
|
59
76
|
* @param {object} o
|
|
60
77
|
* @param {number} o.issue
|
|
@@ -69,13 +86,17 @@ export function classifyBlock(result, issue) {
|
|
|
69
86
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
70
87
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
71
88
|
* @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
|
|
89
|
+
* @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
|
|
72
90
|
*/
|
|
73
91
|
export async function runCore(o) {
|
|
74
92
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
75
93
|
ciMaxMinutes = 360, conformance = "shadow", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
76
94
|
const now = io.now ?? (() => Date.now());
|
|
95
|
+
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
96
|
+
if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
|
|
97
|
+
const remoteVerify = vcfg.mode !== "local";
|
|
77
98
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
78
|
-
let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
|
|
99
|
+
let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
|
|
79
100
|
const branch = `feat/issue-${issue}-lean`;
|
|
80
101
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
81
102
|
const t0 = now();
|
|
@@ -121,7 +142,7 @@ export async function runCore(o) {
|
|
|
121
142
|
const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
|
|
122
143
|
let leaseState = null, leaseTimer = null;
|
|
123
144
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
124
|
-
const renew = async () => { if (io.projector && leaseState) { try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
|
|
145
|
+
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 */ } } };
|
|
125
146
|
async function releaseLease(reason) {
|
|
126
147
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
127
148
|
if (!io.projector || !leaseState) return;
|
|
@@ -131,11 +152,91 @@ export async function runCore(o) {
|
|
|
131
152
|
|
|
132
153
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
133
154
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
134
|
-
let done = null, wtCreated = false;
|
|
155
|
+
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
135
156
|
const runId = `${issue}-${t0}`;
|
|
136
157
|
let runTmp = null;
|
|
137
|
-
|
|
138
|
-
|
|
158
|
+
// ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
|
|
159
|
+
let ledger = null, deleteLocalBranch = false;
|
|
160
|
+
const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
|
|
161
|
+
const protectedBranch = (b) => isProtectedLaneBranch(b, { issue, lane, branches, defaults });
|
|
162
|
+
/** Teardown from the ledger (#4179), on every exit path: builder pids, checkpoint of unpushed work, run tmp, worktree, branch, docker. */
|
|
163
|
+
async function teardown(reason, extra) {
|
|
164
|
+
for (const p of ledger ? ledger.entries("pid") : []) {
|
|
165
|
+
try { if (await io.killGroup?.(p.value, p.marker)) ledgerDo((l) => l.release("pid", p.value)); } catch { /* keep it listed */ }
|
|
166
|
+
}
|
|
167
|
+
// Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
|
|
168
|
+
const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
|
|
169
|
+
if (runTmp) {
|
|
170
|
+
try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
|
|
171
|
+
runTmp = null;
|
|
172
|
+
} // every terminal, including engine-error and deferred
|
|
173
|
+
if (wtCreated && saved) {
|
|
174
|
+
let removed = false;
|
|
175
|
+
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); removed = true; } catch { /* best effort */ }
|
|
176
|
+
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
177
|
+
if (removed) ledgerDo((l) => l.release("worktree", wt));
|
|
178
|
+
let branchDone = true;
|
|
179
|
+
if ((reason === "invalid" && !extra.pr) || (reason === "merged" && deleteLocalBranch)) {
|
|
180
|
+
try { await io.git(["branch", "-D", branch], { cwd }); } catch { branchDone = reason !== "merged"; }
|
|
181
|
+
}
|
|
182
|
+
if (branchDone) ledgerDo((l) => l.release("branch", branch));
|
|
183
|
+
} else if (wtCreated) {
|
|
184
|
+
ev({ event: "TEARDOWN_KEPT", reason: "checkpoint failed: worktree and branch kept so no work is lost", worktree: wt });
|
|
185
|
+
}
|
|
186
|
+
for (const kind of ["container", "volume"]) {
|
|
187
|
+
for (const r of ledger ? ledger.entries(kind) : []) {
|
|
188
|
+
if (!io.docker) continue;
|
|
189
|
+
try { await io.docker(kind === "container" ? ["rm", "-f", String(r.value)] : ["volume", "rm", String(r.value)]); ledgerDo((l) => l.release(kind, r.value)); } catch { /* docker absent or busy: keep it listed */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
ledgerDo((l) => { if (l.remove()) ev({ event: "LEDGER_CLEARED", path: l.path }); else ev({ event: "LEDGER_KEPT", path: l.path, resources: l.entries().map((r) => `${r.kind}:${r.value}`) }); });
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* #4162: save the worktree's uncommitted work (tracked, untracked and .forge-lean/) to refs/forge/salvage/<issue>/<ms>
|
|
196
|
+
* before an earlier run's worktree is removed at the start of this run (a run that died hard never ran its
|
|
197
|
+
* teardown checkpoint, #4179). It stages into the worktree's own index, which is discarded with the worktree, and
|
|
198
|
+
* the ref lives in the shared repo, so it survives the removal. Returns the ref, or null when there is nothing to save.
|
|
199
|
+
*/
|
|
200
|
+
async function salvage(why) {
|
|
201
|
+
if (!io.fs?.exists || !(await io.fs.exists(wt))) return null;
|
|
202
|
+
const g = (a) => io.git(a, { cwd: wt });
|
|
203
|
+
try {
|
|
204
|
+
await g(["add", "-A"]);
|
|
205
|
+
if (await io.fs.exists(join(wt, LEAN_DIR))) await g(["add", "-f", "--", LEAN_DIR]);
|
|
206
|
+
const tree = String(await g(["write-tree"])).trim();
|
|
207
|
+
const headTree = String(await g(["rev-parse", "HEAD^{tree}"])).trim();
|
|
208
|
+
if (!/^[0-9a-f]{40,64}$/.test(tree) || tree === headTree) return null;
|
|
209
|
+
const commit = String(await g(["-c", "user.name=forgedock", "-c", "user.email=forgedock@localhost", "commit-tree", tree, "-p", "HEAD", "-m", `forge salvage #${issue}: ${why}`])).trim();
|
|
210
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit)) return null;
|
|
211
|
+
const ref = `refs/forge/salvage/${issue}/${now()}`;
|
|
212
|
+
await io.git(["update-ref", ref, commit], { cwd });
|
|
213
|
+
ev({ event: "WORKTREE_SALVAGED", ref, why });
|
|
214
|
+
T.anomalies.push(`Uncommitted work from ${why} saved to \`${ref}\` (inspect: \`git diff HEAD ${ref} --stat\`)`);
|
|
215
|
+
return ref;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
ev({ event: "WORKTREE_SALVAGE_FAILED", why, error: String(e?.message ?? e).slice(0, 300) });
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** #4162: stop processes still running inside the worktree (an orphaned runner from a dead run) before it is removed. */
|
|
222
|
+
async function stopWorktreeProcs() {
|
|
223
|
+
if (!io.proc) return;
|
|
224
|
+
const procs = await Promise.resolve(io.proc.inDir(wt)).catch(() => []);
|
|
225
|
+
if (!procs.length) return;
|
|
226
|
+
for (const p of procs) io.proc.kill(p.pid, "SIGTERM");
|
|
227
|
+
for (let i = 0; i < 10 && procs.some((p) => io.proc.alive(p.pid)); i++) await sleep(500);
|
|
228
|
+
const left = procs.filter((p) => io.proc.alive(p.pid));
|
|
229
|
+
for (const p of left) io.proc.kill(p.pid, "SIGKILL");
|
|
230
|
+
ev({ event: "WORKTREE_PROCS_STOPPED", pids: procs.map((p) => p.pid), killed: left.map((p) => p.pid) });
|
|
231
|
+
T.anomalies.push(`Stopped ${procs.length} process(es) still running in the worktree from an earlier run: ${procs.map((p) => `${p.pid} ${p.cmd ?? ""}`.trim().slice(0, 80)).join("; ")}`);
|
|
232
|
+
}
|
|
233
|
+
function finish(...args) {
|
|
234
|
+
if (done) return Promise.resolve(done);
|
|
235
|
+
finishing ??= finishOnce(...args); // a signal during a normal finish waits for the same teardown
|
|
236
|
+
return finishing;
|
|
237
|
+
}
|
|
238
|
+
async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
|
|
239
|
+
await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
|
|
139
240
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
140
241
|
ev({ event: "RUN_TERMINAL", reason, code });
|
|
141
242
|
await emit({ event: "RUN_TERMINAL", reason, code });
|
|
@@ -150,11 +251,6 @@ export async function runCore(o) {
|
|
|
150
251
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
151
252
|
}
|
|
152
253
|
await releaseLease(reason);
|
|
153
|
-
if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
|
|
154
|
-
if (wtCreated) {
|
|
155
|
-
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
|
|
156
|
-
if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
|
|
157
|
-
}
|
|
158
254
|
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, ...extra };
|
|
159
255
|
return done;
|
|
160
256
|
}
|
|
@@ -232,7 +328,9 @@ export async function runCore(o) {
|
|
|
232
328
|
async function callRunner(args) {
|
|
233
329
|
let pauses = 0;
|
|
234
330
|
for (;;) {
|
|
235
|
-
|
|
331
|
+
// #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
|
|
332
|
+
const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
|
|
333
|
+
try { return await io.runner({ ...args, env: { FORGE_DOCKER_LABELS: labels, ...(runTmp ? { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } : {}) } }); }
|
|
236
334
|
catch (e) {
|
|
237
335
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
238
336
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -264,11 +362,13 @@ export async function runCore(o) {
|
|
|
264
362
|
const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
|
|
265
363
|
if (ahead) await gitPush(["push", "-u", "origin", branch]);
|
|
266
364
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
267
|
-
|
|
365
|
+
return true;
|
|
366
|
+
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
268
367
|
}
|
|
269
368
|
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.";
|
|
270
369
|
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.";
|
|
271
|
-
const
|
|
370
|
+
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.";
|
|
371
|
+
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.`;
|
|
272
372
|
|
|
273
373
|
/** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
|
|
274
374
|
function startGovernor(control) {
|
|
@@ -293,7 +393,7 @@ export async function runCore(o) {
|
|
|
293
393
|
}
|
|
294
394
|
|
|
295
395
|
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
296
|
-
const implementRestart = () => builderImplementPrompt() + CONTINUE_NOTE.replace("BASE", lane);
|
|
396
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode) + CONTINUE_NOTE.replace("BASE", lane);
|
|
297
397
|
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
298
398
|
let timeoutRetries = 0, p = prompt;
|
|
299
399
|
for (;;) {
|
|
@@ -316,8 +416,11 @@ export async function runCore(o) {
|
|
|
316
416
|
await renew();
|
|
317
417
|
const control = {};
|
|
318
418
|
const governor = startGovernor(control);
|
|
319
|
-
|
|
419
|
+
let spawned = null;
|
|
420
|
+
const onSpawn = (child) => { if (Number.isSafeInteger(child?.pid) && !finishing) { spawned = child.pid; ledgerDo((l) => l.register("pid", child.pid, { marker: basename(String(child.spawnfile ?? "")) || null })); } };
|
|
421
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt, control, onSpawn,
|
|
320
422
|
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
|
|
423
|
+
call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
|
|
321
424
|
let r;
|
|
322
425
|
call.catch(() => governor.stop());
|
|
323
426
|
if (live && io.fs.stat) {
|
|
@@ -334,6 +437,7 @@ export async function runCore(o) {
|
|
|
334
437
|
} else { try { r = await call; } finally { /* governor stops below */ } }
|
|
335
438
|
await governor.stop();
|
|
336
439
|
await renew();
|
|
440
|
+
await registerReported();
|
|
337
441
|
if (r?.sessionId) sessionId = r.sessionId;
|
|
338
442
|
await attempt("implement", n, r);
|
|
339
443
|
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
@@ -341,6 +445,17 @@ export async function runCore(o) {
|
|
|
341
445
|
}
|
|
342
446
|
const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
343
447
|
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
448
|
+
/** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
|
|
449
|
+
async function registerReported() {
|
|
450
|
+
if (!ledger || finishing) return; // after teardown nothing new is owned
|
|
451
|
+
const res = (await readResult())?.resources;
|
|
452
|
+
if (!res || typeof res !== "object") return;
|
|
453
|
+
for (const [key, kind] of [["containers", "container"], ["volumes", "volume"]]) {
|
|
454
|
+
for (const v of Array.isArray(res[key]) ? res[key] : []) {
|
|
455
|
+
if (typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(v)) ledgerDo((l) => l.register(kind, v));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
344
459
|
|
|
345
460
|
/** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
|
|
346
461
|
async function resolveBlock(r) {
|
|
@@ -353,8 +468,20 @@ export async function runCore(o) {
|
|
|
353
468
|
return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
354
469
|
}
|
|
355
470
|
if (String(v?.state).toUpperCase() !== "OPEN") continue;
|
|
356
|
-
open.
|
|
357
|
-
|
|
471
|
+
// #4153: an open decomposed parent resolves to its open sub-issues; it is satisfied once they all close.
|
|
472
|
+
const subs = isDecomposedRecord(v) ? await decomposedSubissues(d, io) : null;
|
|
473
|
+
const recs = [];
|
|
474
|
+
if (!subs) recs.push([d, v]);
|
|
475
|
+
else for (const n of subs) {
|
|
476
|
+
try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
|
|
477
|
+
return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
for (const [n, rec] of recs) {
|
|
481
|
+
if (String(rec?.state).toUpperCase() !== "OPEN" || n === Number(issue) || open.includes(n)) continue;
|
|
482
|
+
open.push(n);
|
|
483
|
+
if ((rec.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(n);
|
|
484
|
+
}
|
|
358
485
|
}
|
|
359
486
|
if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
|
|
360
487
|
return { kind: "dependency", issues: open, blockers };
|
|
@@ -381,7 +508,8 @@ export async function runCore(o) {
|
|
|
381
508
|
try {
|
|
382
509
|
for (const sub of b.subissues) {
|
|
383
510
|
const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
|
|
384
|
-
|
|
511
|
+
// #4153: the parent's milestone, so each sub-issue derives the parent's lane before any lane runs.
|
|
512
|
+
const out = String(await ghw(["issue", "create", "-R", repo, "--title", sub.title, "--body", `${sub.body.trim()}\n\n${lines.join("\n")}`, ...(milestone ? ["--milestone", milestone] : [])]));
|
|
385
513
|
const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
|
|
386
514
|
if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
|
|
387
515
|
filed.push(n);
|
|
@@ -404,7 +532,7 @@ export async function runCore(o) {
|
|
|
404
532
|
{
|
|
405
533
|
const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
|
|
406
534
|
if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
|
|
407
|
-
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
|
|
535
|
+
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
|
|
408
536
|
if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
|
|
409
537
|
}
|
|
410
538
|
/** Last guard before a PR is created or merged: the default branch is never a lane. */
|
|
@@ -436,9 +564,17 @@ export async function runCore(o) {
|
|
|
436
564
|
leaseTimer.unref?.();
|
|
437
565
|
}
|
|
438
566
|
|
|
567
|
+
if (io.ledger) {
|
|
568
|
+
try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, host: myHost }); } catch { ledger = null; }
|
|
569
|
+
}
|
|
570
|
+
// A signal (engine-cli SIGTERM/SIGINT) runs the same teardown and lease release as any other exit.
|
|
571
|
+
o.onTeardown?.((signal) => finish("deferred", `interrupted by ${signal}`, { code: OUTCOME_CODES.RUN_INTERRUPTED }, OUTCOME_CODES.RUN_INTERRUPTED));
|
|
439
572
|
if (io.runTmp) {
|
|
440
573
|
try { await io.runTmp.prune?.(); } catch { /* best effort */ }
|
|
574
|
+
const planned = io.runTmp.path?.(runId);
|
|
575
|
+
if (planned) ledgerDo((l) => l.register("tmp", planned));
|
|
441
576
|
try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
|
|
577
|
+
if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
|
|
442
578
|
}
|
|
443
579
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
444
580
|
let iss;
|
|
@@ -465,6 +601,10 @@ export async function runCore(o) {
|
|
|
465
601
|
return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
|
|
466
602
|
isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
|
|
467
603
|
}
|
|
604
|
+
// #4162: a run that died can leave its runner writing into this worktree, and uncommitted work in it. Stop the
|
|
605
|
+
// processes and save the work before the worktree is removed; the lease above makes this run the only one doing so.
|
|
606
|
+
await stopWorktreeProcs();
|
|
607
|
+
salvagedRef = await salvage("an earlier run");
|
|
468
608
|
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
|
|
469
609
|
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
470
610
|
// Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
|
|
@@ -504,6 +644,7 @@ export async function runCore(o) {
|
|
|
504
644
|
keepLocal = containsRemote;
|
|
505
645
|
}
|
|
506
646
|
} catch { /* no local branch */ }
|
|
647
|
+
ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
|
|
507
648
|
if (keepLocal) {
|
|
508
649
|
await addWorktree(["worktree", "add", wt, branch]);
|
|
509
650
|
resumed = resumed ?? { pr: null, local: true };
|
|
@@ -524,11 +665,25 @@ export async function runCore(o) {
|
|
|
524
665
|
const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
|
|
525
666
|
const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
|
|
526
667
|
// Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
|
|
527
|
-
const
|
|
668
|
+
const trustedOf = (i) => (i.comments ?? []).filter((c) => {
|
|
528
669
|
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
529
670
|
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
530
671
|
});
|
|
531
|
-
const
|
|
672
|
+
const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => c?.body ?? "")].join("\n\n");
|
|
673
|
+
let trustedComments = trustedOf(iss);
|
|
674
|
+
let issueText = textOf(iss, trustedComments);
|
|
675
|
+
/** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
|
|
676
|
+
async function refreshIssue(stage) {
|
|
677
|
+
let next;
|
|
678
|
+
try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]))); } catch { return; }
|
|
679
|
+
if (!next || typeof next !== "object") return;
|
|
680
|
+
const tc = trustedOf(next), text = textOf(next, tc);
|
|
681
|
+
const old = contractHash(issueText), now = contractHash(text);
|
|
682
|
+
if (old === now) return;
|
|
683
|
+
iss = next; trustedComments = tc; issueText = text;
|
|
684
|
+
ev({ event: "ISSUE_EDITED", old, new: now, stage });
|
|
685
|
+
T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
|
|
686
|
+
}
|
|
532
687
|
/** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
|
|
533
688
|
async function endIfBlocked() {
|
|
534
689
|
let block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
@@ -596,11 +751,13 @@ export async function runCore(o) {
|
|
|
596
751
|
const blocked = await endIfBlocked();
|
|
597
752
|
if (blocked) return blocked;
|
|
598
753
|
let contract = await readContract();
|
|
754
|
+
await refreshIssue("contract validation");
|
|
599
755
|
let vc = validateContract(contract, issueText);
|
|
600
756
|
if (!vc.ok && sessionId) {
|
|
601
757
|
T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
|
|
602
758
|
await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
|
|
603
759
|
contract = await readContract();
|
|
760
|
+
await refreshIssue("contract validation");
|
|
604
761
|
vc = validateContract(contract, issueText);
|
|
605
762
|
}
|
|
606
763
|
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
@@ -618,7 +775,7 @@ export async function runCore(o) {
|
|
|
618
775
|
}
|
|
619
776
|
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
620
777
|
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
621
|
-
const implementPrompt = builderImplementPrompt() + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane));
|
|
778
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
|
|
622
779
|
liveKeys = ["implementation"];
|
|
623
780
|
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
624
781
|
result = await readResult();
|
|
@@ -632,9 +789,97 @@ export async function runCore(o) {
|
|
|
632
789
|
T.implement = [`${files.length} file(s)`, branch];
|
|
633
790
|
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
634
791
|
|
|
792
|
+
// ---- 3b. structure delta (forge#4159): deterministic, before the conformance review; no reviewer run of its own ----
|
|
793
|
+
// A package.json edge between workspace packages the contract does not name blocks (one builder resume) in either
|
|
794
|
+
// conformance mode; import-pair growth and boundary-config loosening only add one line to the reviewer prompt.
|
|
795
|
+
const structureOf = async () => {
|
|
796
|
+
if (!io.structureDelta) return null;
|
|
797
|
+
const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
|
|
798
|
+
return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
|
|
799
|
+
};
|
|
800
|
+
let structure = await structureOf();
|
|
801
|
+
if (structure) {
|
|
802
|
+
const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
|
|
803
|
+
const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
|
|
804
|
+
import_files_added: sum("files_added"), import_files_removed: sum("files_removed"), boundary_changed: Boolean(structure.boundary_config_changed?.changed), boundary_loosened: Boolean(structure.boundary_config_changed?.loosens) };
|
|
805
|
+
ev(rec);
|
|
806
|
+
await emit({ ...rec, phase: "implement" });
|
|
807
|
+
const undeclared = () => unnamedEdges(applyAmendments(frozen, result?.amendments, issueText).contract, structure);
|
|
808
|
+
let edges = undeclared();
|
|
809
|
+
if (edges.length && sessionId) {
|
|
810
|
+
T.decisions.push(`Undeclared package edge(s) ${edges.map((e) => `${e.from}→${e.to}`).join(", ")}; builder resumed once`);
|
|
811
|
+
await builder(++attemptN, structurePrompt(edges, vcfg.mode), sessionId);
|
|
812
|
+
await upsertStage("implementation");
|
|
813
|
+
result = (await readResult()) ?? result;
|
|
814
|
+
structure = (await structureOf()) ?? structure;
|
|
815
|
+
edges = undeclared();
|
|
816
|
+
}
|
|
817
|
+
if (edges.length) {
|
|
818
|
+
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);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const structureLine = structureNote(structure);
|
|
822
|
+
|
|
823
|
+
// ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
|
|
824
|
+
// A red run resumes the builder with the failed jobs' logs (verify.max_rounds), slow CI ends ci-pending, and CI that
|
|
825
|
+
// cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
|
|
826
|
+
let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
|
|
827
|
+
const ciSeenRuns = [];
|
|
828
|
+
/** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
|
|
829
|
+
async function ciVerify(trigger) {
|
|
830
|
+
let fixes = 0;
|
|
831
|
+
for (;;) {
|
|
832
|
+
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
833
|
+
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
834
|
+
files = await changedFiles();
|
|
835
|
+
const v = await runCiVerify({ io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns,
|
|
836
|
+
push: () => gitPush(["push", "-u", "origin", branch]),
|
|
837
|
+
onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } });
|
|
838
|
+
if (v.runId != null) ciSeenRuns.push(v.runId);
|
|
839
|
+
const rec = { event: "CI_VERIFY", trigger, mode: vcfg.mode, round: ciRounds, result: v.result, skipped: v.result === "skipped", run_url: v.runUrl ?? null, failures: v.failures.map((f) => f.job) };
|
|
840
|
+
ev(rec); await emit({ ...rec, phase: "verify" });
|
|
841
|
+
const row = (res) => { T.verify = [res, `mode ${vcfg.mode}, ${ciRounds} fix round(s)${v.runUrl ? `, ${v.runUrl}` : ""}`]; };
|
|
842
|
+
if (v.result === "pass" || v.result === "skipped") {
|
|
843
|
+
ciVerifiedHead = head;
|
|
844
|
+
row(v.result === "pass" ? `CI green at ${head.slice(0, 12)}` : "skipped (docs-only change)");
|
|
845
|
+
if (v.result === "skipped") T.decisions.push(`CI verification skipped: ${v.detail}`);
|
|
846
|
+
return { fixes };
|
|
847
|
+
}
|
|
848
|
+
if (v.result === "timeout") {
|
|
849
|
+
row("CI pending");
|
|
850
|
+
T.anomalies.push("CI verification still pending at the wait budget");
|
|
851
|
+
return { done: await finish("ci-pending", `CI verification of ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
|
|
852
|
+
}
|
|
853
|
+
if (v.result === "unavailable") {
|
|
854
|
+
ciFellBack = true;
|
|
855
|
+
row("unavailable: local fallback");
|
|
856
|
+
T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
|
|
857
|
+
await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null);
|
|
858
|
+
await upsertStage("implementation");
|
|
859
|
+
result = (await readResult()) ?? result;
|
|
860
|
+
return { fixes: fixes + 1 };
|
|
861
|
+
}
|
|
862
|
+
// fail
|
|
863
|
+
const jobs = v.failures.map((f) => f.job).join(", ") || "unknown job";
|
|
864
|
+
row(`CI red (${jobs})`);
|
|
865
|
+
if (ciRounds >= vcfg.maxRounds || !sessionId) {
|
|
866
|
+
return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${v.failures.slice(0, 10).map((f) => `- ${f.job}${f.url ? ` (${f.url})` : ""}`).join("\n")}`, OUTCOME_CODES.CI_RED) };
|
|
867
|
+
}
|
|
868
|
+
ciRounds++; fixes++;
|
|
869
|
+
T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
|
|
870
|
+
await builder(++attemptN, ciFailurePrompt(v.failures, vcfg.mode), sessionId);
|
|
871
|
+
await upsertStage("implementation");
|
|
872
|
+
result = (await readResult()) ?? result;
|
|
873
|
+
if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
|
|
874
|
+
return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED) };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
635
879
|
// ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
|
|
636
880
|
// enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
|
|
637
|
-
// but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression)
|
|
881
|
+
// but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression),
|
|
882
|
+
// plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
|
|
638
883
|
await label("in-review");
|
|
639
884
|
const shadow = conformance !== "enforce";
|
|
640
885
|
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
|
|
@@ -656,29 +901,37 @@ export async function runCore(o) {
|
|
|
656
901
|
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
657
902
|
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
658
903
|
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
|
|
659
|
-
let d = null;
|
|
904
|
+
let d = null, retry = null;
|
|
905
|
+
const skeleton = reviewSkeleton({ contract: effective, changedFiles: files, ignore: IGNORE }); // #4160: every required row, pre-filled
|
|
660
906
|
for (let t = 0; t < 2; t++) {
|
|
661
907
|
await renew();
|
|
662
908
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
663
|
-
prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base }) });
|
|
909
|
+
prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, skeleton, retry }) });
|
|
664
910
|
await renew();
|
|
665
911
|
await attempt("review", ++reviewN, rr);
|
|
666
912
|
review = parseReview(rr?.text);
|
|
667
913
|
d = decide(review, ctx);
|
|
668
914
|
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
669
915
|
if (!d.state.startsWith("review.")) return d;
|
|
916
|
+
// #4160: the second run is told exactly what was missing, instead of getting the same prompt again
|
|
917
|
+
retry = d.state === "review.incomplete" ? { problems: d.failures.map((x) => x.detail), previous: review ? JSON.stringify(review) : String(rr?.text ?? "") } : null;
|
|
670
918
|
}
|
|
671
919
|
return d;
|
|
672
920
|
}
|
|
673
921
|
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
674
922
|
async function gate() {
|
|
923
|
+
await refreshIssue("conformance");
|
|
675
924
|
result = (await readResult()) ?? result;
|
|
676
925
|
files = await changedFiles();
|
|
677
926
|
const amendProblems = await amend();
|
|
678
|
-
const
|
|
927
|
+
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
|
|
928
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified });
|
|
679
929
|
const pre = [...pc.failures, ...amendProblems];
|
|
680
930
|
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
681
931
|
if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
|
|
932
|
+
// shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
|
|
933
|
+
const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.kind)) : [];
|
|
934
|
+
if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard };
|
|
682
935
|
const d = await reviewOnce();
|
|
683
936
|
if (!shadow) return { ...d, by: "review" };
|
|
684
937
|
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
@@ -694,6 +947,7 @@ export async function runCore(o) {
|
|
|
694
947
|
return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
|
|
695
948
|
};
|
|
696
949
|
for (;;) {
|
|
950
|
+
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
697
951
|
const outcome = await gate();
|
|
698
952
|
verdict = outcome;
|
|
699
953
|
if (outcome.state === "conforms") break;
|
|
@@ -704,7 +958,7 @@ export async function runCore(o) {
|
|
|
704
958
|
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);
|
|
705
959
|
resumes++;
|
|
706
960
|
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`);
|
|
707
|
-
await builder(++attemptN, conformanceResumePrompt(outcome.failures), sessionId);
|
|
961
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
|
|
708
962
|
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
709
963
|
}
|
|
710
964
|
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
@@ -765,6 +1019,13 @@ export async function runCore(o) {
|
|
|
765
1019
|
}
|
|
766
1020
|
return "unknown";
|
|
767
1021
|
};
|
|
1022
|
+
/** #4158: MERGEABLE but BLOCKED is a base branch policy block (ruleset / required checks pending), not a conflict. */
|
|
1023
|
+
const policyBlocked = async () => {
|
|
1024
|
+
let j;
|
|
1025
|
+
try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
|
|
1026
|
+
return String(j.mergeable ?? "").toUpperCase() === "MERGEABLE" && String(j.mergeStateStatus ?? "").toUpperCase() === "BLOCKED";
|
|
1027
|
+
};
|
|
1028
|
+
let blockStartedAt = null, blockPollIndex = 0;
|
|
768
1029
|
const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
769
1030
|
const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
|
|
770
1031
|
|| (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
|
|
@@ -784,7 +1045,7 @@ export async function runCore(o) {
|
|
|
784
1045
|
how = "clean merge of the base into the branch";
|
|
785
1046
|
} else {
|
|
786
1047
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
787
|
-
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes,
|
|
1048
|
+
const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge. Do not push.`;
|
|
788
1049
|
await builder(++attemptN, prompt, sessionId ?? null);
|
|
789
1050
|
if (await unresolved()) {
|
|
790
1051
|
await gitOut(["merge", "--abort"]);
|
|
@@ -797,11 +1058,13 @@ export async function runCore(o) {
|
|
|
797
1058
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
798
1059
|
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
799
1060
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
800
|
-
|
|
1061
|
+
let ciFixes = 0;
|
|
1062
|
+
if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
|
|
1063
|
+
if (!clean || ciFixes) {
|
|
801
1064
|
let rv = await doReview();
|
|
802
1065
|
if (rv?.fail) {
|
|
803
1066
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
804
|
-
await builder(++attemptN, conformanceResumePrompt(rv.failures), sessionId ?? null);
|
|
1067
|
+
await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
|
|
805
1068
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
806
1069
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
807
1070
|
rv = await doReview();
|
|
@@ -817,6 +1080,19 @@ export async function runCore(o) {
|
|
|
817
1080
|
return null;
|
|
818
1081
|
}
|
|
819
1082
|
const NR_TESTS = "not reported";
|
|
1083
|
+
/** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
|
|
1084
|
+
async function deleteMergedBranch() {
|
|
1085
|
+
const skip = (why) => { ev({ event: "BRANCH_DELETE_SKIPPED", branch, why }); T.decisions.push(`Lane branch kept: ${why}`); };
|
|
1086
|
+
if (protectedBranch(branch)) return skip("protected branch name");
|
|
1087
|
+
for (const side of ["--head", "--base"]) {
|
|
1088
|
+
let open;
|
|
1089
|
+
try { open = JSON.parse(String(await ghc(["pr", "list", "-R", repo, side, branch, "--state", "open", "--json", "number"])) || "[]"); } catch { return skip("open-PR check failed"); }
|
|
1090
|
+
if (open.length) return skip(`open PR #${open[0].number} uses it as ${side.slice(2)}`);
|
|
1091
|
+
}
|
|
1092
|
+
deleteLocalBranch = true;
|
|
1093
|
+
try { await io.git(["push", "origin", "--delete", branch], { cwd }); ev({ event: "BRANCH_DELETED", branch, where: "origin" }); }
|
|
1094
|
+
catch (e) { ev({ event: "BRANCH_DELETE_FAILED", branch, where: "origin", error: String(e?.stderr ?? e?.message ?? e).slice(0, 300) }); }
|
|
1095
|
+
}
|
|
820
1096
|
|
|
821
1097
|
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
822
1098
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
@@ -871,10 +1147,27 @@ export async function runCore(o) {
|
|
|
871
1147
|
await sleepFn(Math.min(60000, 1000 * 2 ** (mergeRetries - 1)) + Math.floor(random() * 250));
|
|
872
1148
|
continue;
|
|
873
1149
|
}
|
|
1150
|
+
if (await policyBlocked()) {
|
|
1151
|
+
// Policy block: wait and retry at the reviewed head; never base-sync, never base.conflict (#4158).
|
|
1152
|
+
if (blockStartedAt === null) {
|
|
1153
|
+
blockStartedAt = now();
|
|
1154
|
+
T.decisions.push("merge blocked by base branch policy (ruleset/required checks); waiting");
|
|
1155
|
+
T.anomalies.push(`gh pr merge blocked by base branch policy: ${msg.split("\n")[0].slice(0, 120)}`);
|
|
1156
|
+
}
|
|
1157
|
+
if (now() - blockStartedAt >= ciMaxMinutes * 60000) {
|
|
1158
|
+
T.merge = [policyText, "blocked by base branch policy"];
|
|
1159
|
+
return await finish("ci-pending", `PR #${pr} blocked by base branch policy after ${ciMaxMinutes} min (checks pending or ruleset requirement unmet)`, { pr });
|
|
1160
|
+
}
|
|
1161
|
+
onProgress({ event: "phase_paused", phase: "merge", detail: `PR #${pr} blocked by base branch policy` });
|
|
1162
|
+
await sleep(ciPollDelayMs(blockPollIndex++, random));
|
|
1163
|
+
await renew();
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
874
1166
|
if (!MERGE_CONFLICT_RE.test(msg)) throw e;
|
|
875
1167
|
const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
|
|
876
1168
|
}
|
|
877
1169
|
}
|
|
1170
|
+
await deleteMergedBranch();
|
|
878
1171
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
879
1172
|
if (closing.tracked.length) {
|
|
880
1173
|
try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
|