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.test.mjs
CHANGED
|
@@ -4,14 +4,20 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
6
6
|
import { runCore } from "./lean.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
8
8
|
import { orchestrate, parseDeps, formatTable } from "./orchestrate.mjs";
|
|
9
9
|
import { REVIEWER_SYSTEM, approxTokens, builderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
|
|
10
10
|
import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
11
11
|
import { readLog } from "../engine/runlog.mjs";
|
|
12
|
+
import { makeRunTmp } from "./effects.mjs";
|
|
12
13
|
import { runFromCli, orchestrateFromCli, ENGINE_PATHS } from "../engine-cli.mjs";
|
|
13
14
|
|
|
14
15
|
const WT = "/wt/lean-7";
|
|
16
|
+
const STAGE_MD = {
|
|
17
|
+
investigation: "**Verdict**: CONFIRMED\n**Confidence**: high\n**Severity**: medium\n**Task Type**: bug\n\n### What Was Claimed\nfoo breaks\n\n### What We Found\n- src/foo.js:3 derefs null\n\n### Root Cause\nfoo derefs null at src/foo.js:3\n\n### Affected Files\n- src/foo.js: add guard\n\n### Sibling Sweep\n- src/bar.js:8 same guard\n\n### History\nintroduced in abc123\n\n### Recommendation\nguard null\n\n### Related Issues\nnone",
|
|
18
|
+
plan: "### Approach\nguard null in foo\n\n### Deliverables\n| File | Change | Why |\n| --- | --- | --- |\n| src/foo.js | add guard | avoid crash |\n\n### Acceptance Criteria\n- [ ] foo handles null\n\n### Implementation Order\n1. guard\n\n### Out of Scope\nrefactor: later\n\n### Risk Assessment\n| Risk | Severity | Mitigation |\n| --- | --- | --- |\n| none | low | test |",
|
|
19
|
+
implementation: "### Approach\nadded a guard\n\n### Changes\n- src/foo.js: guard\n\n### Acceptance Criteria Status\n- ✅ foo handles null\n\n### Verification\n- node --test test/foo.test.js: pass, fails without fix: yes\n\n### Notes\nnone",
|
|
20
|
+
};
|
|
15
21
|
const ok = (extra = {}) => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 fixed", ...extra }) + "\n```";
|
|
16
22
|
const bad = (reason = "src/foo.js:9 still wrong") => ok({ verdict: "FAIL", fixes_issue: false, reason });
|
|
17
23
|
|
|
@@ -21,8 +27,12 @@ function world(o = {}) {
|
|
|
21
27
|
comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, head: "aaaa1111aaaa1111",
|
|
22
28
|
changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
|
|
23
29
|
result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
|
|
24
|
-
prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [],
|
|
30
|
+
prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [], mtimes: {}, envs: [], prView: [...(o.prView ?? [])], mergeResults: [...(o.mergeResults ?? [])], mergeErrors: [...(o.mergeErrors ?? [])], conflictFiles: [], mergeInProgress: false, markers: false, mergeAttempts: 0,
|
|
31
|
+
stages: { ...STAGE_MD, ...(o.stages ?? {}) },
|
|
25
32
|
};
|
|
33
|
+
const put = (name, text) => { const p = `${WT}/.forge-lean/${name}`; if (w.files[p] !== text) w.mtimes[p] = Date.now(); w.files[p] = text; };
|
|
34
|
+
const writeStages = (keys) => { for (const k of keys) if (w.stages[k] != null) put(`${k}.md`, w.stages[k]); };
|
|
35
|
+
w.writeStages = writeStages;
|
|
26
36
|
w.io = {
|
|
27
37
|
now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
|
|
28
38
|
gh: async (a) => {
|
|
@@ -34,12 +44,28 @@ function world(o = {}) {
|
|
|
34
44
|
if (k === "issue close") { w.closed.push(a); w.seq.push("close"); return ""; }
|
|
35
45
|
if (k === "issue edit") { w.edits.push(a); return ""; }
|
|
36
46
|
if (k === "pr create") { w.prs.push(a); w.seq.push("pr"); return "https://github.com/o/r/pull/77\n"; }
|
|
47
|
+
if (k === "pr view" && a.includes("mergeable,mergeStateStatus")) { const v = w.prView.length > 1 ? w.prView.shift() : (w.prView[0] ?? { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" }); return JSON.stringify(v); }
|
|
48
|
+
if (k === "pr merge" && w.mergeErrors.length) { w.mergeAttempts++; throw new Error(w.mergeErrors.shift()); }
|
|
49
|
+
if (k === "pr merge") { w.mergeAttempts++; }
|
|
37
50
|
if (k === "pr merge") { w.merges.push(a); w.seq.push("merge"); return ""; }
|
|
38
51
|
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
39
52
|
},
|
|
40
53
|
git: async (a) => {
|
|
41
54
|
w.calls.push(["git", ...a]);
|
|
55
|
+
if (a[0] === "worktree" && a[1] === "add" && (w.lockFails ?? 0) > 0) {
|
|
56
|
+
w.lockFails--; const e = new Error("git failed"); e.stderr = "error: could not lock config file .git/config: File exists"; throw e;
|
|
57
|
+
}
|
|
42
58
|
if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
|
|
59
|
+
if (a[0] === "merge" && a[1] === "--no-edit") {
|
|
60
|
+
const r = w.mergeResults.shift() ?? "clean";
|
|
61
|
+
if (r === "clean") { w.head = `cccc${w.calls.length}`.padEnd(16, "0"); return ""; }
|
|
62
|
+
w.conflictFiles = o.conflictFiles ?? ["src/foo.js"]; w.mergeInProgress = true; throw new Error("CONFLICT (content): Merge conflict in src/foo.js");
|
|
63
|
+
}
|
|
64
|
+
if (a[0] === "rev-parse" && a.includes("MERGE_HEAD")) return w.mergeInProgress ? "deadbeef" : "";
|
|
65
|
+
if (a[0] === "diff" && a.includes("--diff-filter=U")) return w.conflictFiles.join("\n");
|
|
66
|
+
if (a[0] === "diff" && a[1] === "--check") return "";
|
|
67
|
+
if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
|
|
68
|
+
if (a[0] === "merge" && a[1] === "--abort") { w.mergeInProgress = false; return ""; }
|
|
43
69
|
if (a[0] === "rev-parse") return w.head;
|
|
44
70
|
if (a[0] === "diff" && a[1] === "--name-only") return w.changed.join("\n");
|
|
45
71
|
if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
|
|
@@ -50,17 +76,20 @@ function world(o = {}) {
|
|
|
50
76
|
runner: async (c) => {
|
|
51
77
|
w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false, c.system]);
|
|
52
78
|
w.seq.push(c.role + (c.resume ? "-resume" : ""));
|
|
79
|
+
w.envs.push(c.env ?? null);
|
|
53
80
|
if (c.role === "builder") {
|
|
54
81
|
const r = c.resume ? (w.resumeResult ?? w.result) : w.result;
|
|
55
|
-
if (
|
|
56
|
-
if (c.resume
|
|
82
|
+
if (o.live) await o.live(w, put);
|
|
83
|
+
else if (!c.resume && !o.noStages) writeStages(["investigation", "plan", "implementation"]);
|
|
84
|
+
if (r) put("result.json", JSON.stringify(r));
|
|
85
|
+
if (c.resume || /Merging origin\//.test(c.prompt)) { w.head = `bbbb${w.calls.length}`.padEnd(16, "0"); if (!o.leaveConflicts) { w.conflictFiles = []; w.mergeInProgress = false; } }
|
|
57
86
|
return { text: "done", sessionId: "sess-1", usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 }, costUsd: 0.1, durationMs: 1000, turns: 3, model: "default" };
|
|
58
87
|
}
|
|
59
88
|
return { text: w.reviews.length ? w.reviews.shift() : ok(), sessionId: "rev", usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
|
|
60
89
|
},
|
|
61
90
|
waitCiGreen: async () => (w.waits.length ? w.waits.shift() : { rc: 0, text: `CI_GATE_HEAD: ${w.head}` }),
|
|
62
91
|
transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
|
|
63
|
-
fs: { read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; } },
|
|
92
|
+
fs: { stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; } },
|
|
64
93
|
};
|
|
65
94
|
return w;
|
|
66
95
|
}
|
|
@@ -68,22 +97,34 @@ const run = (w, extra = {}) => runCore({ issue: 7, repo: "o/r", lane: "staging",
|
|
|
68
97
|
io: w.io, emitTelemetry: async (r) => w.telemetry.push(r), random: () => 0.5, ...extra });
|
|
69
98
|
const builders = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "builder");
|
|
70
99
|
const reviewers = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "reviewer");
|
|
71
|
-
const
|
|
100
|
+
const traj = (w) => w.comments.filter((c) => c.includes("<!-- FORGE:TRAJECTORY -->"));
|
|
101
|
+
const markerOf = (c) => /<!-- (FORGE:[A-Z_-]+) -->/.exec(c)[1];
|
|
72
102
|
|
|
73
103
|
describe("core work-on", () => {
|
|
74
|
-
it("happy path:
|
|
104
|
+
it("happy path: investigation, plan, implementation, review, trajectory in order; merge at the reviewed head", async () => {
|
|
75
105
|
const w = world();
|
|
76
106
|
const dir = tmpDir("core-");
|
|
77
107
|
const r = await run(w, { dir });
|
|
78
108
|
assert.equal(r.terminalReason, "merged");
|
|
79
109
|
assert.equal(r.pr, 77);
|
|
80
|
-
assert.
|
|
81
|
-
|
|
82
|
-
assert.
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:PLAN", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
|
|
111
|
+
assert.match(w.comments[1], /<!-- FORGE:VALIDATE -->/, "VALIDATE stays as an alias marker");
|
|
112
|
+
assert.match(w.comments[1], /^<!-- FORGE:INVESTIGATION -->\n<!-- FORGE:VALIDATE -->\n## Investigation Report\n\*\*Verdict\*\*: CONFIRMED/);
|
|
113
|
+
assert.match(w.comments[2], /## Implementation Plan\n### Approach/);
|
|
114
|
+
assert.match(w.comments[3], /## Implementation Complete\n\*\*Branch:\*\* `feat\/issue-7-lean`\n\*\*Files changed:\*\*\n- `src\/foo.js`\n- `test\/foo.test.js`/);
|
|
115
|
+
assert.match(w.comments[4], /## Review\n\*\*Verdict:\*\* PASS/);
|
|
116
|
+
assert.match(w.comments[4], /Fixes the issue:\*\* yes \(src\/foo.js:3 fixed\)/);
|
|
117
|
+
const t = traj(w);
|
|
118
|
+
assert.equal(t.length, 1);
|
|
119
|
+
assert.match(t[0], /<!-- FORGE:TRAJECTORY -->\n<!-- FORGE:WORK-ON -->\n## Pipeline Trajectory — #7/);
|
|
120
|
+
for (const needle of ["| Validate | CONFIRMED |", "| Plan | posted |", "| Implement |", "| Review | PASS |", "| PR | #77 |", "| Merge |", "| Close | issue closed (completed) |", "**Decisions**", "**Anomalies**", "cost $0.1500", "tokens 14 in / 7 out / 100 cache", "wall"]) {
|
|
121
|
+
assert.ok(t[0].includes(needle), `trajectory lacks ${needle}:\n${t[0]}`);
|
|
85
122
|
}
|
|
86
|
-
|
|
123
|
+
const body = w.prs[0][w.prs[0].indexOf("--body") + 1];
|
|
124
|
+
assert.match(body, /## Root cause\nfoo derefs null at src\/foo.js:3/);
|
|
125
|
+
assert.match(body, /## Deliverables\n\| File \| Change \| Why \|/);
|
|
126
|
+
assert.match(body, /## Verification\n- node --test/);
|
|
127
|
+
assert.match(body, /Closes #7$/);
|
|
87
128
|
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
88
129
|
assert.equal(w.closed.length, 1);
|
|
89
130
|
assert.equal(builders(w).length, 1);
|
|
@@ -92,22 +133,14 @@ describe("core work-on", () => {
|
|
|
92
133
|
assert.equal(ev[0], "RUN_START"); assert.equal(ev.at(-1), "RUN_TERMINAL");
|
|
93
134
|
});
|
|
94
135
|
|
|
95
|
-
it("
|
|
96
|
-
const w = world(
|
|
136
|
+
it("investigation and plan comments are posted before the reviewer runs and before the in-review label", async () => {
|
|
137
|
+
const w = world();
|
|
97
138
|
await run(w);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.
|
|
102
|
-
assert.
|
|
103
|
-
assert.match(v, /Summary:\*\* fixed foo$/);
|
|
104
|
-
assert.ok(!v.includes("more"));
|
|
105
|
-
const pos = (x) => w.seq.indexOf(x);
|
|
106
|
-
const commentSeq = w.seq.map((s, i) => (s === "comment" ? i : -1)).filter((i) => i >= 0);
|
|
107
|
-
assert.ok(commentSeq[vi] > pos("builder") && commentSeq[vi] < pos("reviewer"));
|
|
108
|
-
assert.ok(commentSeq[vi] < pos("label:in-review"));
|
|
109
|
-
assert.match(registers(w)[0], /Validation: https:\/\/github.com\/o\/r\/issues\/7#issuecomment-\d+/);
|
|
110
|
-
assert.equal(w.comments.filter((c) => c.includes("FORGE:VALIDATE")).length, 1);
|
|
139
|
+
const idx = []; w.seq.forEach((x, i) => { if (x === "comment") idx.push(i); });
|
|
140
|
+
const rv = w.seq.indexOf("reviewer");
|
|
141
|
+
assert.ok(idx[1] < rv && idx[2] < rv && idx[3] < rv, "investigation, plan, implementation precede the reviewer");
|
|
142
|
+
assert.ok(idx[3] < w.seq.indexOf("label:in-review"));
|
|
143
|
+
assert.ok(idx[4] > rv, "review comment follows the reviewer");
|
|
111
144
|
});
|
|
112
145
|
|
|
113
146
|
it("labels transition in order, and register is posted after the PR and before the merge", async () => {
|
|
@@ -116,7 +149,7 @@ describe("core work-on", () => {
|
|
|
116
149
|
assert.deepEqual(w.labels, ["investigating", "building", "in-review", "merged"]);
|
|
117
150
|
const order = w.seq.filter((s) => ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"].includes(s));
|
|
118
151
|
assert.deepEqual(order, ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"]);
|
|
119
|
-
assert.ok(w.seq.
|
|
152
|
+
assert.ok(w.seq.lastIndexOf("comment") > w.seq.indexOf("merge"), "the trajectory is posted last");
|
|
120
153
|
});
|
|
121
154
|
|
|
122
155
|
it("emits phase_attempt for builder and reviewer plus run_terminal, stats-compatible", async () => {
|
|
@@ -129,17 +162,17 @@ describe("core work-on", () => {
|
|
|
129
162
|
assert.equal(mapped.at(-1).payload.reason, "merged");
|
|
130
163
|
});
|
|
131
164
|
|
|
132
|
-
it("invalid with evidence
|
|
133
|
-
const w = world({ changed: [],
|
|
165
|
+
it("invalid with evidence: the investigation is the evidence comment, closes, trajectory rows", async () => {
|
|
166
|
+
const w = world({ changed: [], stages: { investigation: "**Verdict**: INVALID\n**Confidence**: high\n\n### What We Found\n- src/foo.js:3 already guards null\n\n### Root Cause\nnone, not a bug" },
|
|
167
|
+
result: { status: "invalid", summary: "not a bug", evidence: ["src/foo.js:3 already guards null"], tests: [] } });
|
|
134
168
|
const r = await run(w);
|
|
135
169
|
assert.equal(r.terminalReason, "invalid");
|
|
136
170
|
assert.deepEqual(w.labels, ["investigating", "building", "invalid"]);
|
|
137
|
-
|
|
138
|
-
assert.
|
|
139
|
-
assert.match(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
assert.equal(registers(w).length, 0, "no duplicate evidence comment");
|
|
171
|
+
assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:TRAJECTORY"]);
|
|
172
|
+
assert.match(w.comments[1], /Verdict\*\*: INVALID/);
|
|
173
|
+
assert.match(w.comments[1], /src\/foo.js:3 already guards null/);
|
|
174
|
+
const t = traj(w)[0];
|
|
175
|
+
for (const needle of ["| Validate | INVALID |", "| Plan | skipped |", "| Implement | skipped |", "| Review | skipped |", "| PR | none |", "| Close | issue closed (not planned) |"]) assert.ok(t.includes(needle), needle);
|
|
143
176
|
assert.equal(w.closed.length, 1);
|
|
144
177
|
assert.equal(w.prs.length, 0);
|
|
145
178
|
assert.equal(reviewers(w).length, 0);
|
|
@@ -157,6 +190,10 @@ describe("core work-on", () => {
|
|
|
157
190
|
assert.equal(w.closed.length, 0);
|
|
158
191
|
assert.ok(w.edits.some((e) => e.includes("needs-human")));
|
|
159
192
|
assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("without evidence")));
|
|
193
|
+
const t = traj(w)[0];
|
|
194
|
+
assert.match(t, /\| Close \| left open \| label needs-human \|/);
|
|
195
|
+
assert.match(t, /\*\*Anomalies\*\*\n- needs-human: Builder reported "invalid" without evidence/);
|
|
196
|
+
assert.match(t, /\| Plan \| not reached \|/);
|
|
160
197
|
});
|
|
161
198
|
|
|
162
199
|
it("review FAIL -> builder resumed via --resume with the reason -> re-review PASS -> merge", async () => {
|
|
@@ -169,7 +206,9 @@ describe("core work-on", () => {
|
|
|
169
206
|
assert.match(b[1][4], /src\/foo.js:9 still wrong/);
|
|
170
207
|
assert.equal(reviewers(w).length, 2);
|
|
171
208
|
assert.equal(w.prs.length, 1);
|
|
172
|
-
assert.equal(
|
|
209
|
+
assert.equal(traj(w).length, 1);
|
|
210
|
+
assert.match(traj(w)[0], /2 rounds/);
|
|
211
|
+
assert.match(traj(w)[0], /Review FAIL \(round 1\)/);
|
|
173
212
|
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
174
213
|
});
|
|
175
214
|
|
|
@@ -194,6 +233,15 @@ describe("core work-on", () => {
|
|
|
194
233
|
assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
|
|
195
234
|
});
|
|
196
235
|
|
|
236
|
+
it("worktree add retries a concurrent .git lock collision, then gives up after 5 tries", async () => {
|
|
237
|
+
const w = world(); w.lockFails = 2;
|
|
238
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
239
|
+
assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 3);
|
|
240
|
+
const w2 = world(); w2.lockFails = 99;
|
|
241
|
+
assert.notEqual((await run(w2)).terminalReason, "merged");
|
|
242
|
+
assert.equal(w2.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 5);
|
|
243
|
+
});
|
|
244
|
+
|
|
197
245
|
it("needs-human terminals carry a specific outcome code, never unclassified", async () => {
|
|
198
246
|
const code = async (w, extra) => {
|
|
199
247
|
const dir = tmpDir("lean-code-");
|
|
@@ -260,54 +308,131 @@ describe("core work-on", () => {
|
|
|
260
308
|
});
|
|
261
309
|
|
|
262
310
|
describe("comment templates", () => {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
tests: [{ command: "node --test test/foo.test.js", result: "pass", fails_without_fix: true }], risks: ["none"], follow_ups: ["audit baz"] };
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
assert.match(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
assert.match(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
assert.
|
|
285
|
-
|
|
286
|
-
assert.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
311
|
+
const rv = { verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 ok", evidence: "src/foo.js:3 guard", checked: "callers of foo, test/foo.test.js" };
|
|
312
|
+
const result = { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 null deref"], root_cause: "derefs null at src/foo.js:3", changes: [{ file: "src/foo.js", change: "add guard", why: "avoid crash" }],
|
|
313
|
+
tests: [{ command: "node --test test/foo.test.js", result: "pass", fails_without_fix: true }], risks: ["none"], follow_ups: ["audit baz"], approach: "guard null" };
|
|
314
|
+
|
|
315
|
+
it("renders builder-written files under the fixed headings", () => {
|
|
316
|
+
assert.match(renderInvestigation(STAGE_MD.investigation, null), /^## Investigation Report\n\*\*Verdict\*\*: CONFIRMED/);
|
|
317
|
+
assert.match(renderPlan(STAGE_MD.plan, null), /^## Implementation Plan\n### Approach/);
|
|
318
|
+
const im = renderImplementation(STAGE_MD.implementation, null, { branch: "b", files: ["a.js"] });
|
|
319
|
+
assert.match(im, /^## Implementation Complete\n\*\*Branch:\*\* `b`\n\*\*Files changed:\*\*\n- `a.js`\n\n### Approach/);
|
|
320
|
+
const r = renderReview(rv);
|
|
321
|
+
assert.match(r, /^## Review\n\*\*Verdict:\*\* PASS/);
|
|
322
|
+
assert.match(r, /Fixes the issue:\*\* yes \(src\/foo.js:3 guard\)/);
|
|
323
|
+
assert.match(r, /Regression check:\*\* no regression \(checked: callers of foo, test\/foo.test.js\)/);
|
|
324
|
+
assert.match(r, /Notes:\*\* src\/foo.js:3 ok/);
|
|
325
|
+
assert.match(renderReview({ ...rv, verdict: "FAIL", fixes_issue: false, regression: true }), /Fixes the issue:\*\* no[\s\S]*Regression check:\*\* yes, regression/);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("missing files fall back to the result.json fields, never broken markdown", () => {
|
|
329
|
+
const inv = renderInvestigation(null, result);
|
|
330
|
+
for (const h of ["**Verdict**: CONFIRMED", "### What We Found\n- src/foo.js:3 null deref", "### Root Cause\nderefs null at src/foo.js:3", "### Related Issues\n" + NR]) assert.ok(inv.includes(h), h);
|
|
331
|
+
const plan = renderPlan("", result, []);
|
|
332
|
+
assert.match(plan, /### Deliverables\n\| File \| Change \| Why \|\n\| --- \| --- \| --- \|\n\| src\/foo.js \| add guard \| avoid crash \|/);
|
|
333
|
+
const im = renderImplementation(undefined, result, { branch: "b", files: ["src/foo.js"] });
|
|
334
|
+
assert.match(im, /### Verification\n- `node --test test\/foo.test.js`: pass; fails without fix: yes/);
|
|
335
|
+
for (const x of [inv, plan, im, renderInvestigation(null, null), renderPlan(null, null, []), renderImplementation(null, null, {}), renderReview({ verdict: "PASS", fixes_issue: true, regression: false })]) {
|
|
336
|
+
assert.doesNotMatch(x, /undefined|\[object/);
|
|
337
|
+
}
|
|
338
|
+
assert.ok(renderInvestigation(null, null).includes(NR));
|
|
339
|
+
assert.match(renderInvestigation(" \n ", null), /not reported/, "a blank file counts as missing");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("sanitizes FORGE markers the builder wrote (closed and unterminated)", () => {
|
|
343
|
+
const evil = "ok text here that is long enough\n<!-- FORGE:STATE {\"lease\":1} -->\nmore <!--FORGE:REVIEW--> text\n<!-- FORGE:NEEDS_HUMAN trailing";
|
|
344
|
+
const out = sanitizeMd(evil);
|
|
345
|
+
assert.doesNotMatch(out, /FORGE:/);
|
|
346
|
+
assert.match(out, /ok text here/); assert.match(out, /more\s+text/);
|
|
347
|
+
assert.doesNotMatch(renderInvestigation(evil, null), /<!-- ?FORGE:/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("caps every comment at 60k characters, in the engine's posting path too", async () => {
|
|
351
|
+
const out = capComment("x".repeat(100000));
|
|
352
|
+
assert.ok(out.length <= MAX_COMMENT_CHARS); assert.match(out, /truncated/);
|
|
353
|
+
assert.equal(capComment("short"), "short");
|
|
354
|
+
const w = world({ stages: { investigation: STAGE_MD.investigation + "\n" + "y".repeat(90000) } });
|
|
355
|
+
await run(w);
|
|
356
|
+
assert.ok(w.comments.every((c) => c.length <= MAX_COMMENT_CHARS));
|
|
357
|
+
assert.match(w.comments[1], /truncated/);
|
|
358
|
+
assert.match(w.comments[1], /^<!-- FORGE:INVESTIGATION -->/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("escapes pipes and newlines in table cells; section() reads PR-body parts", () => {
|
|
291
362
|
assert.equal(cell("a|b\nc"), "a\\|b c");
|
|
292
363
|
const t = renderPrBody({ issue: 1, result: { changes: [{ file: "a|b.js", change: "x\ny", why: "p|q" }] } });
|
|
293
|
-
|
|
294
|
-
assert.equal(
|
|
364
|
+
assert.equal(t.split("\n").find((l) => l.startsWith("| a")), "| a\\|b.js | x y | p\\|q |");
|
|
365
|
+
assert.equal(section(STAGE_MD.investigation, "Root Cause"), "foo derefs null at src/foo.js:3");
|
|
366
|
+
assert.match(renderPrBody({ issue: 1, result: null }), /## Root cause\nnot reported[\s\S]*Closes #1$/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("trajectory rows: merged, invalid, needs-human; missing rows read 'not reached'", () => {
|
|
370
|
+
const base = { issue: 9, validate: ["CONFIRMED", "2 evidence item(s)"], plan: "posted", implement: ["2 file(s)", "b"], review: ["PASS", "1 round"], pr: ["#5", "opened"], merge: ["all", "merged at abc"], close: ["issue closed (completed)", "label workflow:merged"], decisions: ["d1"], anomalies: [], usage: "cost $1" };
|
|
371
|
+
const m = renderTrajectory(base);
|
|
372
|
+
assert.match(m, /^## Pipeline Trajectory — #9\n\n\| Phase \| Result \| Notes \|/);
|
|
373
|
+
for (const row of ["| Validate | CONFIRMED | 2 evidence item(s) |", "| Review | PASS | 1 round |", "| PR | #5 | opened |", "| Merge | all | merged at abc |", "| Close | issue closed (completed) | label workflow:merged |", "- d1", "- none", "_cost $1_"]) assert.ok(m.includes(row), row);
|
|
374
|
+
const nh = renderTrajectory({ issue: 9, validate: "CONFIRMED", anomalies: ["needs-human: Review FAIL twice"], close: ["left open", "label needs-human"] });
|
|
375
|
+
assert.match(nh, /\| Implement \| not reached \| - \|/);
|
|
376
|
+
assert.match(nh, /\*\*Anomalies\*\*\n- needs-human: Review FAIL twice/);
|
|
377
|
+
assert.match(renderTrajectory({ issue: 9, validate: ["a|b", "x\ny"] }), /\| Validate \| a\\\|b \| x y \|/);
|
|
295
378
|
});
|
|
296
379
|
|
|
297
|
-
it("
|
|
298
|
-
const w = world({
|
|
380
|
+
it("a merged run's trajectory and a needs-human run's trajectory carry the right rows", async () => {
|
|
381
|
+
const w = world({ reviews: [bad(), bad("still bad")] });
|
|
299
382
|
await run(w);
|
|
300
|
-
const
|
|
301
|
-
assert.
|
|
302
|
-
|
|
303
|
-
assert.
|
|
304
|
-
|
|
305
|
-
assert.match(
|
|
383
|
+
const t = traj(w)[0];
|
|
384
|
+
assert.match(t, /\| Validate \| CONFIRMED \|/);
|
|
385
|
+
assert.match(t, /\| Plan \| posted \|/);
|
|
386
|
+
assert.match(t, /\| Review \| not reached \|/);
|
|
387
|
+
assert.match(t, /\| Close \| left open \| label needs-human \|/);
|
|
388
|
+
assert.match(t, /Review FAIL twice/);
|
|
306
389
|
});
|
|
307
390
|
|
|
308
|
-
it("
|
|
309
|
-
|
|
310
|
-
|
|
391
|
+
it("missing stage files in a real run are rendered from result.json and noted as anomalies", async () => {
|
|
392
|
+
const w = world({ noStages: true, result: { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 x"], root_cause: "bad at src/foo.js:3", changes: [{ file: "src/foo.js", change: "guard", why: "w" }], tests: [{ command: "t", result: "pass", fails_without_fix: false }] } });
|
|
393
|
+
await run(w);
|
|
394
|
+
assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:PLAN", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
|
|
395
|
+
assert.match(w.comments[1], /Root Cause\nbad at src\/foo.js:3/);
|
|
396
|
+
assert.match(w.comments[3], /fails without fix: no/);
|
|
397
|
+
assert.match(traj(w)[0], /investigation.md missing or empty: rendered from result.json/);
|
|
398
|
+
for (const c of w.comments) assert.doesNotMatch(c, /undefined|\[object/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("live posting: stage notes appear while the builder session is still running", async () => {
|
|
402
|
+
const w = world({ live: async (wd, put) => {
|
|
403
|
+
const until = async (marker) => { for (let i = 0; i < 300 && !wd.comments.some((c) => c.includes(`<!-- ${marker} -->`)); i++) await new Promise((r) => setTimeout(r, 10)); return wd.comments.map(markerOf); };
|
|
404
|
+
wd.liveSeen = {};
|
|
405
|
+
put("investigation.md", STAGE_MD.investigation); wd.liveSeen.afterInvestigation = await until("FORGE:INVESTIGATION");
|
|
406
|
+
put("plan.md", STAGE_MD.plan); wd.liveSeen.afterPlan = await until("FORGE:PLAN");
|
|
407
|
+
put("implementation.md", STAGE_MD.implementation); wd.liveSeen.afterImplementation = await until("FORGE:IMPLEMENTATION");
|
|
408
|
+
} });
|
|
409
|
+
const r = await run(w, { livePollMs: 10, stableMs: 30 });
|
|
410
|
+
assert.equal(r.terminalReason, "merged");
|
|
411
|
+
assert.ok(w.liveSeen.afterInvestigation.includes("FORGE:INVESTIGATION"), "investigation posted before the session ended");
|
|
412
|
+
assert.ok(w.liveSeen.afterPlan.includes("FORGE:PLAN"));
|
|
413
|
+
assert.ok(w.liveSeen.afterImplementation.includes("FORGE:IMPLEMENTATION"));
|
|
414
|
+
assert.deepEqual(w.comments.map(markerOf), ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:PLAN", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"], "each posted exactly once, in order");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("a file still being written is not posted until its mtime is stable", async () => {
|
|
418
|
+
let seenEarly = null;
|
|
419
|
+
const w = world({ live: async (wd, put) => {
|
|
420
|
+
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
421
|
+
for (let i = 0; i < 6; i++) { put("investigation.md", STAGE_MD.investigation + "\n".repeat(i)); await sleepMs(15); }
|
|
422
|
+
seenEarly = wd.comments.map(markerOf);
|
|
423
|
+
await sleepMs(900);
|
|
424
|
+
} });
|
|
425
|
+
await run(w, { livePollMs: 10, stableMs: 400 });
|
|
426
|
+
assert.ok(!seenEarly.includes("FORGE:INVESTIGATION"), "not posted while the file keeps changing");
|
|
427
|
+
assert.equal(w.comments.filter((c) => c.includes("FORGE:INVESTIGATION")).length, 1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("builder prompt (three stage templates) stays within 2.5k tokens", () => {
|
|
431
|
+
const p = builderPrompt(7);
|
|
432
|
+
assert.ok(approxTokens(p) <= 2500, `builder prompt is ${approxTokens(p)} tokens`);
|
|
433
|
+
for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
|
|
434
|
+
"### Approach", "### Deliverables", "### Acceptance Criteria", "### Implementation Order", "### Out of Scope", "### Risk Assessment", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes",
|
|
435
|
+
"investigation.md", "plan.md", "implementation.md", "result.json", "BEFORE coding", "BEFORE editing code"]) assert.ok(p.includes(h), h);
|
|
311
436
|
});
|
|
312
437
|
});
|
|
313
438
|
|
|
@@ -319,7 +444,7 @@ describe("merge_wait policy", () => {
|
|
|
319
444
|
w.io.waitCiGreen = async () => { polled++; return { rc: 2, text: "" }; };
|
|
320
445
|
const r = await run(w);
|
|
321
446
|
assert.equal(r.terminalReason, "merged"); assert.equal(polled, 0);
|
|
322
|
-
assert.match(
|
|
447
|
+
assert.match(traj(w)[0], /\| Merge \| required \(no required checks: merged right after review\) \|/);
|
|
323
448
|
});
|
|
324
449
|
it("required with a pending required check waits (backoff) then merges", async () => {
|
|
325
450
|
const w = world(); const seqs = [states({ build: "pending" }), states({ build: "pending" }), states({ build: "success", other: "failure" })]; let sleeps = 0;
|
|
@@ -327,7 +452,7 @@ describe("merge_wait policy", () => {
|
|
|
327
452
|
const s = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s(ms); };
|
|
328
453
|
const r = await run(w);
|
|
329
454
|
assert.equal(r.terminalReason, "merged"); assert.equal(sleeps, 2);
|
|
330
|
-
assert.match(
|
|
455
|
+
assert.match(traj(w)[0], /\| Merge \| required \(build\) \|/);
|
|
331
456
|
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
332
457
|
});
|
|
333
458
|
it("required with a failing required check is needs-human; slow required check is ci-pending", async () => {
|
|
@@ -340,7 +465,7 @@ describe("merge_wait policy", () => {
|
|
|
340
465
|
const w = world({ waits: [{ rc: 2, text: "" }] }); let req = 0; w.io.requiredChecks = async () => { req++; return []; };
|
|
341
466
|
assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "merged");
|
|
342
467
|
assert.equal(w.waits.length, 0); assert.equal(req, 0);
|
|
343
|
-
assert.match(
|
|
468
|
+
assert.match(traj(w)[0], /\| Merge \| all \|/);
|
|
344
469
|
const w2 = world({ waits: [{ rc: 1, text: "red" }] });
|
|
345
470
|
assert.equal((await run(w2, { mergeWait: "none" })).terminalReason, "merged");
|
|
346
471
|
assert.equal(w2.waits.length, 1); assert.deepEqual(w2.merges[0].slice(-2), ["--match-head-commit", w2.head]);
|
|
@@ -348,9 +473,8 @@ describe("merge_wait policy", () => {
|
|
|
348
473
|
});
|
|
349
474
|
|
|
350
475
|
describe("prompts", () => {
|
|
351
|
-
it("builder prompt
|
|
476
|
+
it("builder prompt keeps the short developer opening and the no-push rule", () => {
|
|
352
477
|
const p = builderPrompt(7);
|
|
353
|
-
assert.ok(approxTokens(p) <= 1000);
|
|
354
478
|
assert.match(p, /Resolve issue #7 \(read it with gh issue view 7 --comments\)/);
|
|
355
479
|
assert.match(p, /Do not push or touch GitHub/);
|
|
356
480
|
assert.match(p, /result\.json/);
|
|
@@ -373,6 +497,7 @@ describe("prompts", () => {
|
|
|
373
497
|
});
|
|
374
498
|
|
|
375
499
|
it("parseVerdict: flags decide, garbage is null", () => {
|
|
500
|
+
assert.equal(parseVerdict(ok({ evidence: "a.js:1", checked: "c" })).evidence, "a.js:1");
|
|
376
501
|
assert.equal(parseVerdict(ok()).fail, false);
|
|
377
502
|
assert.equal(parseVerdict(ok({ regression: true })).fail, true);
|
|
378
503
|
assert.equal(parseVerdict(bad()).fail, true);
|
|
@@ -497,3 +622,286 @@ describe("orchestrate", () => {
|
|
|
497
622
|
await assert.rejects(orchestrateFromCli([], {}), /usage/);
|
|
498
623
|
});
|
|
499
624
|
});
|
|
625
|
+
|
|
626
|
+
describe("runCliBackendAsync", () => {
|
|
627
|
+
it("returns the same envelope as runCliBackend, without blocking the event loop", async () => {
|
|
628
|
+
const { writeFileSync, chmodSync } = await import("node:fs");
|
|
629
|
+
const { runCliBackend, runCliBackendAsync } = await import("../runner.mjs");
|
|
630
|
+
const d = tmpDir("core-async-");
|
|
631
|
+
const bin = `${d}/fake-claude`;
|
|
632
|
+
writeFileSync(bin, '#!/bin/sh\nsleep 0.3\necho \'{"result":"hello","session_id":"s-1","total_cost_usd":0.25,"num_turns":2,"duration_ms":300,"usage":{"input_tokens":3,"output_tokens":4}}\'\n');
|
|
633
|
+
chmodSync(bin, 0o755);
|
|
634
|
+
const base = { spec: { name: "t" }, userMessage: "go", systemPrompt: "sys", args: ["1"], cwd: d, bin, logger: { log() {} } };
|
|
635
|
+
let ticks = 0; const iv = setInterval(() => { ticks++; }, 20);
|
|
636
|
+
const a = await runCliBackendAsync(base);
|
|
637
|
+
clearInterval(iv);
|
|
638
|
+
const s = runCliBackend(base);
|
|
639
|
+
assert.ok(ticks >= 5, `event loop kept turning (${ticks} ticks)`);
|
|
640
|
+
assert.deepEqual({ ...a, durationMs: 0 }, { ...s, durationMs: 0 });
|
|
641
|
+
assert.equal(a.sessionId, "s-1"); assert.equal(a.text, "hello"); assert.equal(a.costUsd, 0.25);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("surfaces a failing CLI as the same CLI_BACKEND_FAILED error", async () => {
|
|
645
|
+
const { writeFileSync, chmodSync } = await import("node:fs");
|
|
646
|
+
const { runCliBackendAsync } = await import("../runner.mjs");
|
|
647
|
+
const d = tmpDir("core-async-");
|
|
648
|
+
writeFileSync(`${d}/bad`, "#!/bin/sh\necho boom >&2\nexit 3\n"); chmodSync(`${d}/bad`, 0o755);
|
|
649
|
+
await assert.rejects(runCliBackendAsync({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: d, bin: `${d}/bad`, logger: { log() {} } }), (e) => e.code === "CLI_BACKEND_FAILED" && /boom/.test(e.message));
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
describe("base-sync (#4100)", () => {
|
|
654
|
+
const CONFLICTING = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" };
|
|
655
|
+
const OKSTATE = { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" };
|
|
656
|
+
const gitCalls = (w) => w.calls.filter((c) => c[0] === "git").map((c) => c.slice(1));
|
|
657
|
+
const noRebaseNoForce = (w) => {
|
|
658
|
+
for (const g of gitCalls(w)) {
|
|
659
|
+
assert.notEqual(g[0], "rebase");
|
|
660
|
+
if (g[0] === "push") assert.ok(!g.some((x) => /^(--force|-f$|\+)/.test(x)), `force push: ${g.join(" ")}`);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
const syncComment = (w) => w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->"));
|
|
664
|
+
|
|
665
|
+
it("conflicting PR, clean merge: fetch, merge, push, merge the PR at the new head; no re-review", async () => {
|
|
666
|
+
const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["clean"] });
|
|
667
|
+
const r = await run(w);
|
|
668
|
+
assert.equal(r.terminalReason, "merged");
|
|
669
|
+
assert.equal(reviewers(w).length, 1, "no re-review for a conflict-free base merge");
|
|
670
|
+
assert.equal(builders(w).length, 1);
|
|
671
|
+
const g = gitCalls(w);
|
|
672
|
+
assert.ok(g.some((x) => x[0] === "fetch" && x[1] === "origin" && x[2] === "staging"));
|
|
673
|
+
assert.ok(g.some((x) => x[0] === "merge" && x[1] === "--no-edit" && x[2] === "origin/staging"));
|
|
674
|
+
assert.equal(g.filter((x) => x[0] === "push").length, 2, "initial push plus the sync push");
|
|
675
|
+
assert.match(syncComment(w), /## Base Sync[\s\S]*clean merge[\s\S]*not required \(conflict-free base merge\)/);
|
|
676
|
+
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
677
|
+
assert.match(traj(w)[0], /\| Base Sync \| 1 sync\(s\) \|/);
|
|
678
|
+
noRebaseNoForce(w);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("no conflict: no Base Sync row detail and no sync comment", async () => {
|
|
682
|
+
const w = world();
|
|
683
|
+
await run(w);
|
|
684
|
+
assert.ok(!syncComment(w));
|
|
685
|
+
assert.match(traj(w)[0], /\| Base Sync \| not needed \| - \|/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("conflicting PR, real conflict: builder resumed with the files, reviewer PASS on the full diff, merge", async () => {
|
|
689
|
+
const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
|
|
690
|
+
const r = await run(w);
|
|
691
|
+
assert.equal(r.terminalReason, "merged");
|
|
692
|
+
const b = builders(w);
|
|
693
|
+
assert.equal(b.length, 2);
|
|
694
|
+
assert.equal(b[1][2], "sess-1", "same session resumed");
|
|
695
|
+
assert.match(b[1][4], /Merging origin\/staging into this branch conflicts in: src\/foo\.js\. 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\./);
|
|
696
|
+
assert.equal(reviewers(w).length, 2, "re-review after the conflict resolution");
|
|
697
|
+
assert.match(syncComment(w), /conflicts in `src\/foo\.js`[\s\S]*builder resolved 1 conflicted file[\s\S]*Re-review:\*\* PASS/);
|
|
698
|
+
assert.equal(w.merges.length, 1);
|
|
699
|
+
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
700
|
+
noRebaseNoForce(w);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("conflict resolution that the re-review FAILs gets one retry then merges on PASS", async () => {
|
|
704
|
+
const w = world({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), bad("src/foo.js:4 lost base change"), ok()] });
|
|
705
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
706
|
+
assert.equal(reviewers(w).length, 3);
|
|
707
|
+
assert.equal(builders(w).length, 3);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("still conflicting after 2 base-syncs ends needs-human with base.conflict, never engine-error", async () => {
|
|
711
|
+
const w = world({ prView: [CONFLICTING], mergeResults: ["clean", "clean", "clean"] });
|
|
712
|
+
const r = await run(w);
|
|
713
|
+
assert.equal(r.terminalReason, "needs-human");
|
|
714
|
+
assert.equal(r.code, "base.conflict");
|
|
715
|
+
assert.equal(gitCalls(w).filter((x) => x[0] === "merge" && x[1] === "--no-edit").length, 2, "at most 2 base-syncs");
|
|
716
|
+
assert.equal(w.merges.length, 0);
|
|
717
|
+
assert.ok(w.edits.some((e) => e.includes("needs-human")));
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("unresolved conflict markers after the builder ends needs-human with base.conflict", async () => {
|
|
721
|
+
const w = world({ prView: [CONFLICTING], mergeResults: ["conflict"] });
|
|
722
|
+
w.markers = true;
|
|
723
|
+
const r = await run(w);
|
|
724
|
+
assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, "base.conflict");
|
|
725
|
+
assert.equal(w.merges.length, 0);
|
|
726
|
+
assert.ok(gitCalls(w).some((x) => x[0] === "merge" && x[1] === "--abort"));
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("a gh pr merge conflict error triggers base-sync, then the merge is retried", async () => {
|
|
730
|
+
const w = world({ prView: [OKSTATE], mergeErrors: ["GraphQL: Pull request is not mergeable (mergePullRequest)"], mergeResults: ["clean"] });
|
|
731
|
+
const r = await run(w);
|
|
732
|
+
assert.equal(r.terminalReason, "merged");
|
|
733
|
+
assert.equal(w.mergeAttempts, 2);
|
|
734
|
+
assert.match(syncComment(w), /gh pr merge reported a conflict/);
|
|
735
|
+
noRebaseNoForce(w);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("an unrelated gh pr merge error is not a base-sync", async () => {
|
|
739
|
+
const w = world({ mergeErrors: ["HTTP 500 boom"] });
|
|
740
|
+
const r = await run(w);
|
|
741
|
+
assert.equal(r.terminalReason, "engine-error");
|
|
742
|
+
assert.ok(!syncComment(w));
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("a resumed run with a conflicting PR syncs (fresh builder when the conflict needs resolving)", async () => {
|
|
746
|
+
const w = world({ prList: [{ number: 77, headRefOid: "x" }], remote: "abc\trefs/heads/feat/issue-7-lean", prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
|
|
747
|
+
const r = await run(w);
|
|
748
|
+
assert.equal(r.terminalReason, "merged");
|
|
749
|
+
const b = builders(w);
|
|
750
|
+
assert.equal(b.length, 1);
|
|
751
|
+
assert.equal(b[0][2], null, "no session id on resume: a fresh builder");
|
|
752
|
+
assert.match(b[0][4], /Merging origin\/staging into this branch conflicts in: src\/foo\.js/);
|
|
753
|
+
assert.equal(w.prs.length, 0);
|
|
754
|
+
noRebaseNoForce(w);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("UNKNOWN mergeability with nothing to merge is not treated as a conflict", async () => {
|
|
758
|
+
const w = world({ prView: [{ mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }, { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }, OKSTATE], mergeResults: ["clean"] });
|
|
759
|
+
// a clean merge that changes head would count; here make the merge a no-op by pinning the head
|
|
760
|
+
const orig = w.io.git;
|
|
761
|
+
w.io.git = async (a, o2) => { const r = await orig(a, o2); if (a[0] === "merge" && a[1] === "--no-edit") w.head = "aaaa1111aaaa1111"; return r; };
|
|
762
|
+
const r = await run(w);
|
|
763
|
+
assert.equal(r.terminalReason, "merged");
|
|
764
|
+
assert.ok(!syncComment(w));
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", () => {
|
|
769
|
+
const limitErr = (extra) => Object.assign(new Error("claude CLI exited with status 1. You've hit your session limit"), { code: "CLI_BACKEND_FAILED", usageLimit: true, ...extra });
|
|
770
|
+
const failFirstBuilder = (w, err, times = 1) => { const orig = w.io.runner; let n = 0; w.io.runner = async (c) => { if (c.role === "builder" && n++ < times) throw err; return orig(c); }; };
|
|
771
|
+
const projector = (w, lease) => { w.leaseWrites = []; w.io.projector = { readState: async () => (lease ? { v: 1, lease } : null), writeState: async (_i, st) => { w.leaseWrites.push(st); } }; };
|
|
772
|
+
|
|
773
|
+
it("#4101 a usage limit with a near reset pauses until the reset, then resumes the same step", async () => {
|
|
774
|
+
const w = world(); const sleeps = [];
|
|
775
|
+
const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps.push(ms); return s0(ms); };
|
|
776
|
+
failFirstBuilder(w, limitErr({ resetAtEpochMs: 90000, resetAt: "8:30pm" }));
|
|
777
|
+
const r = await run(w);
|
|
778
|
+
assert.equal(r.terminalReason, "merged");
|
|
779
|
+
assert.ok(sleeps.includes(90000), `slept until the reset: ${sleeps}`);
|
|
780
|
+
assert.equal(builders(w).length, 1);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("#4101 a usage limit with a far reset ends deferred/rate_limited with the reset time, lease released, no needs-human", async () => {
|
|
784
|
+
const w = world(); projector(w, null);
|
|
785
|
+
failFirstBuilder(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000, resetAt: "8:30pm (Asia/Kolkata)" }));
|
|
786
|
+
const r = await run(w);
|
|
787
|
+
assert.equal(r.terminalReason, "deferred");
|
|
788
|
+
assert.equal(r.code, "rate_limited");
|
|
789
|
+
assert.match(r.detail, /resets at 8:30pm \(Asia\/Kolkata\)/);
|
|
790
|
+
assert.equal(w.edits.some((e) => e.includes("needs-human")), false);
|
|
791
|
+
assert.equal(traj(w).length, 0, "no trajectory comment for a deferral");
|
|
792
|
+
assert.equal(w.leaseWrites.at(-1).lease, null, "lease released");
|
|
793
|
+
assert.equal(w.merges.length, 0);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("#4101 unknown reset pauses a bounded number of times, then defers", async () => {
|
|
797
|
+
const w = world(); let sleeps = 0;
|
|
798
|
+
const s0 = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s0(ms); };
|
|
799
|
+
failFirstBuilder(w, limitErr(), 99);
|
|
800
|
+
const r = await run(w, { maxSessionLimitPauses: 2 });
|
|
801
|
+
assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "rate_limited");
|
|
802
|
+
assert.equal(sleeps, 2);
|
|
803
|
+
assert.match(r.detail, /resets at unknown/);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("#4101 a non-limit runner error is still engine-error", async () => {
|
|
807
|
+
const w = world(); failFirstBuilder(w, new Error("boom"));
|
|
808
|
+
assert.equal((await run(w)).terminalReason, "engine-error");
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("#4102 a same-host holder whose pid is gone is taken over immediately; a live one or another host is not", async () => {
|
|
812
|
+
const future = Date.now() + 1e6;
|
|
813
|
+
const dead = Object.assign(new Error("no such process"), { code: "ESRCH" });
|
|
814
|
+
const w = world(); projector(w, { by: "core_4242", until: future, host: "h1", pid: 4242 });
|
|
815
|
+
const r = await run(w, { hostName: "h1", killFn: () => { throw dead; } });
|
|
816
|
+
assert.equal(r.terminalReason, "merged");
|
|
817
|
+
assert.ok(w.leaseWrites.some((x) => x.lease?.host === "h1" && Number.isSafeInteger(x.lease?.pid)), "lease records host and pid");
|
|
818
|
+
const w2 = world(); projector(w2, { by: "core_4242", until: 1e12, host: "h1", pid: 4242 });
|
|
819
|
+
assert.equal((await run(w2, { hostName: "h1", killFn: () => true })).terminalReason, "deferred");
|
|
820
|
+
const w3 = world(); projector(w3, { by: "core_4242", until: 1e12, host: "other-host", pid: 4242 });
|
|
821
|
+
assert.equal((await run(w3, { hostName: "h1", killFn: () => { throw dead; } })).terminalReason, "deferred");
|
|
822
|
+
assert.equal(builders(w3).length, 0);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("#4103 a leftover unregistered worktree dir is removed (only this issue's lean dir) before add", async () => {
|
|
826
|
+
const w = world(); const removed = [];
|
|
827
|
+
w.io.fs.exists = async (p) => p === "/wt/lean-7"; w.io.fs.rmdir = async (p) => { removed.push(p); };
|
|
828
|
+
await run(w);
|
|
829
|
+
assert.deepEqual(removed, ["/wt/lean-7"]);
|
|
830
|
+
// a registered worktree dir is left to `git worktree remove`
|
|
831
|
+
const w2 = world(); const removed2 = [];
|
|
832
|
+
w2.io.fs.exists = async () => true; w2.io.fs.rmdir = async (p) => { removed2.push(p); };
|
|
833
|
+
const g = w2.io.git; w2.io.git = async (a, o2) => (a[0] === "worktree" && a[1] === "list" ? "worktree /wt/lean-7\nHEAD abc\n" : g(a, o2));
|
|
834
|
+
await run(w2);
|
|
835
|
+
assert.deepEqual(removed2, []);
|
|
836
|
+
// a worktree root that is not the lean root never deletes anything
|
|
837
|
+
const w3 = world(); const removed3 = [];
|
|
838
|
+
w3.io.fs.exists = async () => true; w3.io.fs.rmdir = async (p) => { removed3.push(p); };
|
|
839
|
+
await run(w3, { worktreeRoot: "/wt" , issue: 7 });
|
|
840
|
+
assert.ok(removed3.every((p) => p === "/wt/lean-7"));
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("#4103 'already exists' on add cleans the leftover and retries; index.lock and packed-refs.lock are retried", async () => {
|
|
844
|
+
for (const msg of ["fatal: '/wt/lean-7' already exists", "fatal: Unable to create '/r/.git/index.lock': File exists.", "error: cannot lock ref: Unable to create '/r/.git/packed-refs.lock': File exists", "fatal: could not lock config file /r/.git/config: File exists"]) {
|
|
845
|
+
const w = world(); let failed = false; const removed = [];
|
|
846
|
+
w.io.fs.exists = async (p) => failed && p === "/wt/lean-7"; w.io.fs.rmdir = async (p) => { removed.push(p); };
|
|
847
|
+
const g = w.io.git;
|
|
848
|
+
w.io.git = async (a, o2) => { if (a[0] === "worktree" && a[1] === "add" && !failed) { failed = true; throw Object.assign(new Error(msg), { stderr: msg }); } return g(a, o2); };
|
|
849
|
+
const r = await run(w);
|
|
850
|
+
assert.equal(r.terminalReason, "merged", msg);
|
|
851
|
+
if (/already exists/.test(msg)) assert.deepEqual(removed, ["/wt/lean-7"]);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("#4104 the builder and reviewer run with a per-run TMPDIR that is removed on every terminal", async () => {
|
|
856
|
+
const mk = (w) => { w.tmpMade = []; w.tmpRemoved = []; w.io.runTmp = { prune: async () => { w.pruned = true; }, make: async (i, id) => { const d = `/home/x/.forge/tmp/runs/${id}`; w.tmpMade.push(d); return d; }, remove: async (d) => { w.tmpRemoved.push(d); } }; };
|
|
857
|
+
const cases = {
|
|
858
|
+
merged: (w) => w,
|
|
859
|
+
"needs-human": (w) => { w.reviews.push(bad(), bad()); return w; },
|
|
860
|
+
"engine-error": (w) => { failFirstBuilder(w, new Error("boom")); return w; },
|
|
861
|
+
deferred: (w) => { failFirstBuilder(w, limitErr({ resetAtEpochMs: 3 * 3600 * 1000 })); return w; },
|
|
862
|
+
};
|
|
863
|
+
for (const [reason, prep] of Object.entries(cases)) {
|
|
864
|
+
const w = world(); mk(w); prep(w);
|
|
865
|
+
const r = await run(w);
|
|
866
|
+
assert.equal(r.terminalReason, reason);
|
|
867
|
+
assert.equal(w.pruned, true);
|
|
868
|
+
assert.equal(w.tmpMade.length, 1);
|
|
869
|
+
assert.match(w.tmpMade[0], /\/\.forge\/tmp\/runs\/7-/);
|
|
870
|
+
assert.deepEqual(w.tmpRemoved, w.tmpMade, `${reason}: removed`);
|
|
871
|
+
for (const e of w.envs) assert.equal(e?.TMPDIR, w.tmpMade[0]);
|
|
872
|
+
if (["merged", "needs-human"].includes(reason)) assert.ok(w.envs.length >= 1);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it("#4104 makeRunTmp: removes only its own dirs; prune takes only old dirs with a dead owner and our marker", async () => {
|
|
877
|
+
const { mkdirSync, writeFileSync, existsSync, utimesSync } = await import("node:fs");
|
|
878
|
+
const root = tmpDir("core-runs-");
|
|
879
|
+
let t = 1_000_000_000_000;
|
|
880
|
+
const rt = makeRunTmp({ root, now: () => t, host: "h", alive: (pid) => pid === 111 });
|
|
881
|
+
const mine = await rt.make(7, "7-1");
|
|
882
|
+
assert.ok(existsSync(mine));
|
|
883
|
+
assert.equal(await rt.remove(mine), true); assert.ok(!existsSync(mine));
|
|
884
|
+
assert.equal(await rt.remove(`${root}/not-made`), false);
|
|
885
|
+
const outside = tmpDir("core-outside-"); writeFileSync(`${outside}/f`, "x");
|
|
886
|
+
assert.equal(await rt.remove(outside), false); assert.ok(existsSync(outside));
|
|
887
|
+
const own = (name, o2) => { mkdirSync(`${root}/${name}`); writeFileSync(`${root}/${name}/.forge-run-owner`, JSON.stringify({ host: "h", ...o2 })); };
|
|
888
|
+
own("old-dead", { pid: 999, created: t - 7 * 3600 * 1000 });
|
|
889
|
+
own("old-alive", { pid: 111, created: t - 7 * 3600 * 1000 });
|
|
890
|
+
own("fresh-dead", { pid: 999, created: t - 3600 * 1000 });
|
|
891
|
+
own("old-otherhost", { pid: 999, host: "zzz", created: t - 7 * 3600 * 1000 });
|
|
892
|
+
mkdirSync(`${root}/foreign`); writeFileSync(`${root}/foreign/data`, "not ours");
|
|
893
|
+
t += 1;
|
|
894
|
+
const removed = await rt.prune();
|
|
895
|
+
assert.deepEqual(removed.sort(), ["old-dead"]);
|
|
896
|
+
for (const keep of ["old-alive", "fresh-dead", "foreign"]) assert.ok(existsSync(`${root}/${keep}`), keep);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("#4104 runCliBackend applies the per-run env to the child", async () => {
|
|
900
|
+
const { runCliBackend } = await import("../runner.mjs");
|
|
901
|
+
let seen;
|
|
902
|
+
runCliBackend({ spec: { name: "t" }, userMessage: "go", args: ["1"], cwd: tmpDir("core-env-"), logger: { log() {} }, env: { TMPDIR: "/x/run-tmp", CLAUDE_CODE_TMPDIR: "/x/run-tmp" },
|
|
903
|
+
spawnFn: (_b, _a, o2) => { seen = o2.env; return { status: 0, stdout: "{}", stderr: "" }; } });
|
|
904
|
+
assert.equal(seen.TMPDIR, "/x/run-tmp"); assert.equal(seen.CLAUDE_CODE_TMPDIR, "/x/run-tmp");
|
|
905
|
+
assert.equal(seen.ANTHROPIC_API_KEY, undefined);
|
|
906
|
+
});
|
|
907
|
+
});
|