forgedock 1.25.1 → 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 +15 -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 +85 -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.e2e.test.mjs +232 -0
- package/bin/lean/conformance.mjs +304 -0
- package/bin/lean/conformance.test.mjs +192 -0
- package/bin/lean/effects.mjs +119 -11
- package/bin/lean/lean.mjs +533 -70
- package/bin/lean/lean.test.mjs +1065 -112
- 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 +94 -57
- package/bin/lean/render.mjs +52 -7
- 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 +53 -3
- package/docs/OUTCOME-CODES.md +14 -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 +17 -2
- 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,17 +10,33 @@ 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 } from "../engine/phases.mjs";
|
|
13
|
+
import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking, reviewSkeleton, unnamedEdges, structureNote } from "./conformance.mjs";
|
|
14
16
|
import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
15
17
|
import { resolveLane, isDefaultLane } from "./lane.mjs";
|
|
18
|
+
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
16
19
|
import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
|
|
17
|
-
import { renderInvestigation,
|
|
18
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE,
|
|
20
|
+
import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.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";
|
|
19
23
|
|
|
20
24
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
25
|
+
/** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
|
|
26
|
+
export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
|
|
27
|
+
/** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
|
|
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"];
|
|
21
35
|
export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
|
|
22
36
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
23
37
|
export const MAX_SUBISSUES = 8;
|
|
38
|
+
/** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
|
|
39
|
+
export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
|
|
24
40
|
|
|
25
41
|
/**
|
|
26
42
|
* Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
|
|
@@ -47,6 +63,15 @@ export function classifyBlock(result, issue) {
|
|
|
47
63
|
return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
|
|
48
64
|
}
|
|
49
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
|
+
|
|
50
75
|
/**
|
|
51
76
|
* @param {object} o
|
|
52
77
|
* @param {number} o.issue
|
|
@@ -60,13 +85,18 @@ export function classifyBlock(result, issue) {
|
|
|
60
85
|
* @param {string} [o.builderModel] default: CLI default
|
|
61
86
|
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
62
87
|
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
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)
|
|
63
90
|
*/
|
|
64
91
|
export async function runCore(o) {
|
|
65
92
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
66
|
-
ciMaxMinutes = 360, 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;
|
|
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;
|
|
67
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";
|
|
68
98
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
69
|
-
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
|
|
70
100
|
const branch = `feat/issue-${issue}-lean`;
|
|
71
101
|
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
72
102
|
const t0 = now();
|
|
@@ -112,7 +142,7 @@ export async function runCore(o) {
|
|
|
112
142
|
const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
|
|
113
143
|
let leaseState = null, leaseTimer = null;
|
|
114
144
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
115
|
-
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 */ } } };
|
|
116
146
|
async function releaseLease(reason) {
|
|
117
147
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
118
148
|
if (!io.projector || !leaseState) return;
|
|
@@ -122,11 +152,91 @@ export async function runCore(o) {
|
|
|
122
152
|
|
|
123
153
|
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
124
154
|
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
125
|
-
let done = null, wtCreated = false;
|
|
155
|
+
let done = null, finishing = null, wtCreated = false, salvagedRef = null;
|
|
126
156
|
const runId = `${issue}-${t0}`;
|
|
127
157
|
let runTmp = null;
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
130
240
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
131
241
|
ev({ event: "RUN_TERMINAL", reason, code });
|
|
132
242
|
await emit({ event: "RUN_TERMINAL", reason, code });
|
|
@@ -141,11 +251,6 @@ export async function runCore(o) {
|
|
|
141
251
|
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
142
252
|
}
|
|
143
253
|
await releaseLease(reason);
|
|
144
|
-
if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
|
|
145
|
-
if (wtCreated) {
|
|
146
|
-
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
|
|
147
|
-
if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
|
|
148
|
-
}
|
|
149
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 };
|
|
150
255
|
return done;
|
|
151
256
|
}
|
|
@@ -160,7 +265,6 @@ export async function runCore(o) {
|
|
|
160
265
|
// ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
|
|
161
266
|
const STAGES = [
|
|
162
267
|
{ key: "investigation", file: INVESTIGATION_FILE, marker: ["FORGE:INVESTIGATION", "FORGE:VALIDATE"], render: (md, ctx) => renderInvestigation(md, ctx.result) },
|
|
163
|
-
{ key: "plan", file: PLAN_FILE, marker: ["FORGE:PLAN"], render: (md, ctx) => renderPlan(md, ctx.result, ctx.files) },
|
|
164
268
|
{ key: "implementation", file: IMPLEMENTATION_FILE, marker: ["FORGE:IMPLEMENTATION"], render: (md, ctx) => renderImplementation(md, ctx.result, { branch, files: ctx.files }) },
|
|
165
269
|
];
|
|
166
270
|
const posted = {}; // key -> comment url (or "" when posted without a link)
|
|
@@ -176,10 +280,25 @@ export async function runCore(o) {
|
|
|
176
280
|
const url = await post(st.marker, st.render(md, { result, files }));
|
|
177
281
|
posted[st.key] = url ?? "";
|
|
178
282
|
}
|
|
283
|
+
/** Re-render a stage note that changed after a resume: edit the posted comment in place; post it when it was never posted or the edit fails. */
|
|
284
|
+
async function upsertStage(key) {
|
|
285
|
+
const st = STAGES.find((x) => x.key === key);
|
|
286
|
+
const id = /#issuecomment-(\d+)/.exec(posted[key] ?? "")?.[1];
|
|
287
|
+
const res = await readResult(), md = await readStage(st);
|
|
288
|
+
if (id) {
|
|
289
|
+
const files = key === "implementation" ? await stageFiles() : [];
|
|
290
|
+
const marks = st.marker.map((m) => `<!-- ${m} -->`).join("\n");
|
|
291
|
+
try { await ghw(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${id}`, "-f", `body=${capComment(`${marks}\n${st.render(usableMd(md) ? md : null, { result: res, files })}`)}`]); return; } catch { /* fall back to a new comment */ }
|
|
292
|
+
}
|
|
293
|
+
delete posted[key];
|
|
294
|
+
await flushStages(res, [key]);
|
|
295
|
+
}
|
|
179
296
|
/** Post stage notes in order that exist, are stable (mtime unchanged for stableMs) and not yet posted. */
|
|
180
297
|
const seen = {};
|
|
298
|
+
let liveKeys = ["investigation"]; // which stage notes the running builder call may publish live (plan: investigation; implement: implementation)
|
|
181
299
|
async function livePoll() {
|
|
182
300
|
for (const st of STAGES) {
|
|
301
|
+
if (!liveKeys.includes(st.key)) continue;
|
|
183
302
|
if (st.key in posted) continue;
|
|
184
303
|
let m = null;
|
|
185
304
|
try { m = await io.fs.stat(`${wt}/${st.file}`); } catch { m = null; }
|
|
@@ -209,7 +328,9 @@ export async function runCore(o) {
|
|
|
209
328
|
async function callRunner(args) {
|
|
210
329
|
let pauses = 0;
|
|
211
330
|
for (;;) {
|
|
212
|
-
|
|
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 } : {}) } }); }
|
|
213
334
|
catch (e) {
|
|
214
335
|
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
215
336
|
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
@@ -241,9 +362,13 @@ export async function runCore(o) {
|
|
|
241
362
|
const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
|
|
242
363
|
if (ahead) await gitPush(["push", "-u", "origin", branch]);
|
|
243
364
|
ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
|
|
244
|
-
|
|
365
|
+
return true;
|
|
366
|
+
} catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
|
|
245
367
|
}
|
|
246
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.";
|
|
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.";
|
|
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.`;
|
|
247
372
|
|
|
248
373
|
/** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
|
|
249
374
|
function startGovernor(control) {
|
|
@@ -267,7 +392,9 @@ export async function runCore(o) {
|
|
|
267
392
|
return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
|
|
268
393
|
}
|
|
269
394
|
|
|
270
|
-
|
|
395
|
+
/** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
|
|
396
|
+
const implementRestart = () => builderImplementPrompt(vcfg.mode) + CONTINUE_NOTE.replace("BASE", lane);
|
|
397
|
+
async function builder(n, prompt, resume, live = false, restart = implementRestart) {
|
|
271
398
|
let timeoutRetries = 0, p = prompt;
|
|
272
399
|
for (;;) {
|
|
273
400
|
try { return await builderOnce(n, p, resume, live); } catch (e) {
|
|
@@ -279,7 +406,7 @@ export async function runCore(o) {
|
|
|
279
406
|
if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
|
|
280
407
|
T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
|
|
281
408
|
T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
|
|
282
|
-
p =
|
|
409
|
+
p = restart(); resume = null;
|
|
283
410
|
}
|
|
284
411
|
}
|
|
285
412
|
}
|
|
@@ -289,8 +416,11 @@ export async function runCore(o) {
|
|
|
289
416
|
await renew();
|
|
290
417
|
const control = {};
|
|
291
418
|
const governor = startGovernor(control);
|
|
292
|
-
|
|
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,
|
|
293
422
|
idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
|
|
423
|
+
call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
|
|
294
424
|
let r;
|
|
295
425
|
call.catch(() => governor.stop());
|
|
296
426
|
if (live && io.fs.stat) {
|
|
@@ -307,12 +437,25 @@ export async function runCore(o) {
|
|
|
307
437
|
} else { try { r = await call; } finally { /* governor stops below */ } }
|
|
308
438
|
await governor.stop();
|
|
309
439
|
await renew();
|
|
440
|
+
await registerReported();
|
|
310
441
|
if (r?.sessionId) sessionId = r.sessionId;
|
|
311
442
|
await attempt("implement", n, r);
|
|
312
443
|
onProgress({ event: "phase_exit", phase: "build", status: "committed" });
|
|
313
444
|
return r;
|
|
314
445
|
}
|
|
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; } };
|
|
315
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
|
+
}
|
|
316
459
|
|
|
317
460
|
/** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
|
|
318
461
|
async function resolveBlock(r) {
|
|
@@ -325,8 +468,20 @@ export async function runCore(o) {
|
|
|
325
468
|
return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
|
|
326
469
|
}
|
|
327
470
|
if (String(v?.state).toUpperCase() !== "OPEN") continue;
|
|
328
|
-
open.
|
|
329
|
-
|
|
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
|
+
}
|
|
330
485
|
}
|
|
331
486
|
if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
|
|
332
487
|
return { kind: "dependency", issues: open, blockers };
|
|
@@ -353,7 +508,8 @@ export async function runCore(o) {
|
|
|
353
508
|
try {
|
|
354
509
|
for (const sub of b.subissues) {
|
|
355
510
|
const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
|
|
356
|
-
|
|
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] : [])]));
|
|
357
513
|
const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
|
|
358
514
|
if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
|
|
359
515
|
filed.push(n);
|
|
@@ -376,7 +532,7 @@ export async function runCore(o) {
|
|
|
376
532
|
{
|
|
377
533
|
const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
|
|
378
534
|
if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
|
|
379
|
-
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
|
|
535
|
+
lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
|
|
380
536
|
if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
|
|
381
537
|
}
|
|
382
538
|
/** Last guard before a PR is created or merged: the default branch is never a lane. */
|
|
@@ -408,14 +564,22 @@ export async function runCore(o) {
|
|
|
408
564
|
leaseTimer.unref?.();
|
|
409
565
|
}
|
|
410
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));
|
|
411
572
|
if (io.runTmp) {
|
|
412
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));
|
|
413
576
|
try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
|
|
577
|
+
if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
|
|
414
578
|
}
|
|
415
579
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
416
580
|
let iss;
|
|
417
581
|
try {
|
|
418
|
-
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
|
|
582
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
|
|
419
583
|
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
420
584
|
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
421
585
|
|
|
@@ -437,6 +601,10 @@ export async function runCore(o) {
|
|
|
437
601
|
return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
|
|
438
602
|
isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
|
|
439
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");
|
|
440
608
|
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
|
|
441
609
|
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
442
610
|
// Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
|
|
@@ -476,6 +644,7 @@ export async function runCore(o) {
|
|
|
476
644
|
keepLocal = containsRemote;
|
|
477
645
|
}
|
|
478
646
|
} catch { /* no local branch */ }
|
|
647
|
+
ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
|
|
479
648
|
if (keepLocal) {
|
|
480
649
|
await addWorktree(["worktree", "add", wt, branch]);
|
|
481
650
|
resumed = resumed ?? { pr: null, local: true };
|
|
@@ -490,14 +659,33 @@ export async function runCore(o) {
|
|
|
490
659
|
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
491
660
|
const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
492
661
|
|
|
493
|
-
// ---- 3. builder ----
|
|
662
|
+
// ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
|
|
494
663
|
let result = null, attemptN = 0, reviewN = 0;
|
|
495
664
|
const lastSubject = resumed ? String(await git(["log", "-1", "--format=%s"]).catch(() => "")).trim() : "";
|
|
496
665
|
const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
666
|
+
const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
|
|
667
|
+
// Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
|
|
668
|
+
const trustedOf = (i) => (i.comments ?? []).filter((c) => {
|
|
669
|
+
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
670
|
+
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
671
|
+
});
|
|
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
|
+
}
|
|
687
|
+
/** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
|
|
688
|
+
async function endIfBlocked() {
|
|
501
689
|
let block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
502
690
|
if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
|
|
503
691
|
T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
|
|
@@ -505,70 +693,303 @@ export async function runCore(o) {
|
|
|
505
693
|
result = await readResult();
|
|
506
694
|
block = result?.status === "blocked" ? await resolveBlock(result) : null;
|
|
507
695
|
}
|
|
508
|
-
|
|
696
|
+
return block ? await endBlocked(block, result.summary) : null;
|
|
697
|
+
}
|
|
698
|
+
/** File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open. */
|
|
699
|
+
async function track(reqs, why) {
|
|
700
|
+
const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
|
|
701
|
+
if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
|
|
702
|
+
const items = followupItems(reqs);
|
|
703
|
+
let filed;
|
|
704
|
+
try { filed = await io.fileFollowups({ parent: issue, repo, items }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
705
|
+
for (const [i, r] of reqs.entries()) {
|
|
706
|
+
const n = Number(filed?.[items[i].key]);
|
|
707
|
+
if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
|
|
708
|
+
let st = null;
|
|
709
|
+
try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
|
|
710
|
+
if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
|
|
711
|
+
r.tracked = `#${n}`;
|
|
712
|
+
}
|
|
713
|
+
T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
|
|
714
|
+
}
|
|
715
|
+
/** The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. */
|
|
716
|
+
async function readFrozenContract() {
|
|
717
|
+
let out = "";
|
|
718
|
+
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; }
|
|
719
|
+
let found = null;
|
|
720
|
+
for (const l of out.split("\n")) {
|
|
721
|
+
let c; try { c = JSON.parse(l); } catch { continue; }
|
|
722
|
+
if (!String(c?.body ?? "").startsWith("<!-- FORGE:CONTRACT -->") || !isTrustedComment(c)) continue;
|
|
723
|
+
const fc = parseContractComment(c.body);
|
|
724
|
+
if (fc && contractHash(fc.text) === fc.hash) found = fc;
|
|
725
|
+
}
|
|
726
|
+
return found;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let frozen = null, hash = null, planned = false;
|
|
730
|
+
if (resumed) {
|
|
731
|
+
const fc = await readFrozenContract();
|
|
732
|
+
if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
|
|
733
|
+
}
|
|
734
|
+
await label("building");
|
|
735
|
+
if (!frozen) {
|
|
736
|
+
await builder(++attemptN, builderPlanPrompt(issue) + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, () => builderPlanPrompt(issue));
|
|
737
|
+
planned = true;
|
|
738
|
+
result = await readResult();
|
|
509
739
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
510
|
-
const
|
|
511
|
-
await flushStages(result, ["investigation"]); //
|
|
512
|
-
T.validate = [result ? (result.status === "fixed" ? "CONFIRMED" :
|
|
513
|
-
|
|
514
|
-
if (
|
|
515
|
-
if (closing) {
|
|
740
|
+
const closes = ["invalid", "already-fixed"].includes(result?.status);
|
|
741
|
+
if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
|
|
742
|
+
T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
|
|
743
|
+
if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
|
|
744
|
+
if (closes) {
|
|
516
745
|
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
746
|
+
T.decisions.push(`Builder verdict: ${result.status}`);
|
|
517
747
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
518
748
|
await label("invalid");
|
|
519
749
|
return await finish("invalid", result.summary ?? result.status);
|
|
520
750
|
}
|
|
521
|
-
|
|
522
|
-
if (
|
|
523
|
-
await
|
|
524
|
-
|
|
751
|
+
const blocked = await endIfBlocked();
|
|
752
|
+
if (blocked) return blocked;
|
|
753
|
+
let contract = await readContract();
|
|
754
|
+
await refreshIssue("contract validation");
|
|
755
|
+
let vc = validateContract(contract, issueText);
|
|
756
|
+
if (!vc.ok && sessionId) {
|
|
757
|
+
T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
|
|
758
|
+
await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
|
|
759
|
+
contract = await readContract();
|
|
760
|
+
await refreshIssue("contract validation");
|
|
761
|
+
vc = validateContract(contract, issueText);
|
|
762
|
+
}
|
|
763
|
+
await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
|
|
764
|
+
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);
|
|
765
|
+
frozen = contract;
|
|
766
|
+
hash = contractHash(contractJson(frozen));
|
|
767
|
+
let body = renderContract(frozen, hash);
|
|
768
|
+
if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
|
|
769
|
+
await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
|
|
770
|
+
const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
|
|
771
|
+
ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
|
|
772
|
+
T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
|
|
525
773
|
} else {
|
|
526
|
-
T.validate = "resumed (existing branch)";
|
|
527
|
-
|
|
774
|
+
T.validate = "resumed (existing branch)";
|
|
775
|
+
}
|
|
776
|
+
T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
|
|
777
|
+
if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
|
|
778
|
+
const implementPrompt = builderImplementPrompt(vcfg.mode) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
|
|
779
|
+
liveKeys = ["implementation"];
|
|
780
|
+
await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
|
|
781
|
+
result = await readResult();
|
|
782
|
+
{
|
|
783
|
+
const blocked = await endIfBlocked();
|
|
784
|
+
if (blocked) return blocked;
|
|
785
|
+
}
|
|
786
|
+
let files = await changedFiles();
|
|
787
|
+
if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
|
|
788
|
+
await flushStages(result, ["implementation"]);
|
|
789
|
+
T.implement = [`${files.length} file(s)`, branch];
|
|
790
|
+
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
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
|
+
}
|
|
528
820
|
}
|
|
821
|
+
const structureLine = structureNote(structure);
|
|
529
822
|
|
|
530
|
-
// ----
|
|
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
|
+
|
|
879
|
+
// ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
|
|
880
|
+
// enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
|
|
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).
|
|
531
883
|
await label("in-review");
|
|
532
|
-
|
|
533
|
-
|
|
884
|
+
const shadow = conformance !== "enforce";
|
|
885
|
+
let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
|
|
886
|
+
async function amend() {
|
|
887
|
+
const a = applyAmendments(frozen, result?.amendments, issueText);
|
|
888
|
+
const sig = JSON.stringify(a.applied);
|
|
889
|
+
if (a.applied.length && sig !== lastAmend) {
|
|
890
|
+
lastAmend = sig;
|
|
891
|
+
await post("FORGE:AMENDMENT", renderAmendments(a.applied));
|
|
892
|
+
T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
|
|
893
|
+
}
|
|
894
|
+
effective = a.contract;
|
|
895
|
+
return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
|
|
896
|
+
}
|
|
897
|
+
const IGNORE = [`${LEAN_DIR}/`];
|
|
898
|
+
/** Up to two reviewer runs; a reply without usable rows (unparseable, incomplete, inconsistent) is re-asked, never charged to the builder. */
|
|
899
|
+
async function reviewOnce() {
|
|
900
|
+
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
901
|
+
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
902
|
+
const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
|
|
903
|
+
const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
|
|
904
|
+
let d = null, retry = null;
|
|
905
|
+
const skeleton = reviewSkeleton({ contract: effective, changedFiles: files, ignore: IGNORE }); // #4160: every required row, pre-filled
|
|
534
906
|
for (let t = 0; t < 2; t++) {
|
|
535
|
-
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
536
|
-
// #4129: an over-budget diff is bounded in the prompt; the stat keeps every changed file visible.
|
|
537
|
-
const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
|
|
538
907
|
await renew();
|
|
539
908
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
540
|
-
prompt: reviewerPrompt({ issue: iss, diff, stat, base }) });
|
|
909
|
+
prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, skeleton, retry }) });
|
|
541
910
|
await renew();
|
|
542
911
|
await attempt("review", ++reviewN, rr);
|
|
543
|
-
|
|
544
|
-
|
|
912
|
+
review = parseReview(rr?.text);
|
|
913
|
+
d = decide(review, ctx);
|
|
914
|
+
ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
|
|
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;
|
|
545
918
|
}
|
|
546
|
-
return
|
|
547
|
-
}
|
|
548
|
-
review
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
552
|
-
T.decisions.push(`Review FAIL (round 1): builder resumed with the reason: ${String(review.reason).slice(0, 200)}`);
|
|
553
|
-
await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
|
|
919
|
+
return d;
|
|
920
|
+
}
|
|
921
|
+
/** Precheck then review, on the current result and changed files (also the base-sync re-review). */
|
|
922
|
+
async function gate() {
|
|
923
|
+
await refreshIssue("conformance");
|
|
554
924
|
result = (await readResult()) ?? result;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
925
|
+
files = await changedFiles();
|
|
926
|
+
const amendProblems = await amend();
|
|
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 });
|
|
929
|
+
const pre = [...pc.failures, ...amendProblems];
|
|
930
|
+
if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
|
|
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 };
|
|
935
|
+
const d = await reviewOnce();
|
|
936
|
+
if (!shadow) return { ...d, by: "review" };
|
|
937
|
+
// shadow: record what enforce would have done; block only on what the previous reviewer blocked on
|
|
938
|
+
const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
|
|
939
|
+
const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
|
|
940
|
+
const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
|
|
941
|
+
return { state: d.state === "review.unparseable" ? d.state : blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
|
|
942
|
+
}
|
|
943
|
+
/** Shape the base-sync path expects: null when no usable review, else {fail, verdict, reason, failures}. */
|
|
944
|
+
const doReview = async () => {
|
|
945
|
+
const d = await gate();
|
|
946
|
+
if (d.state.startsWith("review.")) return null;
|
|
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 };
|
|
948
|
+
};
|
|
949
|
+
for (;;) {
|
|
950
|
+
if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
|
|
951
|
+
const outcome = await gate();
|
|
952
|
+
verdict = outcome;
|
|
953
|
+
if (outcome.state === "conforms") break;
|
|
954
|
+
const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
|
|
955
|
+
if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
956
|
+
if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
|
|
957
|
+
if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
|
|
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);
|
|
959
|
+
resumes++;
|
|
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`);
|
|
961
|
+
await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
|
|
962
|
+
await upsertStage("implementation"); // the published note follows the code after every resume
|
|
963
|
+
}
|
|
964
|
+
// Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
|
|
965
|
+
const toFile = pendingTracking(effective);
|
|
966
|
+
if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
|
|
967
|
+
if (shadow) {
|
|
968
|
+
const wf = verdict.wouldFail ?? [];
|
|
969
|
+
ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
970
|
+
await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
|
|
971
|
+
T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
972
|
+
await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
|
|
973
|
+
} else {
|
|
974
|
+
T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
|
|
975
|
+
await post("FORGE:REVIEW", renderReview(review, effective));
|
|
558
976
|
}
|
|
559
|
-
T.review = [review.verdict, `${reviewN} reviewer run(s), ${attemptN > 1 ? "2 rounds" : "1 round"}`];
|
|
560
|
-
await post("FORGE:REVIEW", renderReview(review));
|
|
561
977
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
978
|
+
const closing = closePlan(shadow ? { requirements: [] } : effective);
|
|
562
979
|
|
|
563
980
|
// ---- 5. push + PR ----
|
|
564
|
-
|
|
981
|
+
files = await changedFiles();
|
|
565
982
|
await gitPush(["push", "-u", "origin", branch]);
|
|
566
983
|
laneGuard(lane, "open a PR");
|
|
567
984
|
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
568
|
-
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]),
|
|
985
|
+
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
|
|
569
986
|
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
570
987
|
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
571
988
|
ev({ event: "PR_OPENED", pr });
|
|
989
|
+
if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
|
|
990
|
+
try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
|
|
991
|
+
catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
|
|
992
|
+
}
|
|
572
993
|
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
573
994
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
574
995
|
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
@@ -598,6 +1019,13 @@ export async function runCore(o) {
|
|
|
598
1019
|
}
|
|
599
1020
|
return "unknown";
|
|
600
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;
|
|
601
1029
|
const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
602
1030
|
const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
|
|
603
1031
|
|| (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
|
|
@@ -617,7 +1045,7 @@ export async function runCore(o) {
|
|
|
617
1045
|
how = "clean merge of the base into the branch";
|
|
618
1046
|
} else {
|
|
619
1047
|
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
620
|
-
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.`;
|
|
621
1049
|
await builder(++attemptN, prompt, sessionId ?? null);
|
|
622
1050
|
if (await unresolved()) {
|
|
623
1051
|
await gitOut(["merge", "--abort"]);
|
|
@@ -630,11 +1058,13 @@ export async function runCore(o) {
|
|
|
630
1058
|
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
631
1059
|
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
632
1060
|
if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
633
|
-
|
|
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) {
|
|
634
1064
|
let rv = await doReview();
|
|
635
1065
|
if (rv?.fail) {
|
|
636
1066
|
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
637
|
-
await builder(++attemptN,
|
|
1067
|
+
await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
|
|
638
1068
|
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
639
1069
|
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
|
|
640
1070
|
rv = await doReview();
|
|
@@ -650,6 +1080,19 @@ export async function runCore(o) {
|
|
|
650
1080
|
return null;
|
|
651
1081
|
}
|
|
652
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
|
+
}
|
|
653
1096
|
|
|
654
1097
|
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
655
1098
|
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
@@ -704,11 +1147,31 @@ export async function runCore(o) {
|
|
|
704
1147
|
await sleepFn(Math.min(60000, 1000 * 2 ** (mergeRetries - 1)) + Math.floor(random() * 250));
|
|
705
1148
|
continue;
|
|
706
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
|
+
}
|
|
707
1166
|
if (!MERGE_CONFLICT_RE.test(msg)) throw e;
|
|
708
1167
|
const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
|
|
709
1168
|
}
|
|
710
1169
|
}
|
|
1170
|
+
await deleteMergedBranch();
|
|
711
1171
|
await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
1172
|
+
if (closing.tracked.length) {
|
|
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 */ }
|
|
1174
|
+
}
|
|
712
1175
|
await label("merged");
|
|
713
1176
|
return await finish("merged", `PR #${pr}`, { pr });
|
|
714
1177
|
} catch (e) {
|