forgedock 1.1.11 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/bin/cli-spawn-shared.mjs +25 -0
- package/bin/engine/phases.mjs +178 -28
- package/bin/engine/state.mjs +39 -7
- package/bin/engine-cli.mjs +85 -3
- package/bin/engine.mjs +71 -8
- package/bin/forgedock.mjs +322 -20
- package/bin/hooks/pre-tool-use.mjs +345 -10
- package/bin/init-enrich-api.mjs +12 -4
- package/bin/init-enrich-cli.mjs +328 -0
- package/bin/init-enrich.mjs +137 -0
- package/bin/journey.mjs +116 -15
- package/bin/runner.mjs +653 -10
- package/commands/adopt.md +1 -1
- package/commands/analytics.md +10 -7
- package/commands/audit-agents.md +29 -6
- package/commands/audit.md +9 -20
- package/commands/autopilot.md +52 -78
- package/commands/changelog.md +1 -1
- package/commands/ci-audit.md +19 -10
- package/commands/cleanup.md +1 -1
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-info.md +1 -1
- package/commands/deploy-pr.md +1 -1
- package/commands/design-bench.md +1 -1
- package/commands/design-render-critique-loop.md +1 -1
- package/commands/diagnose.md +1 -1
- package/commands/forge-stats.md +6 -5
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +9 -6
- package/commands/incident-response.md +15 -5
- package/commands/issue.md +302 -15
- package/commands/milestone.md +45 -18
- package/commands/optimize.md +1 -1
- package/commands/orchestrate/phase-1-resolve.md +20 -5
- package/commands/orchestrate/phase-4-execution.md +8 -1
- package/commands/orchestrate/phase-5-cleanup.md +43 -2
- package/commands/orchestrate/phase-6-report.md +1 -0
- package/commands/orchestrate.md +1 -1
- package/commands/pipeline-health/phase-4-proposals.md +40 -10
- package/commands/pipeline-health/phase-5-report.md +140 -13
- package/commands/pipeline-health.md +1 -1
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +1 -1
- package/commands/qa-sweep.md +27 -4
- package/commands/recover-orphans.md +1 -1
- package/commands/replay.md +16 -2
- package/commands/review-pr-agents/protocols.md +4 -0
- package/commands/review-pr-agents/spec-cli.md +179 -0
- package/commands/review-pr-agents.md +7 -4
- package/commands/review-pr-staging.md +44 -15
- package/commands/review-pr.md +122 -28
- package/commands/rollback.md +15 -5
- package/commands/security-audit.md +14 -11
- package/commands/signal-planner.md +32 -12
- package/commands/spec-doctor.md +1 -1
- package/commands/sync-ecosystem.md +54 -11
- package/commands/test-gate.md +49 -11
- package/commands/upgrade-deps.md +518 -0
- package/commands/validate.md +16 -5
- package/commands/work-on/build/architect.md +1 -1
- package/commands/work-on/build/context.md +1 -1
- package/commands/work-on/build/implement.md +2 -2
- package/commands/work-on/build/validate.md +64 -2
- package/commands/work-on/build.md +10 -2
- package/commands/work-on/close.md +1 -1
- package/commands/work-on/decompose.md +35 -25
- package/commands/work-on/investigate.md +2 -2
- package/commands/work-on/remediate.md +1 -1
- package/commands/work-on/review.md +5 -3
- package/commands/work-on-monolithic.md +1 -1
- package/commands/work-on.md +28 -7
- package/docs/AIDER.md +264 -0
- package/docs/AUTOPILOT-CRON.md +130 -0
- package/docs/CI.md +18 -0
- package/docs/CONFIG.md +41 -2
- package/docs/CURSOR.md +135 -0
- package/docs/OPENCODE.md +312 -0
- package/docs/demo.gif +0 -0
- package/docs/eval/README.md +4 -4
- package/docs/launch/show-hn.md +3 -3
- package/docs/site/annotations-explained.md +2 -2
- package/docs/site/architecture.md +180 -0
- package/docs/site/command-reference.md +38 -0
- package/docs/site/configuration.md +243 -0
- package/docs/site/for-companies.md +4 -4
- package/docs/site/forge-annotation-protocol.md +1 -1
- package/docs/site/github-is-the-memory.md +2 -2
- package/docs/site/how-it-works.md +1 -1
- package/docs/site/public/llms-full.txt +380 -0
- package/docs/site/public/llms.txt +35 -0
- package/docs/spec/review-protocol.md +6 -0
- package/docs/spec-graph-schema.md +3 -3
- package/package.json +2 -2
- package/packages/protocol/README.md +75 -0
- package/packages/protocol/src/card.js +114 -0
- package/packages/protocol/src/cli.js +4 -77
- package/packages/protocol/src/emit.js +14 -3
- package/packages/protocol/src/index.js +2 -1
- package/packages/protocol/src/parse.js +17 -8
- package/packages/protocol/src/types.js +1 -1
- package/packages/protocol/src/validate.js +11 -0
- package/scripts/check-command-side-effects.sh +261 -0
- package/scripts/check-spec-markers.sh +273 -0
- package/scripts/classify-lane.sh +29 -7
- package/scripts/forge-run.sh +322 -0
- package/templates/cross-repo-trigger.yml +282 -0
- package/templates/cursor/forge.mdc +148 -0
- package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/README.md
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
<h1>ForgeDock</h1>
|
|
6
6
|
|
|
7
|
-
<p><strong>
|
|
7
|
+
<p><strong>Point it at an issue and get a merged, reviewed PR; point it at a <strong>milestone</strong> and get parallel pipelines with conflict-aware scheduling.</strong></p>
|
|
8
8
|
|
|
9
|
-
<p>LLMs generate the code. ForgeDock owns everything else — <strong>state, scheduling, recovery, review, and memory</strong> — as durable, inspectable structure on the GitHub you already have. Issues are the queue. PRs are the ledger. Annotations are the memory.
|
|
9
|
+
<p>LLMs generate the code. ForgeDock owns everything else — <strong>state, scheduling, recovery, review, and memory</strong> — as durable, inspectable structure on the GitHub you already have. Issues are the queue. PRs are the ledger. Annotations are the memory. Deterministic orchestration for autonomous software engineering.</p>
|
|
10
|
+
|
|
11
|
+
<p><em>ForgeDock (not ForgeRock) — this project is unrelated to ForgeRock's identity and access management platform.</em></p>
|
|
12
|
+
|
|
13
|
+
<p>📖 <a href="https://rapiercraftstudios.github.io/ForgeDock/">Full documentation site</a></p>
|
|
10
14
|
|
|
11
15
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
|
12
16
|
<a href="https://github.com/RapierCraftStudios/ForgeDock/stargazers"><img src="https://img.shields.io/github/stars/RapierCraftStudios/ForgeDock?style=social" alt="GitHub Stars" /></a>
|
|
@@ -52,6 +56,8 @@ $ /work-on #1230 "orchestrate: Layer 5 co-change signal is dead code"
|
|
|
52
56
|
|
|
53
57
|
### Try it in 30 seconds — on a throwaway repo, nothing to lose
|
|
54
58
|
|
|
59
|
+
**Requires:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (subscription or `ANTHROPIC_API_KEY`) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
|
|
60
|
+
|
|
55
61
|
```bash
|
|
56
62
|
npx forgedock demo # spins up a risk-free demo repo and shows you the pipeline end to end
|
|
57
63
|
```
|
|
@@ -250,7 +256,7 @@ Then just open Claude Code and run `/work-on <issue>`.
|
|
|
250
256
|
|
|
251
257
|
Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock init` to generate `forge.yaml`.
|
|
252
258
|
|
|
253
|
-
**Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan
|
|
259
|
+
**Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan. `npx forgedock run` picks an execution backend automatically (`--backend auto`, the default): if the Claude Code CLI (`claude`) is installed and already authenticated (Pro/Max subscription or a CLI-managed key), it drives the command through that — **no separate `ANTHROPIC_API_KEY` required**. Otherwise it falls back to the Anthropic API directly (`ANTHROPIC_API_KEY` required). Force either path explicitly with `--backend cli` / `--backend api` (or `FORGEDOCK_BACKEND=cli|api`) — the API backend is what CI environments without an interactive `claude` login should use. `npx forgedock run-issue <issue>` executes the same command specs on the durable engine (event-sourced run log, leases, crash-safe resume).
|
|
254
260
|
|
|
255
261
|
**Explicit install command:**
|
|
256
262
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-spawn-shared.mjs — shared constants for `claude` CLI `spawnSync`
|
|
3
|
+
* invocations across the codebase.
|
|
4
|
+
*
|
|
5
|
+
* Extracted (issue #2032) to stop `maxBuffer: 50 * 1024 * 1024` from
|
|
6
|
+
* drifting independently at each spawn call site (bin/runner.mjs's
|
|
7
|
+
* `runCliBackend` and `run_bash`, bin/init-enrich-cli.mjs's `enrich()`) —
|
|
8
|
+
* all three invoke the same local `claude` CLI and should share one
|
|
9
|
+
* default rather than three copies of the same magic number.
|
|
10
|
+
*
|
|
11
|
+
* Isolated: imports no external modules, so it is safe to import from any
|
|
12
|
+
* module without creating a cycle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default `maxBuffer` (bytes) for a `spawnSync` call against the local
|
|
17
|
+
* `claude` CLI. 50 MB comfortably covers a full headless response (stdout)
|
|
18
|
+
* plus any diagnostic stderr without risking silent truncation.
|
|
19
|
+
*
|
|
20
|
+
* `bin/runner.mjs`'s `run_bash` tool handler additionally allows this
|
|
21
|
+
* default to be overridden per-call via `FORGEDOCK_MAX_BUFFER_BYTES` — that
|
|
22
|
+
* override logic lives in `runner.mjs` itself; this constant is only the
|
|
23
|
+
* shared *default*.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SPAWN_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -7,27 +7,146 @@
|
|
|
7
7
|
|
|
8
8
|
export const TERMINAL_REASONS = ["merged", "invalid", "needs-human", "decomposed"];
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Fetch the issue's comments. Returns both:
|
|
12
|
+
* - `blob`: all bodies joined into one string, for simple marker-presence checks
|
|
13
|
+
* (`has(blob, marker)`) where it doesn't matter which comment posted the marker.
|
|
14
|
+
* - `comments`: an array of individual comment bodies, preserving per-comment
|
|
15
|
+
* boundaries, for extraction that MUST be scoped to a specific comment (see
|
|
16
|
+
* `parseBranchFromMarkers()` below — forge#2184).
|
|
17
|
+
*
|
|
18
|
+
* The `--jq '[.[].body]'` query asks `gh` for a JSON array of bodies. If the
|
|
19
|
+
* response isn't valid JSON (a non-JSON gh error string, or a test mock that
|
|
20
|
+
* supplies a raw marker string instead of the real API shape), fall back to
|
|
21
|
+
* treating the whole blob as a single pseudo-comment — `has()` checks are
|
|
22
|
+
* unaffected either way, and comment-scoped extraction simply won't match,
|
|
23
|
+
* which is the safe, conservative behavior.
|
|
24
|
+
*/
|
|
11
25
|
async function issueMarkers(issue, io) {
|
|
12
|
-
const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", ".[].body"]);
|
|
13
|
-
|
|
26
|
+
const out = await io.gh(["api", `repos/{owner}/{repo}/issues/${issue}/comments`, "--jq", "[.[].body]"]);
|
|
27
|
+
const blob = out || "";
|
|
28
|
+
let comments = [];
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(out);
|
|
31
|
+
if (Array.isArray(parsed)) {
|
|
32
|
+
comments = parsed.map((c) => (typeof c === "string" ? c : (c && c.body) || ""));
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
comments = blob ? [blob] : [];
|
|
36
|
+
}
|
|
37
|
+
return { blob, comments };
|
|
14
38
|
}
|
|
15
39
|
/**
|
|
16
|
-
* Count commits on branch ahead of
|
|
40
|
+
* Count commits on `branch` ahead of `lane`'s base. On the first build the
|
|
17
41
|
* branch does not exist yet, so real git rejects the ref range — swallow
|
|
18
42
|
* that (and any other git failure) as "0 ahead" rather than letting it
|
|
19
43
|
* propagate and crash runIssue (C1).
|
|
44
|
+
*
|
|
45
|
+
* Takes explicit `lane`/`branch` args (rather than reading them off `state`)
|
|
46
|
+
* so every call site is forced to resolve the branch it means to check —
|
|
47
|
+
* see `resolveBranch()` below (forge#2174: the previous `state.branch`-only
|
|
48
|
+
* signature let the build phase evaluate this against a guessed branch name
|
|
49
|
+
* that never matched the branch the builder actually created).
|
|
50
|
+
*
|
|
51
|
+
* Returns -1 (rather than 0) when the underlying `git` call itself failed
|
|
52
|
+
* (lock contention, transient I/O error, ref not yet fetched, etc.) — distinct
|
|
53
|
+
* from a genuine, successfully-computed 0. This distinction matters to the
|
|
54
|
+
* build phase's `detectOutcome` (forge#2176): a *genuine* 0 ahead (git ran
|
|
55
|
+
* cleanly and reported no new commits) is a stable fixed point safe to mark
|
|
56
|
+
* non-retryable, but a transient git error folded into the same 0 would not
|
|
57
|
+
* be — the very next attempt could see a different, non-erroring result with
|
|
58
|
+
* no external input having changed, so it must remain retryable. Callers that
|
|
59
|
+
* only compare `> 0` (reconcile()'s satisfied check) are unaffected: -1 is
|
|
60
|
+
* still not `> 0`, so existing behavior there is unchanged.
|
|
20
61
|
*/
|
|
21
|
-
async function commitsAhead(
|
|
62
|
+
async function commitsAhead(lane, branch, io) {
|
|
22
63
|
try {
|
|
23
|
-
const n = await io.git(["rev-list", "--count", `origin/${
|
|
64
|
+
const n = await io.git(["rev-list", "--count", `origin/${lane}..${branch}`]);
|
|
24
65
|
return parseInt(String(n).trim(), 10) || 0;
|
|
25
66
|
} catch {
|
|
26
|
-
return
|
|
67
|
+
return -1;
|
|
27
68
|
}
|
|
28
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Marker-presence check used throughout this file — including
|
|
72
|
+
* `FORGE:BUILDER:COMPLETE` eligibility gates in the "build" phase's
|
|
73
|
+
* `reconcile`/`detectOutcome` below (forge#2194 — investigated, no change).
|
|
74
|
+
*
|
|
75
|
+
* This is a plain substring test, deliberately, for consistency: every other
|
|
76
|
+
* marker gate in this file (`INVESTIGATION:INVALID`, `DECOMPOSE:YES`,
|
|
77
|
+
* `INVESTIGATION:COMPLETE`, `FORGE:CONTEXT:COMPLETE`,
|
|
78
|
+
* `FORGE:ARCHITECT:COMPLETE`, `workflow:merged`) uses the identical
|
|
79
|
+
* substring/membership technique — singling out `FORGE:BUILDER:COMPLETE`
|
|
80
|
+
* alone for a "structured" parse would be inconsistent and would not close
|
|
81
|
+
* any real gap: the actual trust boundary for issue-comment content is
|
|
82
|
+
* *authorship* (can an untrusted actor post a comment on this issue at all),
|
|
83
|
+
* not *format*. Nothing in this engine validates comment authorship for any
|
|
84
|
+
* marker today, so an actor able to post an arbitrary comment could just as
|
|
85
|
+
* easily post whatever "structured" shape a parser would accept — format
|
|
86
|
+
* hardening alone buys nothing here. If comment-spoofing is ever a concern
|
|
87
|
+
* worth addressing, the fix is an author allowlist applied uniformly to all
|
|
88
|
+
* markers, not a bespoke parser for this one field.
|
|
89
|
+
*/
|
|
29
90
|
function has(blob, marker) { return blob.includes(marker); }
|
|
30
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Parse the real branch name out of the `FORGE:BUILDER` comment's
|
|
94
|
+
* `**Branch**: \`{BRANCH}\`` field (see `commands/work-on/build/implement.md`
|
|
95
|
+
* Phase I6 — this is the exact format the builder posts). Ground truth for
|
|
96
|
+
* "what branch did the builder actually create" — the engine has no other
|
|
97
|
+
* reliable source, since the branch name is slug-derived from the issue
|
|
98
|
+
* title and cannot be guessed or precomputed (forge#2174).
|
|
99
|
+
*
|
|
100
|
+
* SCOPING (forge#2184): only comments whose body contains `FORGE:BUILDER:COMPLETE`
|
|
101
|
+
* — the same completion marker the build phase already gates on — are eligible
|
|
102
|
+
* to supply the branch. A `**Branch**:` field inside any other comment (a
|
|
103
|
+
* FORGE:CONTRACT, FORGE:ARCHITECT, FORGE:CONTEXT, reviewer, or remediation
|
|
104
|
+
* comment) is never considered, even if it happens to match the same regex
|
|
105
|
+
* shape. If more than one FORGE:BUILDER:COMPLETE comment exists (e.g. a
|
|
106
|
+
* resumed/retried build re-posting a fresh completion comment), the LAST one
|
|
107
|
+
* — by array/chronological order — wins, so the most recent build attempt's
|
|
108
|
+
* branch is used. Returns null (never invents a value) if no eligible comment
|
|
109
|
+
* contains the field.
|
|
110
|
+
*
|
|
111
|
+
* WITHIN-COMMENT FIELD ORDER (forge#2193 — investigated, no change): once the
|
|
112
|
+
* winning comment is selected (comment-level last-match, above — settled by
|
|
113
|
+
* forge#2184, do not conflate with this paragraph), `body.match(re)` returns
|
|
114
|
+
* the FIRST `**Branch**:` occurrence in that comment, because `re` has no
|
|
115
|
+
* `/g` flag. This is intentional, not an oversight: there is exactly one
|
|
116
|
+
* producer of this field — `commands/work-on/build/implement.md` Phase I6 —
|
|
117
|
+
* which posts `**Branch**: \`{BRANCH}\`` exactly once per FORGE:BUILDER
|
|
118
|
+
* comment. `FORGE:BUILDER:COMPLETE` is appended IN PLACE to that same
|
|
119
|
+
* existing comment by `commands/work-on/build/validate.md` Phase V5 (an edit,
|
|
120
|
+
* not a new comment), so no code path in this pipeline ever produces two
|
|
121
|
+
* `**Branch**:` fields inside one FORGE:BUILDER:COMPLETE-eligible comment.
|
|
122
|
+
* First-match and last-match are therefore equivalent for every real input;
|
|
123
|
+
* first-match is kept because it's the simpler default. If a future producer
|
|
124
|
+
* ever posts more than one `**Branch**:` field in a single eligible comment,
|
|
125
|
+
* this will silently keep returning the first one — revisit this comment
|
|
126
|
+
* before changing that invariant.
|
|
127
|
+
*/
|
|
128
|
+
function parseBranchFromMarkers(comments) {
|
|
129
|
+
const re = /\*\*Branch\*\*:\s*`([^`]+)`/;
|
|
130
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
131
|
+
const body = comments[i];
|
|
132
|
+
if (!body || !body.includes("FORGE:BUILDER:COMPLETE")) continue;
|
|
133
|
+
const match = body.match(re);
|
|
134
|
+
if (match) return match[1];
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the branch to evaluate the build phase against: ground truth from
|
|
141
|
+
* the FORGE:BUILDER:COMPLETE comment if present (see `parseBranchFromMarkers()`
|
|
142
|
+
* for the exact scoping rule), else whatever `state.branch` already holds
|
|
143
|
+
* (e.g. a real branch carried forward from a prior PHASE_COMMIT — see
|
|
144
|
+
* `runlog.mjs:deriveState`). Never invents a value.
|
|
145
|
+
*/
|
|
146
|
+
function resolveBranch(state, comments) {
|
|
147
|
+
return parseBranchFromMarkers(comments) || state.branch || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
31
150
|
/** @type {Phase[]} */
|
|
32
151
|
export const PHASES = [
|
|
33
152
|
{
|
|
@@ -35,12 +154,12 @@ export const PHASES = [
|
|
|
35
154
|
command: "work-on/investigate",
|
|
36
155
|
entryCondition: () => true,
|
|
37
156
|
async detectOutcome(state, io) {
|
|
38
|
-
const
|
|
39
|
-
if (has(
|
|
157
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
158
|
+
if (has(blob, "INVESTIGATION:INVALID"))
|
|
40
159
|
return { status: "committed", terminalReason: "invalid", outputs: { verdict: "INVALID" } };
|
|
41
|
-
if (has(
|
|
160
|
+
if (has(blob, "DECOMPOSE:YES"))
|
|
42
161
|
return { status: "committed", terminalReason: "decomposed", outputs: { decompose: true } };
|
|
43
|
-
if (has(
|
|
162
|
+
if (has(blob, "INVESTIGATION:COMPLETE"))
|
|
44
163
|
return { status: "committed", outputs: { verdict: "CONFIRMED" } };
|
|
45
164
|
return { status: "failed", detail: "no INVESTIGATION:COMPLETE marker" };
|
|
46
165
|
},
|
|
@@ -53,13 +172,13 @@ export const PHASES = [
|
|
|
53
172
|
async reconcile(state, io) {
|
|
54
173
|
// Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
|
|
55
174
|
// Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
|
|
56
|
-
const
|
|
57
|
-
return has(
|
|
175
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
176
|
+
return has(blob, "FORGE:CONTEXT:COMPLETE") ? { satisfied: true } : { satisfied: false };
|
|
58
177
|
},
|
|
59
178
|
async detectOutcome(state, io) {
|
|
60
|
-
const
|
|
179
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
61
180
|
// Context is non-critical: a missing marker is a VISIBLE skip, not a hard fail (spec §7).
|
|
62
|
-
if (has(
|
|
181
|
+
if (has(blob, "FORGE:CONTEXT")) return { status: "committed", outputs: {} };
|
|
63
182
|
return { status: "committed", outputs: { skipped: true, which: "context" } };
|
|
64
183
|
},
|
|
65
184
|
},
|
|
@@ -70,12 +189,12 @@ export const PHASES = [
|
|
|
70
189
|
async reconcile(state, io) {
|
|
71
190
|
// Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
|
|
72
191
|
// Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
|
|
73
|
-
const
|
|
74
|
-
return has(
|
|
192
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
193
|
+
return has(blob, "FORGE:ARCHITECT:COMPLETE") ? { satisfied: true } : { satisfied: false };
|
|
75
194
|
},
|
|
76
195
|
async detectOutcome(state, io) {
|
|
77
|
-
const
|
|
78
|
-
return has(
|
|
196
|
+
const { blob } = await issueMarkers(state.issue, io);
|
|
197
|
+
return has(blob, "FORGE:ARCHITECT:COMPLETE")
|
|
79
198
|
? { status: "committed", outputs: {} }
|
|
80
199
|
: { status: "failed", detail: "no FORGE:ARCHITECT:COMPLETE" };
|
|
81
200
|
},
|
|
@@ -85,19 +204,50 @@ export const PHASES = [
|
|
|
85
204
|
command: "work-on/build",
|
|
86
205
|
entryCondition: (s) => s.committed.includes("architect"),
|
|
87
206
|
async reconcile(state, io) {
|
|
88
|
-
// Idempotent resume:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
207
|
+
// Idempotent resume: resolve the real branch from ground truth (FORGE:BUILDER
|
|
208
|
+
// comment) rather than trusting a possibly-stale/absent state.branch, then
|
|
209
|
+
// check it's already ahead of base → treat as done, skip the LLM (forge#2174).
|
|
210
|
+
const { blob, comments } = await issueMarkers(state.issue, io);
|
|
211
|
+
const branch = resolveBranch(state, comments);
|
|
212
|
+
if (branch && has(blob, "FORGE:BUILDER:COMPLETE") && (await commitsAhead(state.lane, branch, io)) > 0) {
|
|
213
|
+
return { satisfied: true, outputs: { branch } };
|
|
92
214
|
}
|
|
93
215
|
return { satisfied: false };
|
|
94
216
|
},
|
|
95
217
|
async detectOutcome(state, io) {
|
|
96
|
-
const
|
|
97
|
-
const complete = has(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
218
|
+
const { blob, comments } = await issueMarkers(state.issue, io);
|
|
219
|
+
const complete = has(blob, "FORGE:BUILDER:COMPLETE"); // #1305: require :COMPLETE …
|
|
220
|
+
// Resolve the branch the builder actually created from the FORGE:BUILDER:COMPLETE
|
|
221
|
+
// comment (ground truth), scoped to that specific comment — see
|
|
222
|
+
// resolveBranch()/parseBranchFromMarkers() above (forge#2174, forge#2184).
|
|
223
|
+
const branch = resolveBranch(state, comments);
|
|
224
|
+
const ahead = branch ? await commitsAhead(state.lane, branch, io) : 0; // … AND real commits
|
|
225
|
+
if (complete && ahead > 0) return { status: "committed", outputs: { branch } };
|
|
226
|
+
const detail = `builder complete=${complete} commitsAhead=${ahead} branch=${branch || "unresolved"}`;
|
|
227
|
+
// forge#2176: when the builder has already posted FORGE:BUILDER:COMPLETE
|
|
228
|
+
// but the resolved (real, ground-truth) branch has zero commits ahead of
|
|
229
|
+
// the lane base, this is a stable fixed point, not a transient failure.
|
|
230
|
+
// commands/work-on/build.md's own early-exit (Phase B0) means any
|
|
231
|
+
// subsequent re-invocation of this phase's runner will see
|
|
232
|
+
// FORGE:BUILDER:COMPLETE already present and immediately no-op with
|
|
233
|
+
// `BUILD_RESULT: status: ALREADY_DONE` — it will never touch git again,
|
|
234
|
+
// so `ahead` cannot change without new, out-of-band input (e.g. a human
|
|
235
|
+
// pushing a commit). Retrying is therefore guaranteed to reproduce this
|
|
236
|
+
// exact result; mark it non-retryable so the engine escalates after a
|
|
237
|
+
// single attempt instead of burning the full attempt budget.
|
|
238
|
+
//
|
|
239
|
+
// When `complete` is false, the builder never finished at all (crashed,
|
|
240
|
+
// ran out of iterations, or was interrupted) — that IS worth a fresh
|
|
241
|
+
// retry, so this branch intentionally leaves `retryable` unset
|
|
242
|
+
// (defaults to retryable in bin/engine.mjs's runPhaseWithRetry).
|
|
243
|
+
//
|
|
244
|
+
// `ahead === -1` means commitsAhead() itself failed (transient git
|
|
245
|
+
// error), NOT a confirmed zero — that is exactly the kind of failure
|
|
246
|
+
// a retry might resolve, so it must stay retryable too. Only a
|
|
247
|
+
// successfully-computed ahead of 0 (a real "nothing new to commit"
|
|
248
|
+
// result) is the true fixed point this non-retryable signal targets.
|
|
249
|
+
if (complete && ahead !== -1) return { status: "failed", detail, retryable: false };
|
|
250
|
+
return { status: "failed", detail };
|
|
101
251
|
},
|
|
102
252
|
},
|
|
103
253
|
{
|
package/bin/engine/state.mjs
CHANGED
|
@@ -8,11 +8,29 @@ const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
|
|
|
8
8
|
|
|
9
9
|
/** @param {import("./phases.mjs").RunState} index */
|
|
10
10
|
export function serializeState(index) {
|
|
11
|
-
// Escape
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
11
|
+
// Escape all three HTML comment delimiter forms so a payload value can never
|
|
12
|
+
// open or close an HTML comment in GitHub's renderer:
|
|
13
|
+
// - "<!--" the comment opener — an unescaped opener would start a new
|
|
14
|
+
// (nested-looking) comment, visually swallowing subsequent content
|
|
15
|
+
// (mirrors packages/protocol/src/emit.js's sanitizeFieldValue, forge#1638/#2119)
|
|
16
|
+
// - "-->" and "--!>" the two comment-close forms browsers/HTML parsers
|
|
17
|
+
// honour. JSON.stringify does not escape any of these, so a payload
|
|
18
|
+
// value containing them could otherwise open/close the
|
|
19
|
+
// <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
|
|
20
|
+
//
|
|
21
|
+
// The "&" escape below MUST run first, before the delimiter escapes. Without
|
|
22
|
+
// it, a payload value that already contains literal entity-like text (e.g.
|
|
23
|
+
// "<!-->" or "<!--") collides with the encoded form of an unrelated
|
|
24
|
+
// real delimiter — the encoder is not injective and the collision is
|
|
25
|
+
// unrecoverable on decode (forge#2137). Escaping "&" first guarantees every
|
|
26
|
+
// "<!--"/"-->"/"--!>" sequence present in the output was produced
|
|
27
|
+
// by *this* encode pass, never by pre-existing entity text (which is now
|
|
28
|
+
// doubly-escaped, e.g. "&" -> "&" -> untouched by the delimiter pass
|
|
29
|
+
// that only matches literal "<!--"/"--"+">" character sequences).
|
|
30
|
+
const payload = JSON.stringify(index)
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/<!--/g, "<!--")
|
|
33
|
+
.replace(/--(!?)>/g, "--$1>");
|
|
16
34
|
return `${OPEN}\n${payload}\n${CLOSE}`;
|
|
17
35
|
}
|
|
18
36
|
|
|
@@ -20,8 +38,22 @@ export function serializeState(index) {
|
|
|
20
38
|
export function parseState(issueBody) {
|
|
21
39
|
const m = BLOCK_RE.exec(issueBody || "");
|
|
22
40
|
if (!m) return null;
|
|
23
|
-
// Inverse of serializeState: restore
|
|
24
|
-
|
|
41
|
+
// Inverse of serializeState: restore "-->", "--!>", "<!--", then
|
|
42
|
+
// "&" — in that order. Unescape the closer forms first (most-specific
|
|
43
|
+
// "--!>" before "-->") so a dangling "!" is never left behind, then
|
|
44
|
+
// restore the opener — same ordering rationale as
|
|
45
|
+
// packages/protocol/src/parse.js's unescapeFieldValue. The "&" -> "&"
|
|
46
|
+
// unescape MUST run LAST (mirroring the encode side running it FIRST) so it
|
|
47
|
+
// never touches the "&" that is part of a just-restored "<!--"/"-->"/"--!>"
|
|
48
|
+
// sequence (forge#2137).
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(
|
|
51
|
+
m[1].trim()
|
|
52
|
+
.replace(/--(!?)>/g, "--$1>")
|
|
53
|
+
.replace(/<!--/g, "<!--")
|
|
54
|
+
.replace(/&/g, "&"),
|
|
55
|
+
);
|
|
56
|
+
} catch { return null; }
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
/** Replace the FORGE:STATE block in place, or append one if absent. */
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
|
|
|
9
9
|
import { readdirSync, statSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { runIssue } from "./engine.mjs";
|
|
12
|
+
import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
14
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
15
|
|
|
@@ -55,6 +55,56 @@ export function makeIo() {
|
|
|
55
55
|
|
|
56
56
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Renders the diagnostic block printed below the bare terminal line whenever
|
|
60
|
+
* a run does not terminate `merged` (forge#2175). Reconstructs the failing
|
|
61
|
+
* phase, attempt count, and `PHASE_FAILED.reason` from the durable run-log
|
|
62
|
+
* (the same data `bin/engine.mjs`'s `runPhaseWithRetry()` already appends via
|
|
63
|
+
* `appendEvent()`), plus the final committed/branch/pr state and the run-log
|
|
64
|
+
* path — closing the gap where an operator previously had to manually open
|
|
65
|
+
* `~/.forge/runs/{issue}.jsonl` and read engine source to interpret a bare
|
|
66
|
+
* `issue #N -> needs-human` line.
|
|
67
|
+
*
|
|
68
|
+
* Best-effort: if the run-log is empty/unreadable (e.g. a `deferred` early
|
|
69
|
+
* return before any event was appended), only the run-log path line is
|
|
70
|
+
* printed — never throws.
|
|
71
|
+
*
|
|
72
|
+
* Validates `issue` internally (mirrors the `Number.isInteger` guard in
|
|
73
|
+
* `lastLocalRun()` below) rather than relying solely on the caller —
|
|
74
|
+
* `runFromCli()` already validates before calling this, but this is an
|
|
75
|
+
* exported helper and a future caller could forget to (forge#2190).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} dir - runDir() (or an injected override for tests)
|
|
78
|
+
* @param {number} issue
|
|
79
|
+
* @returns {string} multi-line diagnostic block (no trailing newline)
|
|
80
|
+
*/
|
|
81
|
+
export function formatTerminalDiagnostics(dir, issue) {
|
|
82
|
+
if (!Number.isInteger(issue)) {
|
|
83
|
+
return ` run-log: <invalid issue: ${JSON.stringify(issue)}>`;
|
|
84
|
+
}
|
|
85
|
+
const runLogPath = join(dir, `${issue}.jsonl`);
|
|
86
|
+
let events = [];
|
|
87
|
+
try {
|
|
88
|
+
events = readLog(dir, issue);
|
|
89
|
+
} catch {
|
|
90
|
+
// Corrupt/unreadable log — degrade to just the run-log path line below.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = [];
|
|
94
|
+
if (events.length > 0) {
|
|
95
|
+
const state = deriveState(events);
|
|
96
|
+
const lastFailure = [...events].reverse().find((e) => e.event === "PHASE_FAILED");
|
|
97
|
+
if (lastFailure) {
|
|
98
|
+
lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${DEFAULT_MAX_ATTEMPTS} attempts)`);
|
|
99
|
+
lines.push(` reason: ${lastFailure.reason}`);
|
|
100
|
+
}
|
|
101
|
+
const committed = state.committed.length ? state.committed.join(",") : "";
|
|
102
|
+
lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push(` run-log: ${runLogPath}`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
58
108
|
/**
|
|
59
109
|
* Resolves the repo `gh` would target by default (i.e. resolved from the cwd
|
|
60
110
|
* git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
|
|
@@ -224,6 +274,21 @@ export function lastLocalRun(dir) {
|
|
|
224
274
|
* @param {{io?: {gh: Function}, runIssue?: Function}} [deps]
|
|
225
275
|
* Injectable for tests — defaults to real `gh`/`git` (makeIo()) and the real
|
|
226
276
|
* `runIssue` engine driver.
|
|
277
|
+
*
|
|
278
|
+
* Flags:
|
|
279
|
+
* --lane <lane> Required — e.g. `main` or `staging`.
|
|
280
|
+
* --repo <owner/repo> Optional — must match the cwd-resolved repo (forge#1593).
|
|
281
|
+
* --backend <cli|api|auto> Optional (forge#2028). Forwarded to every phase's
|
|
282
|
+
* `runCommand()` call. Omit to keep runner.mjs's own
|
|
283
|
+
* default ("auto" ladder — probes the `claude` CLI, falls
|
|
284
|
+
* back to the API). Mirrors `bin/forgedock.mjs`'s `run()`
|
|
285
|
+
* `--backend` semantics — validation (rejecting invalid
|
|
286
|
+
* values) is left to `runCommand()`'s own `resolveBackend()`
|
|
287
|
+
* rather than duplicated here.
|
|
288
|
+
* --model <id> Optional (forge#2028). Forwarded to every phase's
|
|
289
|
+
* `runCommand()` call; only applies on the "api" backend.
|
|
290
|
+
* Omit to keep runner.mjs's default (`FORGEDOCK_MODEL` env
|
|
291
|
+
* or its built-in default).
|
|
227
292
|
*/
|
|
228
293
|
export async function runFromCli(argv, deps = {}) {
|
|
229
294
|
const issue = parseInt(argv[0], 10);
|
|
@@ -231,13 +296,30 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
231
296
|
const lane = flag(argv, "--lane");
|
|
232
297
|
if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
|
|
233
298
|
const repo = flag(argv, "--repo");
|
|
299
|
+
const backend = flag(argv, "--backend");
|
|
300
|
+
const model = flag(argv, "--model");
|
|
234
301
|
const io = deps.io ?? makeIo();
|
|
235
302
|
await assertRepoMatchesCwd(io, repo);
|
|
236
303
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
237
304
|
const agentId = `cli_${process.pid}`;
|
|
238
|
-
|
|
239
|
-
|
|
305
|
+
// Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
|
|
306
|
+
const dir = deps.dir ?? runDir();
|
|
307
|
+
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
308
|
+
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
309
|
+
// Only forwarded when explicitly provided — omitting them preserves
|
|
310
|
+
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
311
|
+
...(backend ? { backend } : {}),
|
|
312
|
+
...(model ? { model } : {}) });
|
|
240
313
|
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
314
|
+
// forge#2175: a non-success termination previously printed nothing beyond
|
|
315
|
+
// the bare reason above — the actual failing phase/attempt/reason was only
|
|
316
|
+
// recoverable by manually reading ~/.forge/runs/{issue}.jsonl and then the
|
|
317
|
+
// engine source. Print it here instead, reconstructed from the same
|
|
318
|
+
// run-log runIssue() just wrote. Skipped for "merged" (the success case)
|
|
319
|
+
// to avoid regressing/cluttering the existing happy-path output.
|
|
320
|
+
if (res.terminalReason !== "merged") {
|
|
321
|
+
console.log(formatTerminalDiagnostics(dir, issue));
|
|
322
|
+
}
|
|
241
323
|
return res;
|
|
242
324
|
}
|
|
243
325
|
|
package/bin/engine.mjs
CHANGED
|
@@ -8,13 +8,51 @@ import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.m
|
|
|
8
8
|
import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
|
|
9
9
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
10
10
|
import { makeProjector } from "./engine/projector.mjs";
|
|
11
|
+
import { VALID_BACKENDS } from "./runner.mjs";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
|
|
14
|
+
// diagnostics without duplicating the retry budget constant.
|
|
15
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
|
|
20
|
+
* to every phase's `runner()` call when supplied. Omit to keep runner.mjs's own
|
|
21
|
+
* default ("auto" ladder — probes the `claude` CLI, falls back to the API).
|
|
22
|
+
* An invalid value throws synchronously (see below) rather than being forwarded.
|
|
23
|
+
* @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
|
|
24
|
+
* `runner()` call when supplied; only applies on the "api" backend. Omit to keep
|
|
25
|
+
* runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
|
|
26
|
+
*/
|
|
14
27
|
export async function runIssue(opts) {
|
|
15
28
|
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
16
29
|
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
17
|
-
commandsDir = fileURLToPath(new URL("../commands", import.meta.url))
|
|
30
|
+
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
|
|
31
|
+
// Optional execution-backend override for every phase's `runner()`
|
|
32
|
+
// call (forge#2028 / MAT-3). Left undefined by default so existing
|
|
33
|
+
// callers keep runner.mjs's own "auto" ladder default unchanged —
|
|
34
|
+
// this is purely additive pass-through, not a new default.
|
|
35
|
+
backend, model } = opts;
|
|
36
|
+
|
|
37
|
+
// forge#2054: validate `backend` before anything else — before state is
|
|
38
|
+
// read/written and before the phase/retry loop begins below. An invalid
|
|
39
|
+
// value must fail fast and non-retryably. Without this check, an invalid
|
|
40
|
+
// backend instead reaches runner.mjs's resolveBackend() deep inside
|
|
41
|
+
// runPhaseWithRetry()'s per-attempt try/catch, throws an uncoded Error, and
|
|
42
|
+
// is silently retried up to `maxAttempts` times (the catch there only
|
|
43
|
+
// fast-fails on `.code === "NO_API_KEY"/"NO_SDK"`) before escalating to
|
|
44
|
+
// needs-human — burning retries on what is actually a config error, not a
|
|
45
|
+
// transient phase failure. runIssue() is the single production choke point
|
|
46
|
+
// (its only caller is bin/engine-cli.mjs's runFromCli()), so validating
|
|
47
|
+
// here protects every current and future caller without requiring each one
|
|
48
|
+
// to remember to validate independently.
|
|
49
|
+
if (backend !== undefined && !VALID_BACKENDS.has(backend)) {
|
|
50
|
+
throw Object.assign(
|
|
51
|
+
new Error(`Invalid backend "${backend}". Must be one of: ${[...VALID_BACKENDS].join(", ")}.`),
|
|
52
|
+
{ code: "INVALID_BACKEND" },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
18
56
|
const projector = makeProjector(io);
|
|
19
57
|
|
|
20
58
|
// 1. Load + reconcile (GitHub wins).
|
|
@@ -49,11 +87,17 @@ export async function runIssue(opts) {
|
|
|
49
87
|
}
|
|
50
88
|
|
|
51
89
|
// 2. Drive phases until terminal.
|
|
90
|
+
//
|
|
91
|
+
// Note: the build phase's branch is NOT precomputed here. The real branch
|
|
92
|
+
// name is slug-derived from the issue title by `commands/work-on/build.md`
|
|
93
|
+
// (Phase B1A) and cannot be guessed ahead of time — a prior guessed default
|
|
94
|
+
// (`fix/pipeline-{issue}`) was never communicated to the builder and never
|
|
95
|
+
// matched the branch it actually created, so `commitsAhead()` always ran
|
|
96
|
+
// against a nonexistent ref and silently evaluated to 0 (forge#2174). The
|
|
97
|
+
// build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
|
|
98
|
+
// the real branch from the `FORGE:BUILDER` comment instead.
|
|
52
99
|
let phase;
|
|
53
100
|
while ((phase = pickPhase(state))) {
|
|
54
|
-
// Every issue's build works on a deterministic branch; set it before build runs.
|
|
55
|
-
if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
|
|
56
|
-
|
|
57
101
|
let reconciled;
|
|
58
102
|
try {
|
|
59
103
|
reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
|
|
@@ -67,7 +111,7 @@ export async function runIssue(opts) {
|
|
|
67
111
|
outcome = { status: "committed", outputs: reconciled.outputs || {} };
|
|
68
112
|
} else {
|
|
69
113
|
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
70
|
-
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts });
|
|
114
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
|
|
71
115
|
}
|
|
72
116
|
|
|
73
117
|
if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
|
|
@@ -95,11 +139,17 @@ export async function runIssue(opts) {
|
|
|
95
139
|
}
|
|
96
140
|
|
|
97
141
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
98
|
-
const { io, runner, dir, issue, commandsDir, maxAttempts } = ctx;
|
|
142
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
|
|
99
143
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
100
144
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
101
145
|
try {
|
|
102
|
-
await runner({
|
|
146
|
+
await runner({
|
|
147
|
+
commandsDir, commandName: phase.command, args: [String(issue)],
|
|
148
|
+
// Only forwarded when explicitly provided — omitting them preserves
|
|
149
|
+
// runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
|
|
150
|
+
...(backend ? { backend } : {}),
|
|
151
|
+
...(model ? { model } : {}),
|
|
152
|
+
});
|
|
103
153
|
} catch (e) {
|
|
104
154
|
// A missing API key / SDK is a config error, not a transient phase
|
|
105
155
|
// failure — surface it distinctly instead of burning retries and
|
|
@@ -111,6 +161,19 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
111
161
|
const outcome = await phase.detectOutcome(state, io);
|
|
112
162
|
if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
|
|
113
163
|
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
|
|
164
|
+
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
165
|
+
// state-derived fixed point — re-running the phase's runner is
|
|
166
|
+
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
167
|
+
// builder already completed and will no-op on any re-invocation, per
|
|
168
|
+
// commands/work-on/build.md's own `BUILD_RESULT: status: ALREADY_DONE`
|
|
169
|
+
// early-exit). Honor that signal by stopping immediately rather than
|
|
170
|
+
// burning the remaining attempt budget on a guaranteed-identical result.
|
|
171
|
+
// Absent/undefined `retryable` defaults to retryable (existing behavior
|
|
172
|
+
// for every phase that doesn't opt in — investigate/context/architect/
|
|
173
|
+
// review/close — is unchanged, preserving transient-failure retries).
|
|
174
|
+
if (outcome.retryable === false) {
|
|
175
|
+
return { status: "blocked", detail: outcome.detail };
|
|
176
|
+
}
|
|
114
177
|
}
|
|
115
178
|
// Exhausted transient retries → escalate (spec §7).
|
|
116
179
|
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
|