forgedock 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
package/bin/engine.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
8
|
-
import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
|
|
8
|
+
import { pickPhase, TERMINAL_REASONS, issueSnapshot } from "./engine/phases.mjs";
|
|
9
9
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
10
10
|
import { makeProjector } from "./engine/projector.mjs";
|
|
11
11
|
import { VALID_BACKENDS } from "./runner.mjs";
|
|
@@ -14,26 +14,37 @@ import { VALID_BACKENDS } from "./runner.mjs";
|
|
|
14
14
|
// diagnostics without duplicating the retry budget constant.
|
|
15
15
|
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
16
16
|
|
|
17
|
+
// forge#2239: lease TTL and renewal-interval defaults. Exported/overridable
|
|
18
|
+
// (mirrors DEFAULT_MAX_ATTEMPTS) so tests can exercise "phase outlives the
|
|
19
|
+
// TTL, lease gets renewed" without waiting on real 10-minute wall-clock time.
|
|
20
|
+
export const DEFAULT_LEASE_TTL_MS = 600000;
|
|
21
|
+
export const DEFAULT_LEASE_RENEW_INTERVAL_MS = 240000;
|
|
22
|
+
|
|
23
|
+
// forge#2524: bounds how many consecutive session-limit pauses a single
|
|
24
|
+
// phase attempt will ride out before falling through to the existing
|
|
25
|
+
// engine-error terminate path. Exists purely as a defensive backstop against
|
|
26
|
+
// a persistently-misparsed/misreported reset time (clock skew, a CLI that
|
|
27
|
+
// always claims "resets in 1 minute" for some unrelated reason) spinning the
|
|
28
|
+
// engine forever — a genuine multi-hour session-limit reset is expected to
|
|
29
|
+
// need at most one or two pauses in practice.
|
|
30
|
+
export const DEFAULT_MAX_SESSION_LIMIT_PAUSES = 5;
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
33
|
+
* Validates the subset of `runIssue()`'s options that must fail fast,
|
|
34
|
+
* synchronously, before any state I/O or timer creation — `backend` and the
|
|
35
|
+
* lease-timing pair. Extracted from `runIssue()` (forge#2452) purely for
|
|
36
|
+
* readability; call-order and thrown-error shape are unchanged from the
|
|
37
|
+
* inline block this replaces, and MUST stay that way — see the per-check
|
|
38
|
+
* rationale comments below, all of which trace back to real review findings.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} params
|
|
41
|
+
* @param {string} [params.backend] - "cli" | "api" | "auto" (forge#2028).
|
|
42
|
+
* @param {number} params.leaseTtlMs - forge#2239: how long a claimed lease is valid for.
|
|
43
|
+
* @param {number} params.leaseRenewIntervalMs - forge#2239: how often the lease is re-written.
|
|
44
|
+
* @param {number} params.maxSessionLimitPauses - forge#2524: bound on consecutive session-limit pauses.
|
|
45
|
+
* @throws {Error & {code: "INVALID_BACKEND"|"INVALID_LEASE_CONFIG"|"INVALID_SESSION_LIMIT_CONFIG"}}
|
|
26
46
|
*/
|
|
27
|
-
|
|
28
|
-
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
29
|
-
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
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
|
-
|
|
47
|
+
function validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses }) {
|
|
37
48
|
// forge#2054: validate `backend` before anything else — before state is
|
|
38
49
|
// read/written and before the phase/retry loop begins below. An invalid
|
|
39
50
|
// value must fail fast and non-retryably. Without this check, an invalid
|
|
@@ -53,6 +64,174 @@ export async function runIssue(opts) {
|
|
|
53
64
|
);
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
// forge#2313: validate the lease-timing relationship before anything else —
|
|
68
|
+
// same placement/rationale as the INVALID_BACKEND check above (before state
|
|
69
|
+
// is read/written, before the phase loop begins). `leaseTtlMs` controls how
|
|
70
|
+
// long a claimed lease is valid for; `leaseRenewIntervalMs` controls how
|
|
71
|
+
// often the in-flight heartbeat (forge#2239) re-writes it. If the renew
|
|
72
|
+
// interval is >= the TTL, the first renewal tick fires at or after the
|
|
73
|
+
// moment the previously-written lease already expired — reopening the exact
|
|
74
|
+
// "phase outlives its lease" gap #2239 closed, because a genuinely-alive
|
|
75
|
+
// run would publish (or be caught holding) an expired `lease.until` in that
|
|
76
|
+
// window, which both the I3 concurrency guard above and the stall-recovery
|
|
77
|
+
// scanner (commands/orchestrate/phase-3-dependency.md) read directly. Both
|
|
78
|
+
// parameters are overridable-for-tests options with no current production
|
|
79
|
+
// caller forwarding them (bin/engine-cli.mjs's runFromCli() only forwards
|
|
80
|
+
// `backend`/`model`), so this only guards a future caller/config typo —
|
|
81
|
+
// reject rather than silently clamp, matching INVALID_BACKEND's precedent.
|
|
82
|
+
//
|
|
83
|
+
// forge#2329: guard for finite numbers BEFORE the relational check below.
|
|
84
|
+
// Every relational comparison against NaN evaluates to false in JS, so a
|
|
85
|
+
// NaN in either parameter silently passes `leaseRenewIntervalMs >=
|
|
86
|
+
// leaseTtlMs` (both `NaN >= x` and `x >= NaN` are false) — reopening the
|
|
87
|
+
// exact gap this whole guard exists to close, just via a different bad
|
|
88
|
+
// input. Infinity/-Infinity are "numbers" per `typeof` but are equally
|
|
89
|
+
// nonsensical as a lease duration/interval and are not reliably caught by
|
|
90
|
+
// the relational check either. Non-number types (strings, null, objects)
|
|
91
|
+
// are coerced by `>=` instead of rejected, which can silently poison
|
|
92
|
+
// downstream arithmetic (`now() + leaseTtlMs`) with string concatenation
|
|
93
|
+
// instead of numeric addition. Same placement precedent as the relational
|
|
94
|
+
// check itself: synchronous, before any state I/O or timer creation, so it
|
|
95
|
+
// cannot interact with the async lease-renewal heartbeat or the
|
|
96
|
+
// crash-and-relaunch write-then-throw architecture (bin/tests/engine-crash.test.mjs).
|
|
97
|
+
for (const [name, value] of [["leaseTtlMs", leaseTtlMs], ["leaseRenewIntervalMs", leaseRenewIntervalMs]]) {
|
|
98
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
99
|
+
throw Object.assign(
|
|
100
|
+
new Error(
|
|
101
|
+
`Invalid lease config: ${name} must be a finite number, got ${typeof value === "number" ? value : `${typeof value} (${JSON.stringify(value)})`}.`,
|
|
102
|
+
),
|
|
103
|
+
{ code: "INVALID_LEASE_CONFIG" },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (leaseRenewIntervalMs >= leaseTtlMs) {
|
|
108
|
+
throw Object.assign(
|
|
109
|
+
new Error(
|
|
110
|
+
`Invalid lease config: leaseRenewIntervalMs (${leaseRenewIntervalMs}) must be less than leaseTtlMs (${leaseTtlMs}), or a live run's lease can expire before the next renewal.`,
|
|
111
|
+
),
|
|
112
|
+
{ code: "INVALID_LEASE_CONFIG" },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// forge#2524: same finite-number + non-negative guard precedent as the
|
|
117
|
+
// lease-timing pair above (forge#2329) — a NaN/negative pause bound would
|
|
118
|
+
// either silently disable the pause-count backstop (every relational
|
|
119
|
+
// comparison against NaN is false, so `pauseCount < maxSessionLimitPauses`
|
|
120
|
+
// would never trip) or reject every pause immediately (a negative bound).
|
|
121
|
+
// Validated eagerly here, synchronously, before any state I/O or timer
|
|
122
|
+
// creation — identical placement rationale to the checks above.
|
|
123
|
+
if (typeof maxSessionLimitPauses !== "number" || !Number.isFinite(maxSessionLimitPauses) || maxSessionLimitPauses < 0) {
|
|
124
|
+
throw Object.assign(
|
|
125
|
+
new Error(
|
|
126
|
+
`Invalid session-limit config: maxSessionLimitPauses must be a finite number >= 0, got ${typeof maxSessionLimitPauses === "number" ? maxSessionLimitPauses : `${typeof maxSessionLimitPauses} (${JSON.stringify(maxSessionLimitPauses)})`}.`,
|
|
127
|
+
),
|
|
128
|
+
{ code: "INVALID_SESSION_LIMIT_CONFIG" },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Builds the defensive `emitProgress` wrapper around a caller-supplied
|
|
135
|
+
* `onProgress` observer. Extracted from `runIssue()` (forge#2452) — purely a
|
|
136
|
+
* factory, no shared state beyond the `onProgress` closure itself, so this
|
|
137
|
+
* carries no behavioral risk.
|
|
138
|
+
*
|
|
139
|
+
* forge#2240: a caller's onProgress must never be able to crash an otherwise
|
|
140
|
+
* healthy run. A plain try/catch only guards a *synchronous* throw — if
|
|
141
|
+
* onProgress is (or becomes) async and its returned promise rejects, that
|
|
142
|
+
* rejection is never awaited/caught here, producing an unhandled promise
|
|
143
|
+
* rejection that terminates the whole Node process (Node >=15 default
|
|
144
|
+
* behavior) well after runIssue() itself has already resolved. That is
|
|
145
|
+
* strictly worse than the silent-hang bug this issue fixes. Detect a
|
|
146
|
+
* thenable return value and attach a no-op .catch() to it (fire-and-forget —
|
|
147
|
+
* onProgress is not awaited either way, consistent with its documented
|
|
148
|
+
* synchronous-observer contract) so a rejection can never escape as
|
|
149
|
+
* unhandled. (review finding, #2240)
|
|
150
|
+
*
|
|
151
|
+
* @param {(event: {event: string, phase: string, status?: string, detail?: string}) => void} onProgress
|
|
152
|
+
* @returns {(event: object) => void}
|
|
153
|
+
*/
|
|
154
|
+
function makeProgressEmitter(onProgress) {
|
|
155
|
+
return (event) => {
|
|
156
|
+
try {
|
|
157
|
+
const result = onProgress(event);
|
|
158
|
+
if (result && typeof result.then === "function") {
|
|
159
|
+
result.catch(() => { /* best-effort observer, never fatal */ });
|
|
160
|
+
}
|
|
161
|
+
} catch { /* best-effort observer, never fatal */ }
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {object} opts
|
|
167
|
+
* @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
|
|
168
|
+
* to every phase's `runner()` call when supplied. Omit to keep runner.mjs's own
|
|
169
|
+
* default ("auto" ladder — probes the `claude` CLI, falls back to the API).
|
|
170
|
+
* An invalid value throws synchronously (see below) rather than being forwarded.
|
|
171
|
+
* @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
|
|
172
|
+
* `runner()` call when supplied; only applies on the "api" backend. Omit to keep
|
|
173
|
+
* runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
|
|
174
|
+
* @param {number} [opts.leaseTtlMs] - forge#2239: how long a claimed lease is
|
|
175
|
+
* valid for. Defaults to DEFAULT_LEASE_TTL_MS. Overridable for tests.
|
|
176
|
+
* @param {number} [opts.leaseRenewIntervalMs] - forge#2239: how often the lease
|
|
177
|
+
* is re-written while an unsatisfied phase's runner is executing. Defaults to
|
|
178
|
+
* DEFAULT_LEASE_RENEW_INTERVAL_MS. Overridable for tests.
|
|
179
|
+
* @param {(event: {event: string, phase: string, status?: string, detail?: string, resetAt?: number}) => void} [opts.onProgress] -
|
|
180
|
+
* forge#2240: optional phase-boundary observer. Called with
|
|
181
|
+
* `{event: "phase_enter", phase}` right before a phase's runner is about to
|
|
182
|
+
* execute (i.e. the phase was NOT already satisfied on reconcile), with
|
|
183
|
+
* `{event: "phase_exit", phase, status: "committed"|"blocked", detail?}`
|
|
184
|
+
* once that phase's outcome is known, and (forge#2524) with
|
|
185
|
+
* `{event: "phase_paused", phase, reason: "session-limit", resetAt, detail}`
|
|
186
|
+
* when a session-limit hit pauses in-process (see the pause/retry loop
|
|
187
|
+
* below). Defaults to a no-op so every existing caller/test is unaffected.
|
|
188
|
+
* Deliberately engine.mjs's ONLY new surface for this issue — no
|
|
189
|
+
* `console.log`/stdout write is added here; printing is the CLI layer's job
|
|
190
|
+
* (bin/engine-cli.mjs), preserving the io-injection/testability convention
|
|
191
|
+
* this module already follows. Invocations are wrapped so a throwing
|
|
192
|
+
* callback can never crash a run.
|
|
193
|
+
* @param {() => number} [opts.now] - clock, overridable for tests.
|
|
194
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep] - forge#2524: the wait
|
|
195
|
+
* primitive used by the session-limit pause loop below. Defaults to a real
|
|
196
|
+
* `setTimeout`-based sleep. Overridable for tests so the suite never
|
|
197
|
+
* actually waits out a real (potentially multi-hour) session-limit reset —
|
|
198
|
+
* a test injects a fast/no-op sleep and asserts the *requested* duration
|
|
199
|
+
* instead of waiting for it. Mirrors the existing `now`/`onProgress`
|
|
200
|
+
* injectability convention.
|
|
201
|
+
* @param {number} [opts.maxSessionLimitPauses] - forge#2524: caps how many
|
|
202
|
+
* consecutive session-limit pauses a single phase attempt will ride out
|
|
203
|
+
* before falling through to the existing engine-error terminate path.
|
|
204
|
+
* Defaults to DEFAULT_MAX_SESSION_LIMIT_PAUSES. Purely a defensive backstop
|
|
205
|
+
* against a persistently-misreported reset time; a genuine reset is
|
|
206
|
+
* expected to need at most one or two pauses in practice.
|
|
207
|
+
*/
|
|
208
|
+
export async function runIssue(opts) {
|
|
209
|
+
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
210
|
+
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
211
|
+
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
|
|
212
|
+
// Optional execution-backend override for every phase's `runner()`
|
|
213
|
+
// call (forge#2028 / MAT-3). Left undefined by default so existing
|
|
214
|
+
// callers keep runner.mjs's own "auto" ladder default unchanged —
|
|
215
|
+
// this is purely additive pass-through, not a new default.
|
|
216
|
+
backend, model,
|
|
217
|
+
leaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
218
|
+
leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
|
|
219
|
+
// forge#2524: real default is a genuine setTimeout-based wait —
|
|
220
|
+
// tests inject a fast/no-op replacement (see opts.sleep doc above).
|
|
221
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
222
|
+
maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES,
|
|
223
|
+
onProgress = () => {} } = opts;
|
|
224
|
+
|
|
225
|
+
// forge#2452: extracted into makeProgressEmitter() — see its docstring for
|
|
226
|
+
// the forge#2240 rationale this wrapper implements.
|
|
227
|
+
const emitProgress = makeProgressEmitter(onProgress);
|
|
228
|
+
|
|
229
|
+
// forge#2452: extracted into validateRunIssueOptions() — see its docstring
|
|
230
|
+
// for the forge#2054/#2313/#2329 rationale and required call-order (before
|
|
231
|
+
// any state I/O or timer creation, unchanged from the inline block this
|
|
232
|
+
// replaces).
|
|
233
|
+
validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses });
|
|
234
|
+
|
|
56
235
|
const projector = makeProjector(io);
|
|
57
236
|
|
|
58
237
|
// 1. Load + reconcile (GitHub wins).
|
|
@@ -72,9 +251,6 @@ export async function runIssue(opts) {
|
|
|
72
251
|
if (!state) {
|
|
73
252
|
state = freshState(issue, lane);
|
|
74
253
|
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
75
|
-
await projector.writeState(issue, state);
|
|
76
|
-
} else if (action === "remirror") {
|
|
77
|
-
await projector.writeState(issue, state);
|
|
78
254
|
} else if (action === "hydrate") {
|
|
79
255
|
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
80
256
|
// local run-log to match the remote compact index so downstream
|
|
@@ -83,8 +259,20 @@ export async function runIssue(opts) {
|
|
|
83
259
|
// and regress committed/issue/v (C2).
|
|
84
260
|
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
85
261
|
state = deriveState(readLog(dir, issue));
|
|
86
|
-
await projector.writeState(issue, state);
|
|
87
262
|
}
|
|
263
|
+
// forge#2239: claim the lease unconditionally here — before the phase loop
|
|
264
|
+
// starts, for EVERY reconcile action ("fresh", "remirror", "hydrate", and
|
|
265
|
+
// "local"). Previously only "fresh"/"remirror"/"hydrate" wrote any state at
|
|
266
|
+
// all at this point, and none of them claimed a real (non-null) lease — the
|
|
267
|
+
// lease was only ever written retroactively after a phase's PHASE_COMMIT
|
|
268
|
+
// (see the write inside the loop below), leaving the entire first phase
|
|
269
|
+
// (and, for the "local" action, every phase before the first commit)
|
|
270
|
+
// publishing lease:null. That is indistinguishable from a dead run to both
|
|
271
|
+
// the I3 concurrency guard above and the stall-recovery scan in
|
|
272
|
+
// commands/orchestrate/phase-3-dependency.md. This write MUST stay after the I3 guard
|
|
273
|
+
// check above (commit 541a3e5 fixed the reverse ordering as a real bug —
|
|
274
|
+
// do not reintroduce it).
|
|
275
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
88
276
|
|
|
89
277
|
// 2. Drive phases until terminal.
|
|
90
278
|
//
|
|
@@ -98,6 +286,66 @@ export async function runIssue(opts) {
|
|
|
98
286
|
// the real branch from the `FORGE:BUILDER` comment instead.
|
|
99
287
|
let phase;
|
|
100
288
|
while ((phase = pickPhase(state))) {
|
|
289
|
+
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
290
|
+
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
291
|
+
// progress) — never the issue's live GitHub state/labels — so a phase
|
|
292
|
+
// could run to completion against an issue that was independently closed
|
|
293
|
+
// (e.g. by a human, or by the investigate phase's own marker path) or
|
|
294
|
+
// labeled `workflow:invalid`/`needs-human` after this run's local state
|
|
295
|
+
// was last derived. `close` is deliberately exempt: it is the one phase
|
|
296
|
+
// whose entire job IS to read and act on this exact state (see its
|
|
297
|
+
// reconcile/detectOutcome in bin/engine/phases.mjs), so guarding it here
|
|
298
|
+
// would be redundant and could race with its own read of the same data.
|
|
299
|
+
//
|
|
300
|
+
// Cost: one extra `gh issue view` per loop iteration (skipped only for
|
|
301
|
+
// `close`, which already pays this cost itself) — bounded by the phase
|
|
302
|
+
// count (currently 6), not the retry budget inside a single phase.
|
|
303
|
+
//
|
|
304
|
+
// Fail-open on a snapshot error (`!snap.ok`): a transient `gh`/network
|
|
305
|
+
// failure here must not block a healthy run any more than the identical
|
|
306
|
+
// fail-open behavior in `close`'s own `reconcile` (bin/engine/phases.mjs)
|
|
307
|
+
// or the `reconcile` try/catch just below in this same loop.
|
|
308
|
+
if (phase.id !== "close") {
|
|
309
|
+
let snap;
|
|
310
|
+
try {
|
|
311
|
+
snap = await issueSnapshot(issue, io);
|
|
312
|
+
} catch {
|
|
313
|
+
snap = { ok: false, state: null, labels: [] };
|
|
314
|
+
}
|
|
315
|
+
if (snap.ok) {
|
|
316
|
+
// `workflow:invalid`, or CLOSED without `workflow:merged`, means the
|
|
317
|
+
// issue is dead — nothing a further phase does can be consequential.
|
|
318
|
+
// Reuses the existing "invalid" terminal reason (TERMINAL_REASONS)
|
|
319
|
+
// rather than inventing a new one; #2353 makes the same choice for
|
|
320
|
+
// `close`'s own CLOSED-not-merged case.
|
|
321
|
+
const isDead = snap.labels.includes("workflow:invalid") ||
|
|
322
|
+
(snap.state === "CLOSED" && !snap.labels.includes("workflow:merged"));
|
|
323
|
+
if (isDead) {
|
|
324
|
+
const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
|
|
325
|
+
`with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
|
|
326
|
+
return await terminate(state, "invalid", detail);
|
|
327
|
+
}
|
|
328
|
+
// `needs-human` is a PAUSE, not a death sentence — a human may still
|
|
329
|
+
// resolve it, and /orchestrate's classify_predecessor_state() already
|
|
330
|
+
// treats this label as GATED (not FAILED), so terminating here with
|
|
331
|
+
// the same reason composes with that existing contract instead of
|
|
332
|
+
// conflating "paused pending a human" with "dead". Deliberately does
|
|
333
|
+
// NOT check `workflow:invalid`/CLOSED here — those are handled above.
|
|
334
|
+
if (snap.labels.includes("needs-human")) {
|
|
335
|
+
const detail = `issue ${issue} carries needs-human — pausing before phase ${phase.id}`;
|
|
336
|
+
return await terminate(state, "needs-human", detail);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// forge#2321: tracks whether `phase_enter` was actually emitted for this
|
|
341
|
+
// loop iteration. Re-declared `false` on every iteration (never hoisted
|
|
342
|
+
// above the loop) so a reconcile-satisfied phase can never inherit a
|
|
343
|
+
// stale `true` from a previous iteration's actually-run phase. Only the
|
|
344
|
+
// committed-exit emission below needs this guard — the blocked-exit
|
|
345
|
+
// emission is unreachable from the `reconciled.satisfied` branch, since
|
|
346
|
+
// that branch always yields `status: "committed"` (see below), never
|
|
347
|
+
// "blocked".
|
|
348
|
+
let phaseEntered = false;
|
|
101
349
|
let reconciled;
|
|
102
350
|
try {
|
|
103
351
|
reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
|
|
@@ -111,18 +359,213 @@ export async function runIssue(opts) {
|
|
|
111
359
|
outcome = { status: "committed", outputs: reconciled.outputs || {} };
|
|
112
360
|
} else {
|
|
113
361
|
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
114
|
-
|
|
362
|
+
// forge#2240: the phase is actually about to run (not a resume no-op)
|
|
363
|
+
// — this is the one point in the loop where "entering phase X" is true.
|
|
364
|
+
phaseEntered = true;
|
|
365
|
+
emitProgress({ event: "phase_enter", phase: phase.id });
|
|
366
|
+
// forge#2239: renew the lease immediately before running this phase's
|
|
367
|
+
// runner, then keep renewing it on a heartbeat for as long as the
|
|
368
|
+
// runner is in flight. A single phase can legitimately run longer than
|
|
369
|
+
// leaseTtlMs — without renewal, a healthy, still-running phase would
|
|
370
|
+
// eventually publish an *expired* (not just null) lease and get reaped
|
|
371
|
+
// by stall recovery even though the run is fine. The interval is
|
|
372
|
+
// `.unref()`'d so it can never keep the process alive on its own, and
|
|
373
|
+
// is always cleared in `finally` so it cannot outlive this phase's
|
|
374
|
+
// execution — covers the committed path, the blocked path, and the
|
|
375
|
+
// engine-error fail-fast throw caught just below.
|
|
376
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
377
|
+
// forge#2239 (review finding): `projector.writeState` is a plain
|
|
378
|
+
// read-body/edit-body round trip with no CAS — whichever `gh issue
|
|
379
|
+
// edit` call actually lands last on GitHub wins, regardless of dispatch
|
|
380
|
+
// order. A fire-and-forget renewal write left in flight when this
|
|
381
|
+
// phase finishes could land AFTER the post-commit write or even after
|
|
382
|
+
// terminate()'s `lease: null` write, resurrecting a phantom lease on an
|
|
383
|
+
// already-terminated run. Track the most recently dispatched renewal's
|
|
384
|
+
// promise and await it in `finally` (below) before letting the loop
|
|
385
|
+
// proceed — this guarantees no renewal write is still in flight when
|
|
386
|
+
// control moves on to the next write (commit or terminate).
|
|
387
|
+
// forge#2348 (review finding): the above join-tracking scheme only
|
|
388
|
+
// ever holds ONE promise in `pendingRenewal` — it protects against a
|
|
389
|
+
// single in-flight write landing late, but has no backpressure against
|
|
390
|
+
// a SECOND renewal tick firing while the first write is still in
|
|
391
|
+
// flight (write round trip > leaseRenewIntervalMs). Without a guard,
|
|
392
|
+
// `pendingRenewal` would simply be overwritten with the newer write's
|
|
393
|
+
// promise, silently orphaning the earlier one from every join point
|
|
394
|
+
// (both this closure's own `finally` below and the #2338 catch-path
|
|
395
|
+
// join) — the earlier write could then land on GitHub after a later
|
|
396
|
+
// write, or after the terminate()/commit write joins only the latest
|
|
397
|
+
// promise and proceeds. Guarding here so at most one renewal write is
|
|
398
|
+
// ever in flight keeps the existing single-variable join points
|
|
399
|
+
// correct and sufficient: skip this tick entirely if a previous
|
|
400
|
+
// renewal write hasn't settled yet, and clear `pendingRenewal` back to
|
|
401
|
+
// null once it does (success or failure) so the guard never
|
|
402
|
+
// permanently wedges future renewals.
|
|
403
|
+
let pendingRenewal = null;
|
|
404
|
+
const renewLease = () => {
|
|
405
|
+
if (pendingRenewal) return;
|
|
406
|
+
pendingRenewal = projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } }).catch(() => {
|
|
407
|
+
// Best-effort: a transient renewal failure must not crash the run.
|
|
408
|
+
// The next scheduled renewal (or the post-commit write) will retry.
|
|
409
|
+
}).finally(() => {
|
|
410
|
+
pendingRenewal = null;
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
const renewTimer = setInterval(renewLease, leaseRenewIntervalMs);
|
|
414
|
+
if (typeof renewTimer.unref === "function") renewTimer.unref();
|
|
415
|
+
try {
|
|
416
|
+
// forge#2524: bounded pause/retry loop for a genuine, still-future
|
|
417
|
+
// session-limit hit. `runPhaseWithRetry()` itself remains fail-fast
|
|
418
|
+
// for CLI_BACKEND_FAILED (forge#2259 — a deterministic CLI crash
|
|
419
|
+
// reproduces identically on every attempt within one call) — this
|
|
420
|
+
// loop operates one layer up, deciding whether to call it again from
|
|
421
|
+
// scratch (fresh attempt budget) after waiting out a temporary usage
|
|
422
|
+
// cap, rather than treating every CLI_BACKEND_FAILED as equally
|
|
423
|
+
// fatal. `renewTimer` (started above) keeps renewing the lease for
|
|
424
|
+
// the entire duration of this loop, including any `sleep()` below —
|
|
425
|
+
// no separate heartbeat mechanism is needed for the wait itself.
|
|
426
|
+
let pauseCount = 0;
|
|
427
|
+
for (;;) {
|
|
428
|
+
try {
|
|
429
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
|
|
430
|
+
break;
|
|
431
|
+
} catch (e) {
|
|
432
|
+
const resetAtMs = e.resetAtEpochMs;
|
|
433
|
+
const canPause =
|
|
434
|
+
e.code === "CLI_BACKEND_FAILED" &&
|
|
435
|
+
typeof resetAtMs === "number" && Number.isFinite(resetAtMs) &&
|
|
436
|
+
resetAtMs > now() &&
|
|
437
|
+
pauseCount < maxSessionLimitPauses;
|
|
438
|
+
if (!canPause) throw e; // falls through to the unchanged outer catch below
|
|
439
|
+
pauseCount += 1;
|
|
440
|
+
const waitMs = Math.max(0, resetAtMs - now());
|
|
441
|
+
// forge#2524 AC4: append a run-log event so a crash mid-wait
|
|
442
|
+
// leaves a durable record of the pause (deriveState() folds this
|
|
443
|
+
// into RunState.lastRateLimit — see bin/engine/runlog.mjs). This
|
|
444
|
+
// does NOT touch committed/terminal state: the phase genuinely
|
|
445
|
+
// hasn't committed yet, so a resumed run correctly re-enters
|
|
446
|
+
// pickPhase at this same phase regardless of whether this event
|
|
447
|
+
// was ever written.
|
|
448
|
+
appendEvent(dir, issue, {
|
|
449
|
+
event: "PHASE_RATE_LIMITED", phase: phase.id,
|
|
450
|
+
resetAt: resetAtMs, resetAtDisplay: e.resetAt || null, waitMs,
|
|
451
|
+
});
|
|
452
|
+
// forge#2524 AC6: progress observer sees the pause so a caller
|
|
453
|
+
// (bin/engine-cli.mjs) can render the wait state instead of the
|
|
454
|
+
// process going silent for the duration.
|
|
455
|
+
emitProgress({
|
|
456
|
+
event: "phase_paused", phase: phase.id, reason: "session-limit",
|
|
457
|
+
resetAt: resetAtMs,
|
|
458
|
+
detail: `session limit hit — pausing until ${e.resetAt || new Date(resetAtMs).toISOString()}`,
|
|
459
|
+
});
|
|
460
|
+
await sleep(waitMs);
|
|
461
|
+
// Loop back and retry the same phase with a fresh attempt budget
|
|
462
|
+
// (a brand-new runPhaseWithRetry() call, not a continuation of
|
|
463
|
+
// the exhausted one) — AC2.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {
|
|
467
|
+
// forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
|
|
468
|
+
// rethrown by runPhaseWithRetry() (see its own catch below) instead
|
|
469
|
+
// of being retried — but until now that throw was never caught here,
|
|
470
|
+
// so it propagated all the way out of runIssue() uncaught (through
|
|
471
|
+
// bin/engine-cli.mjs's runFromCli(), which also has no try/catch)
|
|
472
|
+
// and only landed in bin/forgedock.mjs's outermost `run-issue` case,
|
|
473
|
+
// which just prints the message to stderr and exits 1 — no terminal
|
|
474
|
+
// state or label was ever written, leaving the issue stuck on
|
|
475
|
+
// whatever workflow label it already had. Catch it here instead and
|
|
476
|
+
// reach a clean terminal state: "engine-error" is deliberately NOT
|
|
477
|
+
// "needs-human" — this is the engine/tool breaking, not a genuine
|
|
478
|
+
// human-judgment block (see #2244/#2261). Any other thrown error is
|
|
479
|
+
// a true unexpected crash and keeps propagating unchanged.
|
|
480
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") {
|
|
481
|
+
// forge#2241: when the runner attached a session-limit reset time
|
|
482
|
+
// (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
|
|
483
|
+
// for a genuine session-limit CLI_BACKEND_FAILED — never
|
|
484
|
+
// fabricated), append it so the terminal state is legible without
|
|
485
|
+
// reading raw logs. Purely additive: the base detail string is
|
|
486
|
+
// unchanged, and this appends nothing when e.resetAt is absent.
|
|
487
|
+
const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
|
|
488
|
+
const detail = `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
|
|
489
|
+
// forge#2240 (review finding): this fail-fast path previously left
|
|
490
|
+
// phase_exit unreported — a caller tailing progress output would see
|
|
491
|
+
// "→ phase X started" and then nothing, dangling exactly on the
|
|
492
|
+
// phase that actually failed. Report it as blocked before
|
|
493
|
+
// terminating so the progress trail stays complete on this path too.
|
|
494
|
+
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail });
|
|
495
|
+
// forge#2338 (review finding): `return await terminate(...)` here
|
|
496
|
+
// sits INSIDE this catch block, so per try/catch/finally semantics
|
|
497
|
+
// its expression is fully evaluated — including terminate()'s own
|
|
498
|
+
// `lease: null` write completing — BEFORE the `finally` block below
|
|
499
|
+
// ever runs `clearInterval`/`await pendingRenewal`. A renewal write
|
|
500
|
+
// already dispatched before this throw can therefore land on
|
|
501
|
+
// GitHub AFTER terminate()'s `lease: null` write, resurrecting a
|
|
502
|
+
// phantom lease on an already-terminated run — reopening the exact
|
|
503
|
+
// race #2239 was written to close, just on this one call site the
|
|
504
|
+
// #2239 fix (07b3b8a) didn't cover. Explicitly join here, before
|
|
505
|
+
// calling terminate(), rather than relying on `finally` (too
|
|
506
|
+
// late). The `finally` block's identical calls remain below as a
|
|
507
|
+
// harmless idempotent safety net for the `throw e;` path.
|
|
508
|
+
clearInterval(renewTimer);
|
|
509
|
+
if (pendingRenewal) await pendingRenewal;
|
|
510
|
+
return await terminate(state, "engine-error", detail);
|
|
511
|
+
}
|
|
512
|
+
throw e;
|
|
513
|
+
} finally {
|
|
514
|
+
clearInterval(renewTimer);
|
|
515
|
+
// Join any renewal write already dispatched before this phase's
|
|
516
|
+
// outcome is allowed to reach a commit/terminate write — closes the
|
|
517
|
+
// TOCTOU window above. `pendingRenewal` already swallows its own
|
|
518
|
+
// rejection (see renewLease()), so this await never throws.
|
|
519
|
+
if (pendingRenewal) await pendingRenewal;
|
|
520
|
+
}
|
|
115
521
|
}
|
|
116
522
|
|
|
117
|
-
if (outcome.status === "blocked")
|
|
523
|
+
if (outcome.status === "blocked") {
|
|
524
|
+
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
525
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
526
|
+
}
|
|
118
527
|
|
|
119
528
|
// committed
|
|
120
|
-
|
|
529
|
+
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
530
|
+
// actually emitted for this phase this iteration. A reconcile-satisfied
|
|
531
|
+
// phase (short-circuited above without ever entering the `else` branch)
|
|
532
|
+
// never "started" this run — emitting phase_exit for it produced a
|
|
533
|
+
// dangling exit with no preceding enter (bin/engine-cli.mjs prints
|
|
534
|
+
// "✓ phase X committed" with no prior "→ phase X started" line).
|
|
535
|
+
// Suppressing the unmatched exit is correct here rather than fabricating
|
|
536
|
+
// a synthetic phase_enter, since the phase's runner genuinely never ran.
|
|
537
|
+
if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
|
|
538
|
+
// forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
|
|
539
|
+
// from the injected runner()'s (== bin/runner.mjs's runCommand()) return
|
|
540
|
+
// value — null when the backend doesn't report usage (CLI backend today)
|
|
541
|
+
// or when detectOutcome() never ran (this call site is only reached on
|
|
542
|
+
// the "committed" path, so that case doesn't apply here). Kept as a
|
|
543
|
+
// sibling of `outputs` rather than nested inside it, since `outputs` is
|
|
544
|
+
// owned by phase.detectOutcome() (bin/engine/phases.mjs).
|
|
545
|
+
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {}, usage: outcome.usage ?? null });
|
|
121
546
|
state = deriveState(readLog(dir, issue));
|
|
122
547
|
if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
|
|
123
|
-
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() +
|
|
548
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
124
549
|
|
|
125
|
-
|
|
550
|
+
// forge#2379: the "investigate" phase reporting terminalReason "decomposed"
|
|
551
|
+
// is a HANDOFF to the "decompose" phase (bin/engine/phases.mjs), not a
|
|
552
|
+
// dead end — decompose is what actually dispatches work-on/decompose
|
|
553
|
+
// (sub-issue fan-out, FORGE:DECOMPOSED posting). Before this change,
|
|
554
|
+
// "decomposed" being a member of TERMINAL_REASONS meant the run
|
|
555
|
+
// terminated the instant investigate reported it, and decompose's own
|
|
556
|
+
// entryCondition (state.terminalReason === "decomposed") was never
|
|
557
|
+
// actually reachable through pickPhase. Narrowly exempting exactly this
|
|
558
|
+
// phase/reason combination — and no other phase, no other reason — lets
|
|
559
|
+
// the loop continue to pickPhase, which now correctly selects "decompose"
|
|
560
|
+
// next (investigate.isTerminalAfter was narrowed to only "invalid" in
|
|
561
|
+
// the same change, so it no longer independently forces termination
|
|
562
|
+
// here either). Once the "decompose" phase itself later reports
|
|
563
|
+
// terminalReason "decomposed" (re-affirming it after FORGE:DECOMPOSED:COMPLETE
|
|
564
|
+
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
565
|
+
// exemption does not apply and the normal terminate() path below fires,
|
|
566
|
+
// ending the run for real.
|
|
567
|
+
const isDecomposeHandoff = phase.id === "investigate" && outcome.terminalReason === "decomposed";
|
|
568
|
+
if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason) && !isDecomposeHandoff)
|
|
126
569
|
return await terminate(state, outcome.terminalReason);
|
|
127
570
|
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
128
571
|
return await terminate(state, state.terminalReason || "merged");
|
|
@@ -134,16 +577,41 @@ export async function runIssue(opts) {
|
|
|
134
577
|
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
|
|
135
578
|
await projector.writeState(issue, final);
|
|
136
579
|
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
580
|
+
// forge#2261: a distinct label for engine/tool-level failures (broken CLI
|
|
581
|
+
// invocation, exhausted retries where the runner itself never once
|
|
582
|
+
// succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
|
|
583
|
+
// (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
|
|
584
|
+
// there is no human decision pending, just a tool that needs to be re-run.
|
|
585
|
+
else if (reason === "engine-error") await projector.setLabel(issue, "workflow:engine-error");
|
|
137
586
|
return { terminalReason: reason, detail };
|
|
138
587
|
}
|
|
139
588
|
}
|
|
140
589
|
|
|
141
590
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
142
591
|
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
|
|
592
|
+
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
593
|
+
// throwing (never once reached phase.detectOutcome()). This is the signal
|
|
594
|
+
// that distinguishes an engine/tool crash (the tool never even produced a
|
|
595
|
+
// result to evaluate) from a genuine content-level block (the tool ran
|
|
596
|
+
// fine, but the phase's own completion criteria weren't met — e.g. an
|
|
597
|
+
// unmerged PR, an unresolved branch, or a fixed-point zero-commits case).
|
|
598
|
+
// Flips to false the instant any attempt's runner() call succeeds, even if
|
|
599
|
+
// that attempt's detectOutcome() itself reports failure.
|
|
600
|
+
let allAttemptsThrew = true;
|
|
601
|
+
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
602
|
+
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
603
|
+
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
604
|
+
// backend, or null on the CLI backend / when the field is absent). Reset
|
|
605
|
+
// is unnecessary since a thrown attempt never reaches the assignment
|
|
606
|
+
// below — `lastUsage` simply stays whatever the previous successful
|
|
607
|
+
// attempt (if any) set it to, which is correct: it always reflects the
|
|
608
|
+
// most recent attempt that actually produced a result.
|
|
609
|
+
let lastUsage = null;
|
|
143
610
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
144
611
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
612
|
+
let result;
|
|
145
613
|
try {
|
|
146
|
-
await runner({
|
|
614
|
+
result = await runner({
|
|
147
615
|
commandsDir, commandName: phase.command, args: [String(issue)],
|
|
148
616
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
149
617
|
// runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
|
|
@@ -154,13 +622,34 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
154
622
|
// A missing API key / SDK is a config error, not a transient phase
|
|
155
623
|
// failure — surface it distinctly instead of burning retries and
|
|
156
624
|
// escalating to needs-human as if the LLM run itself misbehaved.
|
|
157
|
-
|
|
158
|
-
|
|
625
|
+
//
|
|
626
|
+
// forge#2259: a non-zero exit from the nested `claude` CLI
|
|
627
|
+
// (bin/runner.mjs's runCliBackend(), which sets err.code =
|
|
628
|
+
// "CLI_BACKEND_FAILED" on any non-zero `result.status`) is likewise
|
|
629
|
+
// not a transient phase failure — same argv/cwd/session state
|
|
630
|
+
// reproduces the identical crash on every attempt. Retrying it
|
|
631
|
+
// `maxAttempts` times burns the entire retry budget (observed live in
|
|
632
|
+
// #2244: 3 nested-CLI invocations, zero PRs/commits) on a failure that
|
|
633
|
+
// was never going to succeed. Fail fast instead, mirroring the
|
|
634
|
+
// NO_API_KEY/NO_SDK precedent (commit 570cb10 / #2054 established this
|
|
635
|
+
// exact dispatch pattern for INVALID_BACKEND). This does not assume
|
|
636
|
+
// *why* the CLI exited 1 (the quota/session-limit theory in #2244 is
|
|
637
|
+
// unproven) — a deterministic tool crash should not be retried as if
|
|
638
|
+
// transient regardless of its root cause.
|
|
639
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
640
|
+
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
641
|
+
// (and therefore no usage data) was ever produced for this attempt.
|
|
642
|
+
// Do not fabricate a value; omitting the field (rather than a stale
|
|
643
|
+
// `lastUsage` from a prior attempt) keeps this event's usage
|
|
644
|
+
// trustworthy as "usage actually observed on this attempt".
|
|
645
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts });
|
|
159
646
|
continue;
|
|
160
647
|
}
|
|
648
|
+
allAttemptsThrew = false;
|
|
649
|
+
lastUsage = result?.usage ?? null;
|
|
161
650
|
const outcome = await phase.detectOutcome(state, io);
|
|
162
|
-
if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
|
|
163
|
-
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
|
|
651
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
|
|
652
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
|
|
164
653
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
165
654
|
// state-derived fixed point — re-running the phase's runner is
|
|
166
655
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -172,11 +661,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
172
661
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
173
662
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
174
663
|
if (outcome.retryable === false) {
|
|
175
|
-
return { status: "blocked", detail: outcome.detail };
|
|
664
|
+
return { status: "blocked", detail: outcome.detail, usage: lastUsage };
|
|
176
665
|
}
|
|
177
666
|
}
|
|
178
667
|
// Exhausted transient retries → escalate (spec §7).
|
|
179
|
-
|
|
668
|
+
// forge#2261: if the runner itself threw on every single attempt (it never
|
|
669
|
+
// once produced a result for detectOutcome() to evaluate), this is an
|
|
670
|
+
// engine/tool failure, not a content-level judgment call — tag the outcome
|
|
671
|
+
// so runIssue()'s terminate() writes a distinct label instead of
|
|
672
|
+
// needs-human. If at least one attempt reached detectOutcome() (the tool
|
|
673
|
+
// ran, the phase just isn't done), this stays the existing untagged shape,
|
|
674
|
+
// which runIssue() defaults to "needs-human" — unchanged behavior for every
|
|
675
|
+
// genuine content-level block (unmerged PR, unresolved branch, a transient
|
|
676
|
+
// git error inside commitsAhead(), etc.).
|
|
677
|
+
if (allAttemptsThrew) {
|
|
678
|
+
return {
|
|
679
|
+
status: "blocked",
|
|
680
|
+
detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
|
|
681
|
+
reason: "engine-error",
|
|
682
|
+
usage: null,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, usage: lastUsage };
|
|
180
686
|
}
|
|
181
687
|
|
|
182
688
|
/**
|
|
@@ -197,5 +703,6 @@ function eventsFromIndex(idx) {
|
|
|
197
703
|
|
|
198
704
|
function freshState(issue, lane) {
|
|
199
705
|
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
200
|
-
branch: null, pr: null, terminal: false, terminalReason: null, lease: null
|
|
706
|
+
branch: null, pr: null, terminal: false, terminalReason: null, lease: null,
|
|
707
|
+
lastRateLimit: null };
|
|
201
708
|
}
|