forgedock 1.0.22 → 1.1.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/README.md +217 -218
- package/bin/cinema.mjs +320 -0
- package/bin/engine/phases.mjs +148 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +27 -0
- package/bin/engine-cli.mjs +50 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +464 -1491
- package/bin/journey.mjs +783 -0
- package/bin/runner.mjs +80 -13
- package/bin/settings-hook.mjs +112 -0
- package/bin/tui.mjs +6 -3
- package/commands/autopilot.md +4 -4
- package/commands/deploy-info.md +5 -3
- package/commands/design-bench.md +1 -0
- package/commands/design-render-critique-loop.md +1 -0
- package/commands/design.md +1 -0
- package/commands/forge-stats.md +1 -0
- package/commands/issue.md +1 -1
- package/commands/orchestrate.md +27 -9
- package/commands/quality-gate.md +80 -34
- package/commands/review-pr-agents.md +7 -7
- package/commands/review-pr.md +21 -4
- package/commands/work-on/build/implement.md +16 -4
- package/commands/work-on/build/validate.md +38 -1
- package/commands/work-on/build.md +27 -9
- package/commands/work-on/close.md +2 -2
- package/commands/work-on/review.md +24 -0
- package/commands/work-on-monolithic.md +1 -0
- package/commands/work-on.md +45 -3
- package/docs/BRAND.md +6 -2
- package/docs/CONFIG.md +39 -34
- package/docs/site/.vitepress/config.mjs +85 -0
- package/docs/site/getting-started.md +15 -8
- package/docs/site/index.md +1 -1
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/package.json +10 -4
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
# Durable Execution Engine Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make ForgeDock's headless pipeline durable — a code-driven per-phase state machine over the existing `runner.mjs`, with an append-only run-log mirrored to a compact `FORGE:STATE` block on GitHub, so runs resume from the last committed phase after any crash/kill/compaction.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A runtime-agnostic engine core (`bin/engine/*`) drives one pipeline phase at a time by calling the existing `runner.runCommand()` with a fresh context per phase. Each phase's outcome is determined by inspecting GitHub state after the run (`detectOutcome`), not from the runner's return. State lives in a local `.forge/runs/{issue}.jsonl` (hot path) and a `FORGE:STATE` HTML-comment index on the issue (durable shared mirror); on divergence GitHub wins. All side effects (GitHub, git, the LLM run) are injected, so the whole engine is unit-testable with fakes and no network.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js ES modules (`.mjs`), `node:test` + `node:assert/strict`, `bin/runner.mjs` (existing, unmodified), `gh` and `git` CLIs (injected as async functions).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Node ES modules (`.mjs`); **zero runtime dependencies** — the Anthropic SDK stays lazy/optional inside `runner.mjs` only. Engine code imports nothing outside `node:*` and sibling engine modules.
|
|
16
|
+
- Tests run via `node --test "bin/tests/**/*.test.mjs"`, use `node:test` + `node:assert/strict`, no network, isolate filesystem with `mkdtempSync`.
|
|
17
|
+
- All side effects are **injected**: `io = { gh(args:string[]):Promise<string>, git(args:string[]):Promise<string> }`, `runner` (a `runCommand`-shaped async fn), and `now():number`. No engine module calls `gh`/`git`/the SDK directly.
|
|
18
|
+
- Windows-safe paths: use `node:path`; command names use `/` separators.
|
|
19
|
+
- Durability granularity is **per-phase**. `FORGE:STATE` is a **compact index** (no rich outputs). On divergence, **GitHub wins**.
|
|
20
|
+
- Commits: conventional (`feat(engine):`, `test(engine):`), signed off (`git commit -s`, DCO required on staging), **no AI attribution**.
|
|
21
|
+
|
|
22
|
+
## Shared types (JSDoc — referenced by every task)
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} RunState // the working state; also the shape of the compact index
|
|
27
|
+
* @property {number} v // version = last committed run-log seq (0 if none)
|
|
28
|
+
* @property {string|null} run // run id
|
|
29
|
+
* @property {number} issue
|
|
30
|
+
* @property {string} lane // "staging" | "milestone/<slug>"
|
|
31
|
+
* @property {string[]} committed // phase ids committed, in order
|
|
32
|
+
* @property {string|null} phase // current/next phase id (null before start)
|
|
33
|
+
* @property {string|null} branch
|
|
34
|
+
* @property {number|null} pr
|
|
35
|
+
* @property {boolean} terminal
|
|
36
|
+
* @property {string|null} terminalReason // "merged"|"invalid"|"needs-human"|"decomposed"|null
|
|
37
|
+
* @property {{by:string, until:number}|null} lease
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} PhaseOutcome
|
|
42
|
+
* @property {"committed"|"failed"|"blocked"} status
|
|
43
|
+
* @property {Object} [outputs] // structured facts to record (branch, pr, skipped, ...)
|
|
44
|
+
* @property {string} [detail] // human reason for blocked/failed
|
|
45
|
+
* @property {string} [terminalReason] // when a committed phase is terminal (e.g. "invalid")
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} Phase
|
|
50
|
+
* @property {string} id
|
|
51
|
+
* @property {string} command // spec name passed to runner.runCommand
|
|
52
|
+
* @property {(s:RunState)=>boolean} entryCondition
|
|
53
|
+
* @property {(s:RunState)=>boolean} [isTerminalAfter] // true if committing this phase ends the run
|
|
54
|
+
* @property {(s:RunState, io:Io)=>Promise<{satisfied:boolean, outputs?:Object}>} [reconcile]
|
|
55
|
+
* @property {(s:RunState, io:Io)=>Promise<PhaseOutcome>} detectOutcome
|
|
56
|
+
*/
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### Task 1: Run-log store (`bin/engine/runlog.mjs`)
|
|
62
|
+
|
|
63
|
+
**Files:**
|
|
64
|
+
- Create: `bin/engine/runlog.mjs`
|
|
65
|
+
- Test: `bin/tests/engine-runlog.test.mjs`
|
|
66
|
+
|
|
67
|
+
**Interfaces:**
|
|
68
|
+
- Produces: `appendEvent(dir, issue, event) => void`, `readLog(dir, issue) => object[]`, `deriveState(events) => RunState`.
|
|
69
|
+
- Consumes: nothing (leaf module).
|
|
70
|
+
|
|
71
|
+
- [ ] **Step 1: Write the failing tests**
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
// bin/tests/engine-runlog.test.mjs
|
|
75
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
76
|
+
import assert from "node:assert/strict";
|
|
77
|
+
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
|
|
78
|
+
import { tmpdir } from "node:os";
|
|
79
|
+
import { join } from "node:path";
|
|
80
|
+
import { appendEvent, readLog, deriveState } from "../engine/runlog.mjs";
|
|
81
|
+
|
|
82
|
+
let dir;
|
|
83
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "fd-runlog-")); });
|
|
84
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
85
|
+
|
|
86
|
+
describe("runlog", () => {
|
|
87
|
+
it("append then read returns events in order with assigned seq", () => {
|
|
88
|
+
appendEvent(dir, 42, { event: "RUN_START", issue: 42, run: "r1", lane: "staging" });
|
|
89
|
+
appendEvent(dir, 42, { event: "PHASE_START", phase: "investigate" });
|
|
90
|
+
const events = readLog(dir, 42);
|
|
91
|
+
assert.equal(events.length, 2);
|
|
92
|
+
assert.deepEqual(events.map(e => e.seq), [1, 2]);
|
|
93
|
+
assert.equal(events[0].event, "RUN_START");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("readLog tolerates a truncated final line (crash mid-write)", () => {
|
|
97
|
+
appendEvent(dir, 42, { event: "RUN_START", issue: 42 });
|
|
98
|
+
appendFileSync(join(dir, "42.jsonl"), '{"seq":2,"event":"PHA'); // no newline, partial JSON
|
|
99
|
+
const events = readLog(dir, 42);
|
|
100
|
+
assert.equal(events.length, 1); // partial final line ignored
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("deriveState: a PHASE_START without a following PHASE_COMMIT is NOT committed", () => {
|
|
104
|
+
appendEvent(dir, 42, { event: "RUN_START", issue: 42, run: "r1", lane: "staging" });
|
|
105
|
+
appendEvent(dir, 42, { event: "PHASE_START", phase: "investigate" });
|
|
106
|
+
appendEvent(dir, 42, { event: "PHASE_COMMIT", phase: "investigate", outputs: {} });
|
|
107
|
+
appendEvent(dir, 42, { event: "PHASE_START", phase: "build" }); // crashed here
|
|
108
|
+
const s = deriveState(readLog(dir, 42));
|
|
109
|
+
assert.deepEqual(s.committed, ["investigate"]);
|
|
110
|
+
assert.equal(s.v, 3); // last committed seq
|
|
111
|
+
assert.equal(s.terminal, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("deriveState: RUN_TERMINAL sets terminal + reason and carries branch/pr from commits", () => {
|
|
115
|
+
appendEvent(dir, 42, { event: "RUN_START", issue: 42, run: "r1", lane: "staging" });
|
|
116
|
+
appendEvent(dir, 42, { event: "PHASE_COMMIT", phase: "build", outputs: { branch: "fix/x-42" } });
|
|
117
|
+
appendEvent(dir, 42, { event: "PHASE_COMMIT", phase: "review", outputs: { pr: 7 } });
|
|
118
|
+
appendEvent(dir, 42, { event: "RUN_TERMINAL", reason: "merged" });
|
|
119
|
+
const s = deriveState(readLog(dir, 42));
|
|
120
|
+
assert.equal(s.terminal, true);
|
|
121
|
+
assert.equal(s.terminalReason, "merged");
|
|
122
|
+
assert.equal(s.branch, "fix/x-42");
|
|
123
|
+
assert.equal(s.pr, 7);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("deriveState of empty log is a zero-value state", () => {
|
|
127
|
+
const s = deriveState([]);
|
|
128
|
+
assert.equal(s.v, 0);
|
|
129
|
+
assert.deepEqual(s.committed, []);
|
|
130
|
+
assert.equal(s.phase, null);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
136
|
+
|
|
137
|
+
Run: `node --test bin/tests/engine-runlog.test.mjs`
|
|
138
|
+
Expected: FAIL — `Cannot find module '../engine/runlog.mjs'`.
|
|
139
|
+
|
|
140
|
+
- [ ] **Step 3: Write the implementation**
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
// bin/engine/runlog.mjs
|
|
144
|
+
/**
|
|
145
|
+
* Append-only per-issue run-log (the crash-safe local hot path).
|
|
146
|
+
* One JSON event per line at {dir}/{issue}.jsonl. seq is assigned monotonically.
|
|
147
|
+
*/
|
|
148
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
149
|
+
import { join, dirname } from "node:path";
|
|
150
|
+
|
|
151
|
+
function logPath(dir, issue) { return join(dir, `${issue}.jsonl`); }
|
|
152
|
+
|
|
153
|
+
/** Append an event; assigns the next seq based on the current line count. */
|
|
154
|
+
export function appendEvent(dir, issue, event) {
|
|
155
|
+
mkdirSync(dir, { recursive: true });
|
|
156
|
+
const path = logPath(dir, issue);
|
|
157
|
+
const seq = readLog(dir, issue).length + 1;
|
|
158
|
+
appendFileSync(path, JSON.stringify({ seq, ...event }) + "\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read all complete events; a truncated final line (crash mid-write) is ignored. */
|
|
162
|
+
export function readLog(dir, issue) {
|
|
163
|
+
const path = logPath(dir, issue);
|
|
164
|
+
if (!existsSync(path)) return [];
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
167
|
+
if (!line.trim()) continue;
|
|
168
|
+
try { out.push(JSON.parse(line)); } catch { /* partial final line: ignore */ }
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Fold events into a RunState. The commit rule lives here. */
|
|
174
|
+
export function deriveState(events) {
|
|
175
|
+
/** @type {import("./phases.mjs").RunState} */
|
|
176
|
+
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
177
|
+
phase: null, branch: null, pr: null, terminal: false,
|
|
178
|
+
terminalReason: null, lease: null };
|
|
179
|
+
for (const e of events) {
|
|
180
|
+
switch (e.event) {
|
|
181
|
+
case "RUN_START":
|
|
182
|
+
s.run = e.run ?? s.run; s.issue = e.issue ?? s.issue; s.lane = e.lane ?? s.lane;
|
|
183
|
+
break;
|
|
184
|
+
case "PHASE_COMMIT":
|
|
185
|
+
if (!s.committed.includes(e.phase)) s.committed.push(e.phase);
|
|
186
|
+
s.v = e.seq;
|
|
187
|
+
if (e.outputs?.branch) s.branch = e.outputs.branch;
|
|
188
|
+
if (e.outputs?.pr != null) s.pr = e.outputs.pr;
|
|
189
|
+
break;
|
|
190
|
+
case "RUN_TERMINAL":
|
|
191
|
+
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
192
|
+
break;
|
|
193
|
+
// PHASE_START / PHASE_FAILED do not change committed state
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return s;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
201
|
+
|
|
202
|
+
Run: `node --test bin/tests/engine-runlog.test.mjs`
|
|
203
|
+
Expected: PASS (5/5).
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 5: Commit**
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
git add bin/engine/runlog.mjs bin/tests/engine-runlog.test.mjs
|
|
209
|
+
git commit -s -m "feat(engine): append-only run-log with commit-rule state derivation"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
### Task 2: `FORGE:STATE` codec (`bin/engine/state.mjs`)
|
|
215
|
+
|
|
216
|
+
**Files:**
|
|
217
|
+
- Create: `bin/engine/state.mjs`
|
|
218
|
+
- Test: `bin/tests/engine-state.test.mjs`
|
|
219
|
+
|
|
220
|
+
**Interfaces:**
|
|
221
|
+
- Produces: `serializeState(index) => string`, `parseState(issueBody) => RunState|null`, `upsertStateBlock(body, index) => string`.
|
|
222
|
+
- Consumes: nothing.
|
|
223
|
+
|
|
224
|
+
- [ ] **Step 1: Write the failing tests**
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
// bin/tests/engine-state.test.mjs
|
|
228
|
+
import { describe, it } from "node:test";
|
|
229
|
+
import assert from "node:assert/strict";
|
|
230
|
+
import { serializeState, parseState, upsertStateBlock } from "../engine/state.mjs";
|
|
231
|
+
|
|
232
|
+
const idx = { v: 7, run: "r1", issue: 42, lane: "staging",
|
|
233
|
+
committed: ["investigate", "build"], phase: "review", branch: "fix/x-42",
|
|
234
|
+
pr: null, terminal: false, terminalReason: null, lease: { by: "a7", until: 1000 } };
|
|
235
|
+
|
|
236
|
+
describe("state codec", () => {
|
|
237
|
+
it("round-trips through serialize/parse", () => {
|
|
238
|
+
const body = "Some issue text.\n\n" + serializeState(idx);
|
|
239
|
+
const got = parseState(body);
|
|
240
|
+
assert.deepEqual(got, idx);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("parseState returns null when no block present", () => {
|
|
244
|
+
assert.equal(parseState("no state here"), null);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("upsertStateBlock replaces an existing block in place (no duplicate)", () => {
|
|
248
|
+
let body = "Header\n\n" + serializeState(idx) + "\n\nFooter";
|
|
249
|
+
body = upsertStateBlock(body, { ...idx, phase: "close", committed: [...idx.committed, "review"], v: 9 });
|
|
250
|
+
assert.equal((body.match(/FORGE:STATE/g) || []).length, 1);
|
|
251
|
+
assert.equal(parseState(body).phase, "close");
|
|
252
|
+
assert.match(body, /Header/); assert.match(body, /Footer/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("upsertStateBlock appends a block when none exists", () => {
|
|
256
|
+
const body = upsertStateBlock("Just text", idx);
|
|
257
|
+
assert.equal(parseState(body).v, 7);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
263
|
+
|
|
264
|
+
Run: `node --test bin/tests/engine-state.test.mjs`
|
|
265
|
+
Expected: FAIL — module not found.
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 3: Write the implementation**
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
// bin/engine/state.mjs
|
|
271
|
+
/**
|
|
272
|
+
* Codec for the single machine-readable FORGE:STATE HTML-comment block on an
|
|
273
|
+
* issue body. Carries the COMPACT INDEX only (no rich per-phase outputs).
|
|
274
|
+
*/
|
|
275
|
+
const OPEN = "<!-- FORGE:STATE";
|
|
276
|
+
const CLOSE = "-->";
|
|
277
|
+
const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
|
|
278
|
+
|
|
279
|
+
/** @param {import("./phases.mjs").RunState} index */
|
|
280
|
+
export function serializeState(index) {
|
|
281
|
+
return `${OPEN}\n${JSON.stringify(index)}\n${CLOSE}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @returns {import("./phases.mjs").RunState|null} */
|
|
285
|
+
export function parseState(issueBody) {
|
|
286
|
+
const m = BLOCK_RE.exec(issueBody || "");
|
|
287
|
+
if (!m) return null;
|
|
288
|
+
try { return JSON.parse(m[1].trim()); } catch { return null; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Replace the FORGE:STATE block in place, or append one if absent. */
|
|
292
|
+
export function upsertStateBlock(body, index) {
|
|
293
|
+
const block = serializeState(index);
|
|
294
|
+
if (BLOCK_RE.test(body || "")) return body.replace(BLOCK_RE, () => block);
|
|
295
|
+
return `${body || ""}\n\n${block}`.trimStart();
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
300
|
+
|
|
301
|
+
Run: `node --test bin/tests/engine-state.test.mjs`
|
|
302
|
+
Expected: PASS (4/4).
|
|
303
|
+
|
|
304
|
+
- [ ] **Step 5: Commit**
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
git add bin/engine/state.mjs bin/tests/engine-state.test.mjs
|
|
308
|
+
git commit -s -m "feat(engine): FORGE:STATE compact-index codec (serialize/parse/upsert)"
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
### Task 3: GitHub projector (`bin/engine/projector.mjs`)
|
|
314
|
+
|
|
315
|
+
**Files:**
|
|
316
|
+
- Create: `bin/engine/projector.mjs`
|
|
317
|
+
- Test: `bin/tests/engine-projector.test.mjs`
|
|
318
|
+
|
|
319
|
+
**Interfaces:**
|
|
320
|
+
- Consumes: `state.mjs` (`parseState`, `upsertStateBlock`).
|
|
321
|
+
- Produces: `makeProjector(io) => { readState(issue), writeState(issue, index), setLabel(issue, label), removeLabel(issue, label) }`, where `io.gh(args)` is an injected async fn returning stdout.
|
|
322
|
+
|
|
323
|
+
- [ ] **Step 1: Write the failing tests**
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
// bin/tests/engine-projector.test.mjs
|
|
327
|
+
import { describe, it } from "node:test";
|
|
328
|
+
import assert from "node:assert/strict";
|
|
329
|
+
import { makeProjector } from "../engine/projector.mjs";
|
|
330
|
+
import { serializeState } from "../engine/state.mjs";
|
|
331
|
+
|
|
332
|
+
// Fake gh: records calls, serves a scripted issue body.
|
|
333
|
+
function fakeGh(body) {
|
|
334
|
+
const calls = [];
|
|
335
|
+
const gh = async (args) => {
|
|
336
|
+
calls.push(args);
|
|
337
|
+
if (args[0] === "issue" && args[1] === "view") return JSON.stringify({ body });
|
|
338
|
+
if (args[0] === "issue" && args[1] === "edit") { body = argValue(args, "--body"); return ""; }
|
|
339
|
+
return "";
|
|
340
|
+
};
|
|
341
|
+
return { gh, calls, getBody: () => body };
|
|
342
|
+
}
|
|
343
|
+
function argValue(args, flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; }
|
|
344
|
+
|
|
345
|
+
const idx = { v: 3, run: "r1", issue: 42, lane: "staging", committed: ["investigate"],
|
|
346
|
+
phase: "build", branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
|
|
347
|
+
|
|
348
|
+
describe("projector", () => {
|
|
349
|
+
it("readState returns null when the issue has no block", async () => {
|
|
350
|
+
const f = fakeGh("plain body");
|
|
351
|
+
const p = makeProjector({ gh: f.gh });
|
|
352
|
+
assert.equal(await p.readState(42), null);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("writeState upserts the block, readState reads it back", async () => {
|
|
356
|
+
const f = fakeGh("Issue description.");
|
|
357
|
+
const p = makeProjector({ gh: f.gh });
|
|
358
|
+
await p.writeState(42, idx);
|
|
359
|
+
assert.match(f.getBody(), /Issue description\./); // original text preserved
|
|
360
|
+
assert.deepEqual(await p.readState(42), idx);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("setLabel calls gh issue edit --add-label", async () => {
|
|
364
|
+
const f = fakeGh("x");
|
|
365
|
+
const p = makeProjector({ gh: f.gh });
|
|
366
|
+
await p.setLabel(42, "needs-human");
|
|
367
|
+
assert.ok(f.calls.some(c => c.includes("--add-label") && c.includes("needs-human")));
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
373
|
+
|
|
374
|
+
Run: `node --test bin/tests/engine-projector.test.mjs`
|
|
375
|
+
Expected: FAIL — module not found.
|
|
376
|
+
|
|
377
|
+
- [ ] **Step 3: Write the implementation**
|
|
378
|
+
|
|
379
|
+
```js
|
|
380
|
+
// bin/engine/projector.mjs
|
|
381
|
+
/**
|
|
382
|
+
* GitHub adapter: reads/writes the FORGE:STATE block and workflow labels.
|
|
383
|
+
* `io.gh(args)` is injected (async, returns stdout) so this is testable offline.
|
|
384
|
+
*/
|
|
385
|
+
import { parseState, upsertStateBlock } from "./state.mjs";
|
|
386
|
+
|
|
387
|
+
export function makeProjector(io) {
|
|
388
|
+
const gh = io.gh;
|
|
389
|
+
|
|
390
|
+
async function getBody(issue) {
|
|
391
|
+
const out = await gh(["issue", "view", String(issue), "--json", "body"]);
|
|
392
|
+
try { return JSON.parse(out).body ?? ""; } catch { return ""; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
/** @returns {Promise<import("./phases.mjs").RunState|null>} */
|
|
397
|
+
async readState(issue) { return parseState(await getBody(issue)); },
|
|
398
|
+
|
|
399
|
+
async writeState(issue, index) {
|
|
400
|
+
const body = upsertStateBlock(await getBody(issue), index);
|
|
401
|
+
await gh(["issue", "edit", String(issue), "--body", body]);
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async setLabel(issue, label) {
|
|
405
|
+
await gh(["issue", "edit", String(issue), "--add-label", label]);
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
async removeLabel(issue, label) {
|
|
409
|
+
await gh(["issue", "edit", String(issue), "--remove-label", label]);
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
416
|
+
|
|
417
|
+
Run: `node --test bin/tests/engine-projector.test.mjs`
|
|
418
|
+
Expected: PASS (3/3).
|
|
419
|
+
|
|
420
|
+
- [ ] **Step 5: Commit**
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
git add bin/engine/projector.mjs bin/tests/engine-projector.test.mjs
|
|
424
|
+
git commit -s -m "feat(engine): GitHub projector for FORGE:STATE mirror + labels"
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
### Task 4: Phase table + `pickPhase` (`bin/engine/phases.mjs`)
|
|
430
|
+
|
|
431
|
+
**Files:**
|
|
432
|
+
- Create: `bin/engine/phases.mjs`
|
|
433
|
+
- Test: `bin/tests/engine-phases.test.mjs`
|
|
434
|
+
|
|
435
|
+
**Interfaces:**
|
|
436
|
+
- Produces: `PHASES` (Phase[]), `pickPhase(state) => Phase|null`, `TERMINAL_REASONS` (string[]).
|
|
437
|
+
- Consumes: `io` at call time for `reconcile`/`detectOutcome` (injected by the engine).
|
|
438
|
+
|
|
439
|
+
Each phase's `detectOutcome` inspects GitHub *after* the run (via `io.gh`/`io.git`) — the runner's return status is advisory only. The `build` phase encodes the #1305 fix (requires `FORGE:BUILDER:COMPLETE` **and** commits ahead of base).
|
|
440
|
+
|
|
441
|
+
- [ ] **Step 1: Write the failing tests**
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
// bin/tests/engine-phases.test.mjs
|
|
445
|
+
import { describe, it } from "node:test";
|
|
446
|
+
import assert from "node:assert/strict";
|
|
447
|
+
import { PHASES, pickPhase } from "../engine/phases.mjs";
|
|
448
|
+
|
|
449
|
+
const base = { v: 0, run: "r1", issue: 42, lane: "staging", committed: [], phase: null,
|
|
450
|
+
branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
|
|
451
|
+
|
|
452
|
+
describe("pickPhase", () => {
|
|
453
|
+
it("returns the first uncommitted phase whose entryCondition holds", () => {
|
|
454
|
+
assert.equal(pickPhase(base).id, "investigate");
|
|
455
|
+
assert.equal(pickPhase({ ...base, committed: ["investigate"] }).id, "context");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("returns null once all phases are committed", () => {
|
|
459
|
+
const all = PHASES.map(p => p.id);
|
|
460
|
+
assert.equal(pickPhase({ ...base, committed: all }), null);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("returns null when the state is already terminal", () => {
|
|
464
|
+
assert.equal(pickPhase({ ...base, terminal: true, terminalReason: "invalid" }), null);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("build.detectOutcome fails when there are no commits ahead of base (encodes #1305)", async () => {
|
|
468
|
+
const build = PHASES.find(p => p.id === "build");
|
|
469
|
+
const io = {
|
|
470
|
+
gh: async () => JSON.stringify([{ body: "<!-- FORGE:BUILDER --> done <!-- FORGE:BUILDER:COMPLETE -->" }]),
|
|
471
|
+
git: async () => "0", // rev-list count = 0 commits ahead
|
|
472
|
+
};
|
|
473
|
+
const outcome = await build.detectOutcome({ ...base, branch: "fix/x-42" }, io);
|
|
474
|
+
assert.equal(outcome.status, "failed");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("build.detectOutcome commits when :COMPLETE marker present AND commits exist", async () => {
|
|
478
|
+
const build = PHASES.find(p => p.id === "build");
|
|
479
|
+
const io = {
|
|
480
|
+
gh: async () => JSON.stringify([{ body: "<!-- FORGE:BUILDER:COMPLETE -->" }]),
|
|
481
|
+
git: async () => "2",
|
|
482
|
+
};
|
|
483
|
+
const outcome = await build.detectOutcome({ ...base, branch: "fix/x-42" }, io);
|
|
484
|
+
assert.equal(outcome.status, "committed");
|
|
485
|
+
assert.equal(outcome.outputs.branch, "fix/x-42");
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
491
|
+
|
|
492
|
+
Run: `node --test bin/tests/engine-phases.test.mjs`
|
|
493
|
+
Expected: FAIL — module not found.
|
|
494
|
+
|
|
495
|
+
- [ ] **Step 3: Write the implementation**
|
|
496
|
+
|
|
497
|
+
```js
|
|
498
|
+
// bin/engine/phases.mjs
|
|
499
|
+
/**
|
|
500
|
+
* Declarative phase table for the headless work-on pipeline. The ENGINE (not an
|
|
501
|
+
* LLM) chooses the next phase via pickPhase. Each phase's outcome is read from
|
|
502
|
+
* GitHub/git AFTER the run (detectOutcome); the runner's return is advisory.
|
|
503
|
+
* @typedef ... (see plan "Shared types")
|
|
504
|
+
*/
|
|
505
|
+
|
|
506
|
+
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
|
|
507
|
+
|
|
508
|
+
/** Fetch the issue's comment bodies as one blob for marker checks. */
|
|
509
|
+
async function issueMarkers(issue, io) {
|
|
510
|
+
const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", ".[].body"]);
|
|
511
|
+
return out || "";
|
|
512
|
+
}
|
|
513
|
+
/** Count commits on branch ahead of the lane base. */
|
|
514
|
+
async function commitsAhead(state, io) {
|
|
515
|
+
const n = await io.git(["rev-list", "--count", `origin/${state.lane}..${state.branch}`]);
|
|
516
|
+
return parseInt(String(n).trim(), 10) || 0;
|
|
517
|
+
}
|
|
518
|
+
function has(blob, marker) { return blob.includes(marker); }
|
|
519
|
+
|
|
520
|
+
/** @type {Phase[]} */
|
|
521
|
+
export const PHASES = [
|
|
522
|
+
{
|
|
523
|
+
id: "investigate",
|
|
524
|
+
command: "work-on/investigate",
|
|
525
|
+
entryCondition: () => true,
|
|
526
|
+
async detectOutcome(state, io) {
|
|
527
|
+
const m = await issueMarkers(state.issue, io);
|
|
528
|
+
if (has(m, "INVESTIGATION:INVALID"))
|
|
529
|
+
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
530
|
+
if (has(m, "DECOMPOSE:YES"))
|
|
531
|
+
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
532
|
+
if (has(m, "INVESTIGATION:COMPLETE"))
|
|
533
|
+
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
534
|
+
return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
|
|
535
|
+
},
|
|
536
|
+
isTerminalAfter: (s) => s.terminalReason === "invalid" || s.terminalReason === "decomposed",
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
id: "context",
|
|
540
|
+
command: "work-on/build/context",
|
|
541
|
+
entryCondition: (s) => s.committed.includes("investigate"),
|
|
542
|
+
async detectOutcome(state, io) {
|
|
543
|
+
const m = await issueMarkers(state.issue, io);
|
|
544
|
+
// Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
|
|
545
|
+
if (has(m, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
|
|
546
|
+
return { status: "committed", outputs: { skipped: true, which: "context" } };
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
id: "architect",
|
|
551
|
+
command: "work-on/build/architect",
|
|
552
|
+
entryCondition: (s) => s.committed.includes("context"),
|
|
553
|
+
async detectOutcome(state, io) {
|
|
554
|
+
const m = await issueMarkers(state.issue, io);
|
|
555
|
+
return has(m, "FORGE:ARCHITECT")
|
|
556
|
+
? { status: "committed", outputs: {} }
|
|
557
|
+
: { status: "failed", detail: "no FORGE:ARCHITECT" };
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: "build",
|
|
562
|
+
command: "work-on/build",
|
|
563
|
+
entryCondition: (s) => s.committed.includes("architect"),
|
|
564
|
+
async reconcile(state, io) {
|
|
565
|
+
// Idempotent resume: branch already ahead of base → treat as done, skip the LLM.
|
|
566
|
+
if (state.branch && (await commitsAhead(state, io)) > 0) {
|
|
567
|
+
const m = await issueMarkers(state.issue, io);
|
|
568
|
+
if (has(m, "FORGE:BUILDER:COMPLETE")) return { satisfied: true, outputs: { branch: state.branch } };
|
|
569
|
+
}
|
|
570
|
+
return { satisfied: false };
|
|
571
|
+
},
|
|
572
|
+
async detectOutcome(state, io) {
|
|
573
|
+
const m = await issueMarkers(state.issue, io);
|
|
574
|
+
const complete = has(m, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
|
|
575
|
+
const ahead = state.branch ? await commitsAhead(state, io) : 0; // … AND real commits
|
|
576
|
+
if (complete && ahead > 0) return { status: "committed", outputs: { branch: state.branch } };
|
|
577
|
+
return { status: "failed", detail: `builder complete=${complete} commitsAhead=${ahead}` };
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
id: "review",
|
|
582
|
+
command: "work-on/review",
|
|
583
|
+
entryCondition: (s) => s.committed.includes("build"),
|
|
584
|
+
async reconcile(state, io) {
|
|
585
|
+
const pr = await openPrFor(state, io); // adopt an existing PR instead of opening a second
|
|
586
|
+
return pr ? { satisfied: false, outputs: { pr } } : { satisfied: false };
|
|
587
|
+
},
|
|
588
|
+
async detectOutcome(state, io) {
|
|
589
|
+
const pr = await prStatusFor(state, io);
|
|
590
|
+
if (!pr) return { status: "failed", detail: "no PR created" };
|
|
591
|
+
if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
|
|
592
|
+
if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
|
|
593
|
+
return { status: "failed", detail: "PR open, not merged" };
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
id: "close",
|
|
598
|
+
command: "work-on/close",
|
|
599
|
+
entryCondition: (s) => s.committed.includes("review"),
|
|
600
|
+
async detectOutcome(state, io) {
|
|
601
|
+
const out = await io.gh(["issue", "view", String(state.issue), "--json", "state,labels"]);
|
|
602
|
+
const j = JSON.parse(out || "{}");
|
|
603
|
+
const labels = (j.labels || []).map((l) => l.name || l);
|
|
604
|
+
if (j.state === "CLOSED" || labels.includes("workflow:merged"))
|
|
605
|
+
return { status: "committed", terminalReason: "merged", outputs: {} };
|
|
606
|
+
return { status: "failed", detail: "issue not closed" };
|
|
607
|
+
},
|
|
608
|
+
isTerminalAfter: () => true,
|
|
609
|
+
},
|
|
610
|
+
];
|
|
611
|
+
|
|
612
|
+
async function openPrFor(state, io) {
|
|
613
|
+
if (!state.branch) return null;
|
|
614
|
+
const out = await io.gh(["pr", "list", "--head", state.branch, "--json", "number", "--state", "all"]);
|
|
615
|
+
try { const a = JSON.parse(out || "[]"); return a[0]?.number ?? null; } catch { return null; }
|
|
616
|
+
}
|
|
617
|
+
async function prStatusFor(state, io) {
|
|
618
|
+
const n = await openPrFor(state, io);
|
|
619
|
+
if (!n) return null;
|
|
620
|
+
const out = await io.gh(["pr", "view", String(n), "--json", "number,state,labels,mergedAt"]);
|
|
621
|
+
const j = JSON.parse(out || "{}");
|
|
622
|
+
const labels = (j.labels || []).map((l) => l.name || l);
|
|
623
|
+
return { number: j.number, merged: !!j.mergedAt || j.state === "MERGED",
|
|
624
|
+
needsHuman: labels.includes("needs-human") };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The engine's transition function: first uncommitted phase whose gate holds. */
|
|
628
|
+
export function pickPhase(state) {
|
|
629
|
+
if (state.terminal) return null;
|
|
630
|
+
for (const p of PHASES) {
|
|
631
|
+
if (state.committed.includes(p.id)) continue;
|
|
632
|
+
if (p.entryCondition(state)) return p;
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
639
|
+
|
|
640
|
+
Run: `node --test bin/tests/engine-phases.test.mjs`
|
|
641
|
+
Expected: PASS (5/5).
|
|
642
|
+
|
|
643
|
+
- [ ] **Step 5: Commit**
|
|
644
|
+
|
|
645
|
+
```bash
|
|
646
|
+
git add bin/engine/phases.mjs bin/tests/engine-phases.test.mjs
|
|
647
|
+
git commit -s -m "feat(engine): declarative phase table + deterministic pickPhase"
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
### Task 5: State reconciliation (`bin/engine/reconcile.mjs`)
|
|
653
|
+
|
|
654
|
+
**Files:**
|
|
655
|
+
- Create: `bin/engine/reconcile.mjs`
|
|
656
|
+
- Test: `bin/tests/engine-reconcile.test.mjs`
|
|
657
|
+
|
|
658
|
+
**Interfaces:**
|
|
659
|
+
- Produces: `reconcileState(local, remote) => { state:RunState, action:"local"|"hydrate"|"remirror"|"fresh" }`.
|
|
660
|
+
- Consumes: nothing (pure).
|
|
661
|
+
|
|
662
|
+
- [ ] **Step 1: Write the failing tests**
|
|
663
|
+
|
|
664
|
+
```js
|
|
665
|
+
// bin/tests/engine-reconcile.test.mjs
|
|
666
|
+
import { describe, it } from "node:test";
|
|
667
|
+
import assert from "node:assert/strict";
|
|
668
|
+
import { reconcileState } from "../engine/reconcile.mjs";
|
|
669
|
+
|
|
670
|
+
const mk = (v, phase) => ({ v, run: "r1", issue: 42, lane: "staging",
|
|
671
|
+
committed: [], phase, branch: null, pr: null, terminal: false, terminalReason: null, lease: null });
|
|
672
|
+
|
|
673
|
+
describe("reconcileState (GitHub wins)", () => {
|
|
674
|
+
it("no remote, no local → fresh", () => {
|
|
675
|
+
assert.equal(reconcileState(null, null).action, "fresh");
|
|
676
|
+
});
|
|
677
|
+
it("no local, remote present → hydrate from remote", () => {
|
|
678
|
+
const r = reconcileState(null, mk(3, "build"));
|
|
679
|
+
assert.equal(r.action, "hydrate"); assert.equal(r.state.phase, "build");
|
|
680
|
+
});
|
|
681
|
+
it("remote ahead of local → GitHub wins (hydrate), discard local", () => {
|
|
682
|
+
const r = reconcileState(mk(3, "build"), mk(5, "review"));
|
|
683
|
+
assert.equal(r.action, "hydrate"); assert.equal(r.state.v, 5);
|
|
684
|
+
});
|
|
685
|
+
it("local ahead of remote → keep local, re-mirror", () => {
|
|
686
|
+
const r = reconcileState(mk(5, "review"), mk(3, "build"));
|
|
687
|
+
assert.equal(r.action, "remirror"); assert.equal(r.state.v, 5);
|
|
688
|
+
});
|
|
689
|
+
it("in sync → keep local", () => {
|
|
690
|
+
const r = reconcileState(mk(3, "build"), mk(3, "build"));
|
|
691
|
+
assert.equal(r.action, "local"); assert.equal(r.state.v, 3);
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
697
|
+
|
|
698
|
+
Run: `node --test bin/tests/engine-reconcile.test.mjs`
|
|
699
|
+
Expected: FAIL — module not found.
|
|
700
|
+
|
|
701
|
+
- [ ] **Step 3: Write the implementation**
|
|
702
|
+
|
|
703
|
+
```js
|
|
704
|
+
// bin/engine/reconcile.mjs
|
|
705
|
+
/**
|
|
706
|
+
* Merge the local run-log state and the remote FORGE:STATE index.
|
|
707
|
+
* Rule: GitHub wins on divergence (spec §5.3).
|
|
708
|
+
*/
|
|
709
|
+
export function reconcileState(local, remote) {
|
|
710
|
+
if (!remote) return { state: local ?? null, action: local ? "local" : "fresh" };
|
|
711
|
+
if (!local) return { state: remote, action: "hydrate" };
|
|
712
|
+
if (remote.v > local.v) return { state: remote, action: "hydrate" }; // advanced elsewhere
|
|
713
|
+
if (remote.v < local.v) return { state: local, action: "remirror" }; // crashed pre-mirror
|
|
714
|
+
return { state: local, action: "local" };
|
|
715
|
+
}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
719
|
+
|
|
720
|
+
Run: `node --test bin/tests/engine-reconcile.test.mjs`
|
|
721
|
+
Expected: PASS (5/5).
|
|
722
|
+
|
|
723
|
+
- [ ] **Step 5: Commit**
|
|
724
|
+
|
|
725
|
+
```bash
|
|
726
|
+
git add bin/engine/reconcile.mjs bin/tests/engine-reconcile.test.mjs
|
|
727
|
+
git commit -s -m "feat(engine): state reconciliation (GitHub wins on divergence)"
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
---
|
|
731
|
+
|
|
732
|
+
### Task 6: Engine loop (`bin/engine.mjs`)
|
|
733
|
+
|
|
734
|
+
**Files:**
|
|
735
|
+
- Create: `bin/engine.mjs`
|
|
736
|
+
- Test: `bin/tests/engine.test.mjs`
|
|
737
|
+
|
|
738
|
+
**Interfaces:**
|
|
739
|
+
- Consumes: `runlog.mjs`, `phases.mjs`, `reconcile.mjs`, `projector.mjs`.
|
|
740
|
+
- Produces: `runIssue({ issue, dir, agentId, lane, io, runner, now, maxAttempts }) => Promise<{terminalReason:string}>`.
|
|
741
|
+
- `io = { gh, git }` (async, injected). `runner` is a `runCommand`-shaped async fn. `now()` returns ms. `dir` is the run-log directory. `maxAttempts` defaults to 3.
|
|
742
|
+
|
|
743
|
+
- [ ] **Step 1: Write the failing test (golden path + visible block)**
|
|
744
|
+
|
|
745
|
+
```js
|
|
746
|
+
// bin/tests/engine.test.mjs
|
|
747
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
748
|
+
import assert from "node:assert/strict";
|
|
749
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
750
|
+
import { tmpdir } from "node:os";
|
|
751
|
+
import { join } from "node:path";
|
|
752
|
+
import { runIssue } from "../engine.mjs";
|
|
753
|
+
import { readLog, deriveState } from "../engine/runlog.mjs";
|
|
754
|
+
|
|
755
|
+
let dir; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "fd-engine-")); });
|
|
756
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
757
|
+
|
|
758
|
+
// A scriptable fake GitHub/git world whose markers advance as phases "run".
|
|
759
|
+
function fakeWorld() {
|
|
760
|
+
const w = { markers: "", pr: null, prMerged: false, prNeedsHuman: false,
|
|
761
|
+
issueState: "OPEN", labels: [], commitsAhead: 0, body: "Issue." };
|
|
762
|
+
const io = {
|
|
763
|
+
gh: async (args) => {
|
|
764
|
+
const a = args.join(" ");
|
|
765
|
+
if (a.startsWith("api ") && a.includes("/comments")) return w.markers;
|
|
766
|
+
if (a.startsWith("issue view") && a.includes("body")) return JSON.stringify({ body: w.body });
|
|
767
|
+
if (a.startsWith("issue view")) return JSON.stringify({ state: w.issueState, labels: w.labels });
|
|
768
|
+
if (a.startsWith("issue edit")) { const i = args.indexOf("--body"); if (i>=0) w.body = args[i+1];
|
|
769
|
+
const j = args.indexOf("--add-label"); if (j>=0) w.labels.push(args[j+1]); return ""; }
|
|
770
|
+
if (a.startsWith("pr list")) return JSON.stringify(w.pr ? [{ number: w.pr }] : []);
|
|
771
|
+
if (a.startsWith("pr view")) return JSON.stringify({ number: w.pr, state: w.prMerged?"MERGED":"OPEN",
|
|
772
|
+
mergedAt: w.prMerged ? "t" : null, labels: w.prNeedsHuman ? [{name:"needs-human"}] : [] });
|
|
773
|
+
return "";
|
|
774
|
+
},
|
|
775
|
+
git: async () => String(w.commitsAhead),
|
|
776
|
+
};
|
|
777
|
+
return { w, io };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
describe("runIssue", () => {
|
|
781
|
+
it("drives investigate→close to merged, committing every phase", async () => {
|
|
782
|
+
const { w, io } = fakeWorld();
|
|
783
|
+
// Each phase run advances the world so detectOutcome sees a committed result.
|
|
784
|
+
const script = {
|
|
785
|
+
"work-on/investigate": () => { w.markers += " INVESTIGATION:COMPLETE"; },
|
|
786
|
+
"work-on/build/context": () => { w.markers += " FORGE:CONTEXT"; },
|
|
787
|
+
"work-on/build/architect": () => { w.markers += " FORGE:ARCHITECT"; },
|
|
788
|
+
"work-on/build": () => { w.markers += " FORGE:BUILDER:COMPLETE"; w.commitsAhead = 2; },
|
|
789
|
+
"work-on/review": () => { w.pr = 7; w.prMerged = true; },
|
|
790
|
+
"work-on/close": () => { w.issueState = "CLOSED"; },
|
|
791
|
+
};
|
|
792
|
+
const runner = async ({ commandName }) => { script[commandName](); return { status: "complete" }; };
|
|
793
|
+
|
|
794
|
+
const res = await runIssue({ issue: 42, dir, agentId: "a1", lane: "staging",
|
|
795
|
+
io, runner, now: () => 1000, maxAttempts: 3 });
|
|
796
|
+
|
|
797
|
+
assert.equal(res.terminalReason, "merged");
|
|
798
|
+
const s = deriveState(readLog(dir, 42));
|
|
799
|
+
assert.deepEqual(s.committed, ["investigate","context","architect","build","review","close"]);
|
|
800
|
+
assert.equal(s.branch, "fix/pipeline-42"); // set by engine before build (see impl)
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("stops at needs-human when a phase reports blocked (no silent merge)", async () => {
|
|
804
|
+
const { w, io } = fakeWorld();
|
|
805
|
+
const script = {
|
|
806
|
+
"work-on/investigate": () => { w.markers += " INVESTIGATION:COMPLETE"; },
|
|
807
|
+
"work-on/build/context": () => { w.markers += " FORGE:CONTEXT"; },
|
|
808
|
+
"work-on/build/architect": () => { w.markers += " FORGE:ARCHITECT"; },
|
|
809
|
+
"work-on/build": () => { w.markers += " FORGE:BUILDER:COMPLETE"; w.commitsAhead = 1; },
|
|
810
|
+
"work-on/review": () => { w.pr = 7; w.prNeedsHuman = true; },
|
|
811
|
+
};
|
|
812
|
+
const runner = async ({ commandName }) => { script[commandName]?.(); return { status: "complete" }; };
|
|
813
|
+
const res = await runIssue({ issue: 42, dir, agentId: "a1", lane: "staging",
|
|
814
|
+
io, runner, now: () => 1000, maxAttempts: 1 });
|
|
815
|
+
assert.equal(res.terminalReason, "needs-human");
|
|
816
|
+
assert.ok(w.labels.includes("needs-human"));
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
822
|
+
|
|
823
|
+
Run: `node --test bin/tests/engine.test.mjs`
|
|
824
|
+
Expected: FAIL — `Cannot find module '../engine.mjs'`.
|
|
825
|
+
|
|
826
|
+
- [ ] **Step 3: Write the implementation**
|
|
827
|
+
|
|
828
|
+
```js
|
|
829
|
+
// bin/engine.mjs
|
|
830
|
+
/**
|
|
831
|
+
* Durable per-phase engine loop. Drives one pipeline phase at a time via an
|
|
832
|
+
* injected runner (runCommand-shaped), determining each phase's outcome from
|
|
833
|
+
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
834
|
+
*/
|
|
835
|
+
import { fileURLToPath } from "node:url";
|
|
836
|
+
import { appendEvent, readLog, deriveState } from "./engine/runlog.mjs";
|
|
837
|
+
import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
|
|
838
|
+
import { reconcileState } from "./engine/reconcile.mjs";
|
|
839
|
+
import { makeProjector } from "./engine/projector.mjs";
|
|
840
|
+
|
|
841
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
842
|
+
|
|
843
|
+
export async function runIssue(opts) {
|
|
844
|
+
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
845
|
+
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
846
|
+
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)) } = opts;
|
|
847
|
+
const projector = makeProjector(io);
|
|
848
|
+
|
|
849
|
+
// 1. Load + reconcile (GitHub wins).
|
|
850
|
+
const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
|
|
851
|
+
const remote = await projector.readState(issue);
|
|
852
|
+
let { state, action } = reconcileState(local, remote);
|
|
853
|
+
if (!state) {
|
|
854
|
+
state = freshState(issue, lane);
|
|
855
|
+
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
856
|
+
await projector.writeState(issue, state);
|
|
857
|
+
} else if (action === "remirror") {
|
|
858
|
+
await projector.writeState(issue, state);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 2. Drive phases until terminal.
|
|
862
|
+
let phase;
|
|
863
|
+
while ((phase = pickPhase(state))) {
|
|
864
|
+
// Every issue's build works on a deterministic branch; set it before build runs.
|
|
865
|
+
if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
|
|
866
|
+
|
|
867
|
+
const reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
|
|
868
|
+
let outcome;
|
|
869
|
+
if (reconciled.satisfied) {
|
|
870
|
+
outcome = { status: "committed", outputs: reconciled.outputs || {} };
|
|
871
|
+
} else {
|
|
872
|
+
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
873
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts });
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
|
|
877
|
+
|
|
878
|
+
// committed
|
|
879
|
+
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {} });
|
|
880
|
+
state = deriveState(readLog(dir, issue));
|
|
881
|
+
if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
|
|
882
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + 600000 } });
|
|
883
|
+
|
|
884
|
+
if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason))
|
|
885
|
+
return await terminate(state, outcome.terminalReason);
|
|
886
|
+
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
887
|
+
return await terminate(state, state.terminalReason || "merged");
|
|
888
|
+
}
|
|
889
|
+
return await terminate(state, state.terminalReason || "merged");
|
|
890
|
+
|
|
891
|
+
async function terminate(s, reason, detail) {
|
|
892
|
+
appendEvent(dir, issue, { event: "RUN_TERMINAL", reason });
|
|
893
|
+
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
|
|
894
|
+
await projector.writeState(issue, final);
|
|
895
|
+
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
896
|
+
return { terminalReason: reason, detail };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function runPhaseWithRetry(phase, state, ctx) {
|
|
901
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts } = ctx;
|
|
902
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
903
|
+
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
904
|
+
try {
|
|
905
|
+
await runner({ commandsDir, commandName: phase.command, args: [String(issue)] });
|
|
906
|
+
} catch (e) {
|
|
907
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message });
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const outcome = await phase.detectOutcome(state, io);
|
|
911
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
|
|
912
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
|
|
913
|
+
}
|
|
914
|
+
// Exhausted transient retries → escalate (spec §7).
|
|
915
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function freshState(issue, lane) {
|
|
919
|
+
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
920
|
+
branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
925
|
+
|
|
926
|
+
Run: `node --test bin/tests/engine.test.mjs`
|
|
927
|
+
Expected: PASS (2/2).
|
|
928
|
+
|
|
929
|
+
- [ ] **Step 5: Commit**
|
|
930
|
+
|
|
931
|
+
```bash
|
|
932
|
+
git add bin/engine.mjs bin/tests/engine.test.mjs
|
|
933
|
+
git commit -s -m "feat(engine): durable per-phase loop (runIssue) with retry + needs-human"
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
938
|
+
### Task 7: Crash-injection durability test (`bin/tests/engine-crash.test.mjs`)
|
|
939
|
+
|
|
940
|
+
**Files:**
|
|
941
|
+
- Create: `bin/tests/engine-crash.test.mjs`
|
|
942
|
+
|
|
943
|
+
**Interfaces:**
|
|
944
|
+
- Consumes: `runIssue` (Task 6), `runlog` helpers. No new production code — this task proves the durability property.
|
|
945
|
+
|
|
946
|
+
- [ ] **Step 1: Write the failing test**
|
|
947
|
+
|
|
948
|
+
```js
|
|
949
|
+
// bin/tests/engine-crash.test.mjs
|
|
950
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
951
|
+
import assert from "node:assert/strict";
|
|
952
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
953
|
+
import { tmpdir } from "node:os";
|
|
954
|
+
import { join } from "node:path";
|
|
955
|
+
import { runIssue } from "../engine.mjs";
|
|
956
|
+
import { readLog, deriveState } from "../engine/runlog.mjs";
|
|
957
|
+
|
|
958
|
+
let dir; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "fd-crash-")); });
|
|
959
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
960
|
+
|
|
961
|
+
function fakeWorld() {
|
|
962
|
+
const w = { markers:"", pr:null, prMerged:false, issueState:"OPEN", labels:[], commitsAhead:0, body:"Issue." };
|
|
963
|
+
const io = { gh: async (args) => { const a=args.join(" ");
|
|
964
|
+
if (a.includes("/comments")) return w.markers;
|
|
965
|
+
if (a.startsWith("issue view") && a.includes("body")) return JSON.stringify({ body:w.body });
|
|
966
|
+
if (a.startsWith("issue view")) return JSON.stringify({ state:w.issueState, labels:w.labels });
|
|
967
|
+
if (a.startsWith("issue edit")) { const i=args.indexOf("--body"); if(i>=0) w.body=args[i+1];
|
|
968
|
+
const j=args.indexOf("--add-label"); if(j>=0) w.labels.push(args[j+1]); return ""; }
|
|
969
|
+
if (a.startsWith("pr list")) return JSON.stringify(w.pr?[{number:w.pr}]:[]);
|
|
970
|
+
if (a.startsWith("pr view")) return JSON.stringify({ number:w.pr, state:w.prMerged?"MERGED":"OPEN", mergedAt:w.prMerged?"t":null, labels:[] });
|
|
971
|
+
return ""; }, git: async () => String(w.commitsAhead) };
|
|
972
|
+
return { w, io };
|
|
973
|
+
}
|
|
974
|
+
const scriptFor = (w) => ({
|
|
975
|
+
"work-on/investigate": () => { w.markers += " INVESTIGATION:COMPLETE"; },
|
|
976
|
+
"work-on/build/context": () => { w.markers += " FORGE:CONTEXT"; },
|
|
977
|
+
"work-on/build/architect": () => { w.markers += " FORGE:ARCHITECT"; },
|
|
978
|
+
"work-on/build": () => { w.markers += " FORGE:BUILDER:COMPLETE"; w.commitsAhead = 2; },
|
|
979
|
+
"work-on/review": () => { w.pr = 7; w.prMerged = true; },
|
|
980
|
+
"work-on/close": () => { w.issueState = "CLOSED"; },
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
describe("crash injection", () => {
|
|
984
|
+
it("resumes correctly when the process dies after each phase", async () => {
|
|
985
|
+
const { w, io } = fakeWorld();
|
|
986
|
+
const script = scriptFor(w);
|
|
987
|
+
const order = ["work-on/investigate","work-on/build/context","work-on/build/architect","work-on/build","work-on/review","work-on/close"];
|
|
988
|
+
|
|
989
|
+
// Run 1..N: each run "crashes" (throws) right after the k-th phase's runner executes.
|
|
990
|
+
for (let crashAfter = 1; crashAfter <= order.length; crashAfter++) {
|
|
991
|
+
let seen = 0;
|
|
992
|
+
const runner = async ({ commandName }) => {
|
|
993
|
+
script[commandName](); // real side effect happens…
|
|
994
|
+
seen++;
|
|
995
|
+
if (seen === crashAfter) throw new Error("SIMULATED CRASH"); // …then the process dies
|
|
996
|
+
return { status: "complete" };
|
|
997
|
+
};
|
|
998
|
+
try { await runIssue({ issue: 42, dir, agentId: "a1", lane: "staging", io, runner, now: () => 1 }); }
|
|
999
|
+
catch { /* crashed; next run resumes from the log */ }
|
|
1000
|
+
}
|
|
1001
|
+
// Final clean run finishes whatever remains.
|
|
1002
|
+
const runner = async ({ commandName }) => { script[commandName](); return { status: "complete" }; };
|
|
1003
|
+
const res = await runIssue({ issue: 42, dir, agentId: "a1", lane: "staging", io, runner, now: () => 1 });
|
|
1004
|
+
|
|
1005
|
+
assert.equal(res.terminalReason, "merged");
|
|
1006
|
+
const s = deriveState(readLog(dir, 42));
|
|
1007
|
+
assert.deepEqual(s.committed, ["investigate","context","architect","build","review","close"]);
|
|
1008
|
+
// No phase double-committed (idempotent): each id appears once.
|
|
1009
|
+
assert.equal(new Set(s.committed).size, s.committed.length);
|
|
1010
|
+
// Exactly one PR was ever adopted (review.reconcile prevented a second).
|
|
1011
|
+
assert.equal(w.pr, 7);
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
> **CORRECTION (supersedes the code block above).** Injecting the crash from the `runner` does NOT work: `runPhaseWithRetry` catches a thrown runner as a retryable `PHASE_FAILED` (so the run completes in one launch — a false positive), and `maxAttempts: 1` makes a single failure escalate straight to `needs-human` (breaking the run), not resume. A true process death must be injected where `runIssue` does NOT internally catch it: (1) the `projector.writeState` FORGE:STATE mirror (engine.mjs:53), which fires *after* the local `PHASE_COMMIT` is appended — resume must continue from the run-log; and (2) `phase.detectOutcome` (engine.mjs:81), which fires *after* a phase's side effect but *before* its commit — the phase must re-run idempotently. The committed `bin/tests/engine-crash.test.mjs` injects at both points (via a fake `io.gh`/`io.git` that throws once), relaunches `runIssue` like a supervisor, and asserts `launches >= 2` so the crash provably fired.
|
|
1017
|
+
|
|
1018
|
+
- [ ] **Step 2: Run the test to verify it fails (if it does), then reconcile**
|
|
1019
|
+
|
|
1020
|
+
Run: `node --test bin/tests/engine-crash.test.mjs`
|
|
1021
|
+
Expected: 9/9 PASS — 8 mirror-crash points (initial state write + 6 phase commits + terminate) plus 1 mid-phase build crash, each asserting `launches >= 2` so resume is provably exercised (see the CORRECTION note below for why the crash is injected at the mirror/detectOutcome, not the runner).
|
|
1022
|
+
|
|
1023
|
+
- [ ] **Step 3: Commit**
|
|
1024
|
+
|
|
1025
|
+
```bash
|
|
1026
|
+
git add bin/tests/engine-crash.test.mjs
|
|
1027
|
+
git commit -s -m "test(engine): crash-injection proves per-phase resume + idempotency"
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
---
|
|
1031
|
+
|
|
1032
|
+
### Task 8: Headless CLI entry + stall scan (`bin/engine-cli.mjs`, wire into `bin/forgedock.mjs`)
|
|
1033
|
+
|
|
1034
|
+
**Files:**
|
|
1035
|
+
- Create: `bin/engine-cli.mjs`
|
|
1036
|
+
- Modify: `bin/forgedock.mjs` (add a `run-issue` subcommand case in the command switch near the existing `install/init/...` dispatch)
|
|
1037
|
+
- Test: `bin/tests/engine-cli.test.mjs`
|
|
1038
|
+
|
|
1039
|
+
**Interfaces:**
|
|
1040
|
+
- Consumes: `runIssue` (Task 6).
|
|
1041
|
+
- Produces: `makeIo() => {gh, git}` (real `gh`/`git` via `execFile`), `scanStalls(issues, dir, io, now) => number[]` (issues whose lease expired on a non-terminal state), and `runFromCli(argv)`.
|
|
1042
|
+
|
|
1043
|
+
Note: the subcommand is named `run-issue`, not `run`, to avoid colliding with the pre-existing `forgedock run <command>` spec-runner from #1151.
|
|
1044
|
+
|
|
1045
|
+
- [ ] **Step 1: Write the failing test for `scanStalls` (pure, no network)**
|
|
1046
|
+
|
|
1047
|
+
```js
|
|
1048
|
+
// bin/tests/engine-cli.test.mjs
|
|
1049
|
+
import { describe, it } from "node:test";
|
|
1050
|
+
import assert from "node:assert/strict";
|
|
1051
|
+
import { scanStalls } from "../engine-cli.mjs";
|
|
1052
|
+
|
|
1053
|
+
describe("scanStalls", () => {
|
|
1054
|
+
it("flags issues whose lease expired and state is non-terminal", () => {
|
|
1055
|
+
const now = 10_000;
|
|
1056
|
+
const states = {
|
|
1057
|
+
42: { terminal: false, lease: { by: "a1", until: 5_000 } }, // expired → stalled
|
|
1058
|
+
43: { terminal: false, lease: { by: "a2", until: 20_000 } }, // live → ok
|
|
1059
|
+
44: { terminal: true, lease: null }, // done → ok
|
|
1060
|
+
};
|
|
1061
|
+
const io = { readState: async (i) => states[i] };
|
|
1062
|
+
return scanStalls([42, 43, 44], io, now).then((stalled) => {
|
|
1063
|
+
assert.deepEqual(stalled, [42]);
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1070
|
+
|
|
1071
|
+
Run: `node --test bin/tests/engine-cli.test.mjs`
|
|
1072
|
+
Expected: FAIL — module not found.
|
|
1073
|
+
|
|
1074
|
+
- [ ] **Step 3: Write the implementation**
|
|
1075
|
+
|
|
1076
|
+
```js
|
|
1077
|
+
// bin/engine-cli.mjs
|
|
1078
|
+
/**
|
|
1079
|
+
* Headless entry point: `forgedock run-issue <issue>` drives one issue through the
|
|
1080
|
+
* durable engine; scanStalls finds dead-lease issues for the orchestrator to resume.
|
|
1081
|
+
*/
|
|
1082
|
+
import { execFile } from "node:child_process";
|
|
1083
|
+
import { promisify } from "node:util";
|
|
1084
|
+
import { homedir } from "node:os";
|
|
1085
|
+
import { join } from "node:path";
|
|
1086
|
+
import { runIssue } from "./engine.mjs";
|
|
1087
|
+
|
|
1088
|
+
const pexec = promisify(execFile);
|
|
1089
|
+
|
|
1090
|
+
/** Real gh/git accessors. */
|
|
1091
|
+
export function makeIo() {
|
|
1092
|
+
const run = (bin) => async (args) => {
|
|
1093
|
+
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
|
|
1094
|
+
return stdout;
|
|
1095
|
+
};
|
|
1096
|
+
return { gh: run("gh"), git: run("git") };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* @param {number[]} issues
|
|
1103
|
+
* @param {{readState:(i:number)=>Promise<{terminal:boolean,lease:?{until:number}}|null>}} io
|
|
1104
|
+
* @param {number} now
|
|
1105
|
+
* @returns {Promise<number[]>} issues that appear stalled (expired lease, non-terminal)
|
|
1106
|
+
*/
|
|
1107
|
+
export async function scanStalls(issues, io, now) {
|
|
1108
|
+
const stalled = [];
|
|
1109
|
+
for (const i of issues) {
|
|
1110
|
+
const s = await io.readState(i);
|
|
1111
|
+
if (s && !s.terminal && s.lease && s.lease.until < now) stalled.push(i);
|
|
1112
|
+
}
|
|
1113
|
+
return stalled;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export async function runFromCli(argv) {
|
|
1117
|
+
const issue = parseInt(argv[0], 10);
|
|
1118
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number>");
|
|
1119
|
+
const lane = flag(argv, "--lane") || "staging";
|
|
1120
|
+
const io = makeIo();
|
|
1121
|
+
const agentId = `cli_${process.pid}`;
|
|
1122
|
+
const res = await runIssue({ issue, dir: runDir(), agentId, lane, io,
|
|
1123
|
+
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now() });
|
|
1124
|
+
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
1125
|
+
return res;
|
|
1126
|
+
}
|
|
1127
|
+
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
- [ ] **Step 4: Wire the subcommand into `bin/forgedock.mjs`**
|
|
1131
|
+
|
|
1132
|
+
Add a `case` to the existing command switch (the block that dispatches `install`/`init`/`update`/`help`). Locate it and insert:
|
|
1133
|
+
|
|
1134
|
+
```js
|
|
1135
|
+
case "run-issue": {
|
|
1136
|
+
const { runFromCli } = await import("./engine-cli.mjs");
|
|
1137
|
+
await runFromCli(process.argv.slice(3));
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
Note: named `run-issue`, not `run`, to avoid colliding with the pre-existing `forgedock run <command>` spec-runner from #1151.
|
|
1143
|
+
|
|
1144
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
1145
|
+
|
|
1146
|
+
Run: `node --test bin/tests/engine-cli.test.mjs`
|
|
1147
|
+
Expected: PASS (1/1).
|
|
1148
|
+
|
|
1149
|
+
- [ ] **Step 6: Verify the full engine suite is green**
|
|
1150
|
+
|
|
1151
|
+
Run: `node --test "bin/tests/**/*.test.mjs"`
|
|
1152
|
+
Expected: PASS — all engine suites plus the pre-existing runner/tui/etc. suites.
|
|
1153
|
+
|
|
1154
|
+
- [ ] **Step 7: Commit**
|
|
1155
|
+
|
|
1156
|
+
```bash
|
|
1157
|
+
git add bin/engine-cli.mjs bin/tests/engine-cli.test.mjs bin/forgedock.mjs
|
|
1158
|
+
git commit -s -m "feat(engine): forgedock run-issue headless entry + lease-based stall scan"
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
---
|
|
1162
|
+
|
|
1163
|
+
### Task 9: Documentation — orchestrator integration note
|
|
1164
|
+
|
|
1165
|
+
**Files:**
|
|
1166
|
+
- Modify: `commands/orchestrate.md` (add a short "Engine mode" section)
|
|
1167
|
+
- Modify: `docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md` (mark status: Implemented once Tasks 1–8 land)
|
|
1168
|
+
|
|
1169
|
+
**Interfaces:** none (docs only).
|
|
1170
|
+
|
|
1171
|
+
- [ ] **Step 1: Add an "Engine mode (headless)" subsection to `commands/orchestrate.md`**
|
|
1172
|
+
|
|
1173
|
+
Insert after the existing wave-dispatch description:
|
|
1174
|
+
|
|
1175
|
+
```markdown
|
|
1176
|
+
### Engine mode (headless)
|
|
1177
|
+
|
|
1178
|
+
When running headless/CI, dispatch each issue via the durable engine instead of a
|
|
1179
|
+
prose agent:
|
|
1180
|
+
|
|
1181
|
+
forgedock run <issue> --lane <staging|milestone/slug>
|
|
1182
|
+
|
|
1183
|
+
The engine drives every phase transition deterministically, mirrors state to the
|
|
1184
|
+
`FORGE:STATE` block on the issue, and holds a lease. To recover stalls, scan the
|
|
1185
|
+
in-flight issues' `FORGE:STATE`; any issue with an expired lease and a non-terminal
|
|
1186
|
+
state is re-dispatched with the same `forgedock run <issue>` command — it resumes
|
|
1187
|
+
from the last committed phase (idempotent). This replaces the label-heuristic
|
|
1188
|
+
"already in progress" check and the resume-with-nagging loop.
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
- [ ] **Step 2: Commit**
|
|
1192
|
+
|
|
1193
|
+
```bash
|
|
1194
|
+
git add commands/orchestrate.md docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md
|
|
1195
|
+
git commit -s -m "docs(orchestrate): document headless engine mode + lease-based resume"
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
---
|
|
1199
|
+
|
|
1200
|
+
## Self-Review
|
|
1201
|
+
|
|
1202
|
+
**1. Spec coverage**
|
|
1203
|
+
|
|
1204
|
+
| Spec section | Task(s) |
|
|
1205
|
+
|---|---|
|
|
1206
|
+
| §4 components: runlog / state / projector / phases / engine | 1 / 2 / 3 / 4 / 6 |
|
|
1207
|
+
| §5.1 run-log schema + commit rule | 1 |
|
|
1208
|
+
| §5.2 FORGE:STATE compact index | 2, 3 |
|
|
1209
|
+
| §5.3 resume + divergence (GitHub wins) | 5, 6 |
|
|
1210
|
+
| §6 deterministic pickPhase | 4 |
|
|
1211
|
+
| §6 phase idempotency (reconcile) | 4 (build/review), 6 (invocation) |
|
|
1212
|
+
| §7 failure taxonomy (committed/failed/blocked, visible skips) | 4 (skip in context), 6 (retry/block) |
|
|
1213
|
+
| §8 lease + orchestrator integration | 6 (lease write), 8 (scanStalls), 9 (docs) |
|
|
1214
|
+
| §9 testing (units + crash injection) | 1–6 units, 7 crash injection |
|
|
1215
|
+
| §12 definition of done | 6 (drive to merged), 7 (resume proof), 8 (headless entry) |
|
|
1216
|
+
|
|
1217
|
+
No spec section is left without a task.
|
|
1218
|
+
|
|
1219
|
+
**2. Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". Every code step shows complete code; every phase entry is fully written.
|
|
1220
|
+
|
|
1221
|
+
**3. Type consistency:** `RunState` shape (`v`, `committed`, `phase`, `branch`, `pr`, `terminal`, `terminalReason`, `lease`) is identical across `runlog.deriveState`, `state` codec, `reconcileState`, `phases`, and `engine`. `PhaseOutcome.status` values (`committed`/`failed`/`blocked`) match between `phases.detectOutcome` and `engine.runPhaseWithRetry`. `io = {gh, git}` and the `runner` signature (`{commandsDir, commandName, args}`) match `runner.runCommand`'s real parameters (verified against `origin/main:bin/runner.mjs`).
|
|
1222
|
+
|
|
1223
|
+
**Known follow-ups (out of scope, tracked separately):** full per-phase `reconcile` for context/architect/close; oversized-diff handling in `review` (verification spec #1315); interactive Claude Code adapter; in-process worker-pool vs process-per-issue decision (spec §10).
|