forgedock 1.23.1 → 1.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/forgedock.mjs +10 -2
- package/bin/lean/effects.mjs +55 -8
- package/bin/lean/lean.mjs +286 -57
- package/bin/lean/lean.test.mjs +492 -84
- package/bin/lean/prompts.mjs +42 -7
- package/bin/lean/render.mjs +67 -28
- package/bin/runner.mjs +55 -1
- package/commands/orchestrate.md +12 -4
- package/commands/review-pr.md +1 -1
- package/docs/RELEASE-NOTES.md +6 -0
- package/package.json +1 -1
- package/scripts/review-finalize.sh +10 -7
- package/scripts/review-finalize.test.sh +6 -4
package/bin/lean/lean.mjs
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
|
|
6
6
|
* severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
|
|
7
7
|
*/
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { join, dirname, basename } from "node:path";
|
|
9
|
+
import { hostname as osHostname } from "node:os";
|
|
9
10
|
import { appendEvent } from "../engine/runlog.mjs";
|
|
10
11
|
import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
11
|
-
import { ciPollDelayMs, ENGINE_VERSION } from "../engine.mjs";
|
|
12
|
+
import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
|
|
12
13
|
import { openDependency } from "../engine/phases.mjs";
|
|
13
|
-
import {
|
|
14
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
|
|
14
|
+
import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
|
|
15
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
17
|
-
export const REGISTER_MARKER = "FORGE:WORK-ON";
|
|
18
|
+
export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
|
|
18
19
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -31,7 +32,7 @@ const num = (v) => (Number.isFinite(v) ? v : null);
|
|
|
31
32
|
*/
|
|
32
33
|
export async function runCore(o) {
|
|
33
34
|
const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
34
|
-
ciMaxMinutes = 360, mergeWait = "required", onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
35
|
+
ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
35
36
|
const now = io.now ?? (() => Date.now());
|
|
36
37
|
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
37
38
|
const base = `origin/${lane}`;
|
|
@@ -56,7 +57,8 @@ export async function runCore(o) {
|
|
|
56
57
|
usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
57
58
|
}
|
|
58
59
|
const post = async (marker, body) => {
|
|
59
|
-
const
|
|
60
|
+
const marks = (Array.isArray(marker) ? marker : [marker]).map((m) => `<!-- ${m} -->`).join("\n");
|
|
61
|
+
const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
|
|
60
62
|
return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
61
63
|
};
|
|
62
64
|
const label = async (state) => { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } };
|
|
@@ -64,10 +66,19 @@ export async function runCore(o) {
|
|
|
64
66
|
|
|
65
67
|
// ---- lease (same FORGE:STATE lease the full path uses) ----
|
|
66
68
|
const agentId = o.agentId ?? `core_${process.pid}`;
|
|
69
|
+
const myHost = o.hostName ?? osHostname();
|
|
70
|
+
const leaseObj = () => ({ by: agentId, until: now() + leaseTtlMs, host: myHost, pid: o.pid ?? process.pid });
|
|
71
|
+
/** Provably dead: same host and `kill(pid, 0)` throws ESRCH. A different host keeps the TTL behaviour. */
|
|
72
|
+
const holderDead = (l) => {
|
|
73
|
+
const parsed = parseLeaseHolder(l?.by);
|
|
74
|
+
const h = l?.host ?? parsed?.host, pid = l?.pid ?? parsed?.pid;
|
|
75
|
+
if (!h || !Number.isSafeInteger(pid) || pid <= 0 || sanitizeLeaseHost(h) !== sanitizeLeaseHost(myHost)) return false;
|
|
76
|
+
try { (o.killFn ?? process.kill.bind(process))(pid, 0); return false; } catch (e) { return e?.code === "ESRCH"; }
|
|
77
|
+
};
|
|
67
78
|
const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
|
|
68
79
|
let leaseState = null, leaseTimer = null;
|
|
69
80
|
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
70
|
-
const renew = async () => { if (io.projector && leaseState) { try { await writeLease(
|
|
81
|
+
const renew = async () => { if (io.projector && leaseState) { try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
|
|
71
82
|
async function releaseLease(reason) {
|
|
72
83
|
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
73
84
|
if (!io.projector || !leaseState) return;
|
|
@@ -75,7 +86,11 @@ export async function runCore(o) {
|
|
|
75
86
|
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
// Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
|
|
90
|
+
const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
|
|
78
91
|
let done = null, wtCreated = false;
|
|
92
|
+
const runId = `${issue}-${t0}`;
|
|
93
|
+
let runTmp = null;
|
|
79
94
|
async function finish(reason, detail = "", extra = {}, codeOverride = null) {
|
|
80
95
|
if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
|
|
81
96
|
const code = codeOverride ?? codeForTerminalReason(reason);
|
|
@@ -85,7 +100,14 @@ export async function runCore(o) {
|
|
|
85
100
|
const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
86
101
|
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
|
|
87
102
|
}
|
|
103
|
+
if (["merged", "invalid", "needs-human"].includes(reason)) {
|
|
104
|
+
if (reason === "merged") T.close = ["issue closed (completed)", "label workflow:merged"];
|
|
105
|
+
else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
|
|
106
|
+
else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
|
|
107
|
+
try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
|
|
108
|
+
}
|
|
88
109
|
await releaseLease(reason);
|
|
110
|
+
if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
|
|
89
111
|
if (wtCreated) {
|
|
90
112
|
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
|
|
91
113
|
if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
|
|
@@ -101,10 +123,89 @@ export async function runCore(o) {
|
|
|
101
123
|
return finish("needs-human", why, { code }, code);
|
|
102
124
|
}
|
|
103
125
|
|
|
104
|
-
|
|
126
|
+
// ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
|
|
127
|
+
const STAGES = [
|
|
128
|
+
{ key: "investigation", file: INVESTIGATION_FILE, marker: ["FORGE:INVESTIGATION", "FORGE:VALIDATE"], render: (md, ctx) => renderInvestigation(md, ctx.result) },
|
|
129
|
+
{ key: "plan", file: PLAN_FILE, marker: ["FORGE:PLAN"], render: (md, ctx) => renderPlan(md, ctx.result, ctx.files) },
|
|
130
|
+
{ key: "implementation", file: IMPLEMENTATION_FILE, marker: ["FORGE:IMPLEMENTATION"], render: (md, ctx) => renderImplementation(md, ctx.result, { branch, files: ctx.files }) },
|
|
131
|
+
];
|
|
132
|
+
const posted = {}; // key -> comment url (or "" when posted without a link)
|
|
133
|
+
const readStage = async (st) => { try { return await io.fs.read(`${wt}/${st.file}`); } catch { return null; } };
|
|
134
|
+
const usableMd = (md) => sanitizeMd(md).replace(/\s+/g, " ").length >= 20;
|
|
135
|
+
const stageFiles = async () => {
|
|
136
|
+
const out = String(await git(["diff", "--name-only", `${base}...HEAD`]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
137
|
+
if (out.length) return out;
|
|
138
|
+
return String(await git(["diff", "--name-only", "HEAD"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
139
|
+
};
|
|
140
|
+
async function postStage(st, result, md) {
|
|
141
|
+
const files = st.key === "implementation" ? await stageFiles() : [];
|
|
142
|
+
const url = await post(st.marker, st.render(md, { result, files }));
|
|
143
|
+
posted[st.key] = url ?? "";
|
|
144
|
+
}
|
|
145
|
+
/** Post stage notes in order that exist, are stable (mtime unchanged for stableMs) and not yet posted. */
|
|
146
|
+
const seen = {};
|
|
147
|
+
async function livePoll() {
|
|
148
|
+
for (const st of STAGES) {
|
|
149
|
+
if (st.key in posted) continue;
|
|
150
|
+
let m = null;
|
|
151
|
+
try { m = await io.fs.stat(`${wt}/${st.file}`); } catch { m = null; }
|
|
152
|
+
if (!m) return;
|
|
153
|
+
const e = seen[st.key];
|
|
154
|
+
if (!e || e.mtime !== m.mtimeMs) { seen[st.key] = { mtime: m.mtimeMs, since: Date.now() }; return; }
|
|
155
|
+
if (Date.now() - e.since < (o.stableMs ?? 2000)) return;
|
|
156
|
+
const md = await readStage(st);
|
|
157
|
+
if (!usableMd(md)) return;
|
|
158
|
+
try { await postStage(st, null, md); } catch { return; }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Post whatever was not posted live, in order; a missing file falls back to the result.json fields. */
|
|
162
|
+
async function flushStages(result, keys) {
|
|
163
|
+
for (const st of STAGES) {
|
|
164
|
+
if (!keys.includes(st.key) || st.key in posted) continue;
|
|
165
|
+
const md = await readStage(st);
|
|
166
|
+
if (!usableMd(md)) {
|
|
167
|
+
if (!result && st.key === "investigation") continue; // nothing to render
|
|
168
|
+
T.anomalies.push(`${st.file} missing or empty: rendered from result.json`);
|
|
169
|
+
}
|
|
170
|
+
await postStage(st, result, md);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Runner call with the per-run TMPDIR; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
|
|
175
|
+
async function callRunner(args) {
|
|
176
|
+
let pauses = 0;
|
|
177
|
+
for (;;) {
|
|
178
|
+
try { return await io.runner({ ...args, ...(runTmp ? { env: { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } } : {}) }); }
|
|
179
|
+
catch (e) {
|
|
180
|
+
const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
|
|
181
|
+
if (!(e?.usageLimit === true || epoch !== null)) throw e;
|
|
182
|
+
const waitMs = epoch !== null && epoch > now() ? epoch - now() : DEFAULT_SESSION_LIMIT_WAIT_MS;
|
|
183
|
+
if (pauses >= maxSessionLimitPauses || waitMs > sessionLimitMaxWaitMs) { e.deferUsageLimit = true; throw e; }
|
|
184
|
+
pauses++;
|
|
185
|
+
ev({ event: "PHASE_RATE_LIMITED", phase: args.role, resetAt: epoch, resetAtDisplay: e.resetAt ?? null, waitMs });
|
|
186
|
+
onProgress({ event: "phase_paused", phase: args.role, reason: "session-limit", detail: `session limit hit; pausing until ${e.resetAt || new Date(now() + waitMs).toISOString()}` });
|
|
187
|
+
await sleep(waitMs);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function builder(n, prompt, resume, live = false) {
|
|
105
193
|
onProgress({ event: "phase_enter", phase: "build" });
|
|
106
194
|
await renew();
|
|
107
|
-
const
|
|
195
|
+
const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt }));
|
|
196
|
+
let r;
|
|
197
|
+
if (live && io.fs.stat) {
|
|
198
|
+
let settled = false;
|
|
199
|
+
const tracked = call.then((v) => { settled = true; return v; }, (e) => { settled = true; throw e; });
|
|
200
|
+
tracked.catch(() => {});
|
|
201
|
+
while (!settled) {
|
|
202
|
+
let t;
|
|
203
|
+
await Promise.race([tracked.catch(() => {}), new Promise((res) => { t = setTimeout(res, o.livePollMs ?? 1000); })]);
|
|
204
|
+
clearTimeout(t);
|
|
205
|
+
if (!settled) { try { await livePoll(); } catch { /* live posting is best effort */ } }
|
|
206
|
+
}
|
|
207
|
+
r = await tracked;
|
|
208
|
+
} else r = await call;
|
|
108
209
|
await renew();
|
|
109
210
|
if (r?.sessionId) sessionId = r.sessionId;
|
|
110
211
|
await attempt("implement", n, r);
|
|
@@ -126,15 +227,22 @@ export async function runCore(o) {
|
|
|
126
227
|
if (io.projector) {
|
|
127
228
|
let st = null;
|
|
128
229
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
129
|
-
if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
|
|
230
|
+
if (st?.lease && st.lease.until > now() && st.lease.by !== agentId && holderDead(st.lease)) {
|
|
231
|
+
ev({ event: "LEASE_TAKEOVER", from: st.lease.by, host: st.lease.host ?? null, pid: st.lease.pid ?? null });
|
|
232
|
+
onProgress({ event: "lease_takeover", detail: `taking over the lease of dead holder ${st.lease.by} (pid ${st.lease.pid ?? "?"} gone)` });
|
|
233
|
+
} else if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
|
|
130
234
|
return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
|
|
131
235
|
}
|
|
132
236
|
leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false };
|
|
133
|
-
try { await writeLease(
|
|
134
|
-
leaseTimer = setInterval(() => { writeLease(
|
|
237
|
+
try { await writeLease(leaseObj()); } catch { /* best effort */ }
|
|
238
|
+
leaseTimer = setInterval(() => { writeLease(leaseObj()).catch(() => {}); }, o.leaseRenewMs ?? 120000);
|
|
135
239
|
leaseTimer.unref?.();
|
|
136
240
|
}
|
|
137
241
|
|
|
242
|
+
if (io.runTmp) {
|
|
243
|
+
try { await io.runTmp.prune?.(); } catch { /* best effort */ }
|
|
244
|
+
try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
|
|
245
|
+
}
|
|
138
246
|
ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
139
247
|
let iss;
|
|
140
248
|
try {
|
|
@@ -157,11 +265,36 @@ export async function runCore(o) {
|
|
|
157
265
|
} catch { /* no resume info: start fresh */ }
|
|
158
266
|
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
|
|
159
267
|
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
268
|
+
// Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
|
|
269
|
+
// A leftover dir that git no longer lists as a worktree blocks `worktree add`. Remove ONLY this issue's lean dir under the lean root.
|
|
270
|
+
const wtRoot = o.worktreeRoot ?? join(cwd, ".claude", "worktrees");
|
|
271
|
+
const leftoverDir = async () => {
|
|
272
|
+
if (!io.fs.exists || !io.fs.rmdir || dirname(wt) !== wtRoot || basename(wt) !== `lean-${issue}`) return false;
|
|
273
|
+
if (!(await io.fs.exists(wt))) return false;
|
|
274
|
+
const listed = String(await io.git(["worktree", "list", "--porcelain"], { cwd }).catch(() => "")).split("\n").some((l) => l === `worktree ${wt}`);
|
|
275
|
+
if (listed) return false;
|
|
276
|
+
await io.fs.rmdir(wt);
|
|
277
|
+
ev({ event: "WORKTREE_LEFTOVER_REMOVED", path: wt });
|
|
278
|
+
return true;
|
|
279
|
+
};
|
|
280
|
+
await leftoverDir();
|
|
281
|
+
const LOCK_RE = /could not lock|cannot lock ref|\.lock'?: File exists|Unable to create .*\.lock|index\.lock|packed-refs\.lock/i;
|
|
282
|
+
const addWorktree = async (args) => {
|
|
283
|
+
let cleaned = false;
|
|
284
|
+
for (let i = 0; ; i++) {
|
|
285
|
+
try { return await io.git(args, { cwd }); } catch (e) {
|
|
286
|
+
const msg = String(e?.stderr ?? e?.message ?? e);
|
|
287
|
+
if (!cleaned && /already exists/i.test(msg) && (await leftoverDir())) { cleaned = true; continue; }
|
|
288
|
+
if (i >= 4 || !LOCK_RE.test(msg)) throw e;
|
|
289
|
+
await sleep(500 * (i + 1) + Math.floor(random() * 500));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
160
293
|
if (resumed) {
|
|
161
294
|
await io.git(["fetch", "origin", branch], { cwd });
|
|
162
|
-
await
|
|
295
|
+
await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
|
|
163
296
|
} else {
|
|
164
|
-
await
|
|
297
|
+
await addWorktree(["worktree", "add", "-B", branch, wt, base]);
|
|
165
298
|
}
|
|
166
299
|
wtCreated = true;
|
|
167
300
|
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
@@ -169,36 +302,41 @@ export async function runCore(o) {
|
|
|
169
302
|
const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
170
303
|
|
|
171
304
|
// ---- 3. builder ----
|
|
172
|
-
let result = null, attemptN = 0,
|
|
305
|
+
let result = null, attemptN = 0, reviewN = 0;
|
|
173
306
|
if (!(resumed && (await changedFiles()).length > 0)) {
|
|
174
307
|
await label("building");
|
|
175
|
-
await builder(++attemptN, builderPrompt(issue), null);
|
|
308
|
+
await builder(++attemptN, builderPrompt(issue), null, true);
|
|
176
309
|
result = await readResult();
|
|
177
310
|
const files0 = await changedFiles();
|
|
178
311
|
const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
179
|
-
const verdict = result ? (result.status === "fixed" ? "valid" : result.status) : null;
|
|
180
312
|
const closing = ["invalid", "already-fixed"].includes(result?.status);
|
|
313
|
+
await flushStages(result, ["investigation"]); // first stage note: the evidence comment for invalid/already-fixed
|
|
314
|
+
T.validate = [result ? (result.status === "fixed" ? "CONFIRMED" : String(result.status).toUpperCase()) : "no result.json", `${evidence.length} evidence item(s)`];
|
|
315
|
+
T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
|
|
181
316
|
if (closing && !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);
|
|
182
|
-
// The validation comment (no model call): verdict, evidence, one-line summary. For invalid/already-fixed it IS the evidence comment.
|
|
183
|
-
if (verdict) validateUrl = await post("FORGE:VALIDATE", renderValidate(result, verdict));
|
|
184
317
|
if (closing) {
|
|
318
|
+
T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
|
|
185
319
|
await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
186
320
|
await label("invalid");
|
|
187
321
|
return await finish("invalid", result.summary ?? result.status);
|
|
188
322
|
}
|
|
189
323
|
if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_BLOCKED);
|
|
190
324
|
if (!files0.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);
|
|
191
|
-
|
|
325
|
+
await flushStages(result, ["plan", "implementation"]);
|
|
326
|
+
T.plan = "posted"; T.implement = [`${files0.length} file(s)`, branch];
|
|
327
|
+
} else {
|
|
328
|
+
T.validate = "resumed (existing branch)"; T.plan = "skipped (resumed)"; T.implement = ["existing commits", branch];
|
|
329
|
+
T.decisions.push("Open PR or remote branch found: builder skipped, reviewing existing commits");
|
|
192
330
|
}
|
|
193
331
|
|
|
194
332
|
// ---- 4. review (one retry through the same builder session) ----
|
|
195
333
|
await label("in-review");
|
|
196
|
-
let review = null
|
|
334
|
+
let review = null;
|
|
197
335
|
const doReview = async () => {
|
|
198
336
|
for (let t = 0; t < 2; t++) {
|
|
199
337
|
const diff = String(await git(["diff", `${base}...HEAD`]));
|
|
200
338
|
await renew();
|
|
201
|
-
const rr = await
|
|
339
|
+
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
202
340
|
prompt: reviewerPrompt({ issue: iss, diff }) });
|
|
203
341
|
await renew();
|
|
204
342
|
await attempt("review", ++reviewN, rr);
|
|
@@ -211,72 +349,163 @@ export async function runCore(o) {
|
|
|
211
349
|
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
212
350
|
if (review.fail) {
|
|
213
351
|
if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
352
|
+
T.decisions.push(`Review FAIL (round 1): builder resumed with the reason: ${String(review.reason).slice(0, 200)}`);
|
|
214
353
|
await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
|
|
215
354
|
result = (await readResult()) ?? result;
|
|
216
355
|
review = await doReview();
|
|
217
356
|
if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
218
357
|
if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
219
358
|
}
|
|
220
|
-
|
|
359
|
+
T.review = [review.verdict, `${reviewN} reviewer run(s), ${attemptN > 1 ? "2 rounds" : "1 round"}`];
|
|
360
|
+
await post("FORGE:REVIEW", renderReview(review));
|
|
221
361
|
ev({ event: "PHASE_COMMIT", phase: "review" });
|
|
222
362
|
|
|
223
363
|
// ---- 5. push + PR ----
|
|
224
364
|
const files = await changedFiles();
|
|
225
365
|
await git(["push", "-u", "origin", branch]);
|
|
226
366
|
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghc(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
227
|
-
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, result, gitFiles: files })])).trim();
|
|
367
|
+
"--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), plan: await readStage(STAGES[1]), implementation: await readStage(STAGES[2]), result, gitFiles: files })])).trim();
|
|
228
368
|
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
229
369
|
if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
|
|
230
370
|
ev({ event: "PR_OPENED", pr });
|
|
371
|
+
T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
|
|
231
372
|
const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
|
|
232
|
-
|
|
373
|
+
let localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
233
374
|
|
|
234
|
-
// ---- 6.
|
|
235
|
-
// merge policy: required (default) | all | none
|
|
375
|
+
// ---- 6. merge policy: required (default) | all | none ----
|
|
236
376
|
let policy = mergeWait, required = [];
|
|
237
377
|
if (mergeWait === "required") {
|
|
238
378
|
try { required = (await io.requiredChecks?.(lane)) ?? []; } catch { required = []; }
|
|
239
379
|
}
|
|
240
380
|
const policyText = mergeWait === "required" ? (required.length ? `required (${required.join(", ")})` : "required (no required checks: merged right after review)") : mergeWait;
|
|
241
|
-
|
|
381
|
+
T.merge = [policyText, "waiting"];
|
|
382
|
+
T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
|
|
242
383
|
|
|
243
|
-
// ----
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
for (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
384
|
+
// ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
|
|
385
|
+
let syncs = 0;
|
|
386
|
+
const MAX_SYNCS = 2;
|
|
387
|
+
const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
|
|
388
|
+
/** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
|
|
389
|
+
const prConflicting = async () => {
|
|
390
|
+
for (let i = 0; i < 2; i++) {
|
|
391
|
+
let j;
|
|
392
|
+
try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
|
|
393
|
+
const m = String(j.mergeable ?? "").toUpperCase(), st = String(j.mergeStateStatus ?? "").toUpperCase();
|
|
394
|
+
if (m === "CONFLICTING" || st === "DIRTY") return "conflict";
|
|
395
|
+
if (m !== "UNKNOWN") return false;
|
|
396
|
+
if (i === 0) await sleep(2000);
|
|
397
|
+
}
|
|
398
|
+
return "unknown";
|
|
399
|
+
};
|
|
400
|
+
const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
|
|
401
|
+
const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
|
|
402
|
+
|| (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
|
|
403
|
+
const syncRow = [];
|
|
404
|
+
/** Returns null to continue with the merge flow, or a terminal result. */
|
|
405
|
+
async function baseSync(trigger, soft = false) {
|
|
406
|
+
if (syncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
407
|
+
syncs++;
|
|
408
|
+
await renew();
|
|
409
|
+
try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
410
|
+
const baseSha = (await gitOut(["rev-parse", base])).slice(0, 12);
|
|
411
|
+
const before = await gitOut(["rev-parse", "HEAD"]);
|
|
412
|
+
let clean = true, files = [];
|
|
413
|
+
try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
|
|
414
|
+
let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
|
|
415
|
+
if (clean) {
|
|
416
|
+
how = "clean merge of the base into the branch";
|
|
417
|
+
} else {
|
|
418
|
+
T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
|
|
419
|
+
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, run the relevant tests, and commit the merge. Do not push.`;
|
|
420
|
+
await builder(++attemptN, prompt, sessionId ?? null);
|
|
421
|
+
if (await unresolved()) {
|
|
422
|
+
await gitOut(["merge", "--abort"]);
|
|
423
|
+
return await needsHuman(`Base-sync could not resolve the conflicts in ${files.join(", ")} (conflict markers or an unfinished merge remain).`, OUTCOME_CODES.BASE_CONFLICT);
|
|
262
424
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
425
|
+
how = `builder resolved ${files.length} conflicted file(s) and committed the merge`;
|
|
426
|
+
const r2 = await readResult();
|
|
427
|
+
tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
|
|
428
|
+
}
|
|
429
|
+
const after = await gitOut(["rev-parse", "HEAD"]);
|
|
430
|
+
if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
|
|
431
|
+
if (after !== before) await git(["push", "origin", branch]); // plain push of a merge commit: never force
|
|
432
|
+
if (!clean) {
|
|
433
|
+
let rv = await doReview();
|
|
434
|
+
if (rv?.fail) {
|
|
435
|
+
T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
|
|
436
|
+
await builder(++attemptN, resumeBuilderPrompt(rv.reason), sessionId ?? null);
|
|
437
|
+
if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
|
|
438
|
+
if ((await gitOut(["rev-parse", "HEAD"])) !== after) await git(["push", "origin", branch]);
|
|
439
|
+
rv = await doReview();
|
|
440
|
+
}
|
|
441
|
+
if (!rv) return await needsHuman("Reviewer returned no parseable verdict after base-sync.", OUTCOME_CODES.REVIEW_NO_VERDICT);
|
|
442
|
+
if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
|
|
443
|
+
rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
|
|
444
|
+
}
|
|
445
|
+
localHead = await gitOut(["rev-parse", "HEAD"]);
|
|
446
|
+
syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
|
|
447
|
+
T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
|
|
448
|
+
await post("FORGE:BASE_SYNC", `## Base Sync\n**Trigger:** ${trigger}\n**Base merged:** \`${base}\` at \`${baseSha}\`\n**Result:** ${clean ? "clean merge" : `conflicts in ${files.map((f) => `\`${f}\``).join(", ") || "unknown files"}`}\n**Resolution:** ${how}\n**Tests run:** ${tests}\n**Re-review:** ${rereview}\n**New head:** \`${localHead.slice(0, 12)}\``);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
const NR_TESTS = "not reported";
|
|
452
|
+
|
|
453
|
+
// ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
|
|
454
|
+
// A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
|
|
455
|
+
for (;;) {
|
|
456
|
+
const c0 = await prConflicting();
|
|
457
|
+
if (c0) { const r = await baseSync(c0 === "unknown" ? "PR mergeability unknown" : "PR is conflicting", c0 === "unknown"); if (r) return r; }
|
|
458
|
+
if (policy === "none" || (policy === "required" && !required.length)) {
|
|
459
|
+
// merge right after review
|
|
460
|
+
T.merge = [policyText, "merged right after review"];
|
|
461
|
+
} else {
|
|
462
|
+
const startedAt = now();
|
|
463
|
+
let pollIndex = 0, gateHead = null;
|
|
464
|
+
for (;;) {
|
|
465
|
+
await renew();
|
|
466
|
+
if (policy === "all") {
|
|
467
|
+
const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
|
|
468
|
+
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
469
|
+
if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
|
|
470
|
+
} else {
|
|
471
|
+
let states = [];
|
|
472
|
+
try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
|
|
473
|
+
const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
|
|
474
|
+
const failed = required.filter((n) => st(n) === "failure");
|
|
475
|
+
if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
|
|
476
|
+
if (required.every((n) => st(n) === "success")) break;
|
|
477
|
+
}
|
|
478
|
+
if (now() - startedAt >= ciMaxMinutes * 60000) {
|
|
479
|
+
// Slow CI is never a needs-human: leave the PR open and resumable.
|
|
480
|
+
ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
|
|
481
|
+
T.anomalies.push("CI still pending at the wait budget");
|
|
482
|
+
return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
|
|
483
|
+
}
|
|
484
|
+
onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
|
|
485
|
+
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
486
|
+
}
|
|
487
|
+
if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
|
|
488
|
+
return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`, OUTCOME_CODES.MERGE_HEAD_MISMATCH);
|
|
267
489
|
}
|
|
268
|
-
onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
|
|
269
|
-
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
270
490
|
}
|
|
271
|
-
if (
|
|
272
|
-
|
|
491
|
+
if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
|
|
492
|
+
T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
|
|
493
|
+
try {
|
|
494
|
+
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
|
495
|
+
break;
|
|
496
|
+
} catch (e) {
|
|
497
|
+
if (!/not mergeable|merge conflict|conflict/i.test(String(e?.stderr ?? "") + String(e?.message ?? ""))) throw e;
|
|
498
|
+
const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
|
|
273
499
|
}
|
|
274
500
|
}
|
|
275
|
-
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
|
276
501
|
await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
277
502
|
await label("merged");
|
|
278
503
|
return await finish("merged", `PR #${pr}`, { pr });
|
|
279
504
|
} catch (e) {
|
|
505
|
+
if (e?.deferUsageLimit) {
|
|
506
|
+
const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
|
|
507
|
+
return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null }, OUTCOME_CODES.RATE_LIMITED);
|
|
508
|
+
}
|
|
280
509
|
return finish("engine-error", e?.message ?? String(e));
|
|
281
510
|
}
|
|
282
511
|
}
|