forgedock 1.3.0 → 1.4.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/state.mjs +20 -28
- package/bin/engine-cli.mjs +80 -5
- package/bin/engine.mjs +390 -18
- package/bin/forgedock.mjs +336 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +169 -13
- package/bin/journey.mjs +8 -2
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +221 -22
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr.md +23 -7
- 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 +2 -0
- 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/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/forge-run.sh +35 -4
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,6 +14,12 @@ 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
|
+
|
|
17
23
|
/**
|
|
18
24
|
* @param {object} opts
|
|
19
25
|
* @param {string} [opts.backend] - "cli" | "api" | "auto" (forge#2028). Forwarded
|
|
@@ -23,6 +29,22 @@ export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
|
23
29
|
* @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
|
|
24
30
|
* `runner()` call when supplied; only applies on the "api" backend. Omit to keep
|
|
25
31
|
* runner.mjs's default (`FORGEDOCK_MODEL` env or its built-in default).
|
|
32
|
+
* @param {number} [opts.leaseTtlMs] - forge#2239: how long a claimed lease is
|
|
33
|
+
* valid for. Defaults to DEFAULT_LEASE_TTL_MS. Overridable for tests.
|
|
34
|
+
* @param {number} [opts.leaseRenewIntervalMs] - forge#2239: how often the lease
|
|
35
|
+
* is re-written while an unsatisfied phase's runner is executing. Defaults to
|
|
36
|
+
* DEFAULT_LEASE_RENEW_INTERVAL_MS. Overridable for tests.
|
|
37
|
+
* @param {(event: {event: string, phase: string, status?: string, detail?: string}) => void} [opts.onProgress] -
|
|
38
|
+
* forge#2240: optional phase-boundary observer. Called with
|
|
39
|
+
* `{event: "phase_enter", phase}` right before a phase's runner is about to
|
|
40
|
+
* execute (i.e. the phase was NOT already satisfied on reconcile), and with
|
|
41
|
+
* `{event: "phase_exit", phase, status: "committed"|"blocked", detail?}`
|
|
42
|
+
* once that phase's outcome is known. Defaults to a no-op so every existing
|
|
43
|
+
* caller/test is unaffected. Deliberately engine.mjs's ONLY new surface for
|
|
44
|
+
* this issue — no `console.log`/stdout write is added here; printing is the
|
|
45
|
+
* CLI layer's job (bin/engine-cli.mjs), preserving the io-injection/testability
|
|
46
|
+
* convention this module already follows. Invocations are wrapped so a
|
|
47
|
+
* throwing callback can never crash a run.
|
|
26
48
|
*/
|
|
27
49
|
export async function runIssue(opts) {
|
|
28
50
|
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
@@ -32,7 +54,30 @@ export async function runIssue(opts) {
|
|
|
32
54
|
// call (forge#2028 / MAT-3). Left undefined by default so existing
|
|
33
55
|
// callers keep runner.mjs's own "auto" ladder default unchanged —
|
|
34
56
|
// this is purely additive pass-through, not a new default.
|
|
35
|
-
backend, model
|
|
57
|
+
backend, model,
|
|
58
|
+
leaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
59
|
+
leaseRenewIntervalMs = DEFAULT_LEASE_RENEW_INTERVAL_MS,
|
|
60
|
+
onProgress = () => {} } = opts;
|
|
61
|
+
|
|
62
|
+
// forge#2240: defensive wrapper — a caller's onProgress must never be able
|
|
63
|
+
// to crash an otherwise-healthy run. A plain try/catch only guards a
|
|
64
|
+
// *synchronous* throw — if onProgress is (or becomes) async and its
|
|
65
|
+
// returned promise rejects, that rejection is never awaited/caught here,
|
|
66
|
+
// producing an unhandled promise rejection that terminates the whole
|
|
67
|
+
// Node process (Node >=15 default behavior) well after runIssue() itself
|
|
68
|
+
// has already resolved. That is strictly worse than the silent-hang bug
|
|
69
|
+
// this issue fixes. Detect a thenable return value and attach a no-op
|
|
70
|
+
// .catch() to it (fire-and-forget — onProgress is not awaited either way,
|
|
71
|
+
// consistent with its documented synchronous-observer contract) so a
|
|
72
|
+
// rejection can never escape as unhandled. (review finding, #2240)
|
|
73
|
+
const emitProgress = (event) => {
|
|
74
|
+
try {
|
|
75
|
+
const result = onProgress(event);
|
|
76
|
+
if (result && typeof result.then === "function") {
|
|
77
|
+
result.catch(() => { /* best-effort observer, never fatal */ });
|
|
78
|
+
}
|
|
79
|
+
} catch { /* best-effort observer, never fatal */ }
|
|
80
|
+
};
|
|
36
81
|
|
|
37
82
|
// forge#2054: validate `backend` before anything else — before state is
|
|
38
83
|
// read/written and before the phase/retry loop begins below. An invalid
|
|
@@ -53,6 +98,55 @@ export async function runIssue(opts) {
|
|
|
53
98
|
);
|
|
54
99
|
}
|
|
55
100
|
|
|
101
|
+
// forge#2313: validate the lease-timing relationship before anything else —
|
|
102
|
+
// same placement/rationale as the INVALID_BACKEND check above (before state
|
|
103
|
+
// is read/written, before the phase loop begins). `leaseTtlMs` controls how
|
|
104
|
+
// long a claimed lease is valid for; `leaseRenewIntervalMs` controls how
|
|
105
|
+
// often the in-flight heartbeat (forge#2239) re-writes it. If the renew
|
|
106
|
+
// interval is >= the TTL, the first renewal tick fires at or after the
|
|
107
|
+
// moment the previously-written lease already expired — reopening the exact
|
|
108
|
+
// "phase outlives its lease" gap #2239 closed, because a genuinely-alive
|
|
109
|
+
// run would publish (or be caught holding) an expired `lease.until` in that
|
|
110
|
+
// window, which both the I3 concurrency guard above and the stall-recovery
|
|
111
|
+
// scanner (commands/orchestrate/phase-3-dependency.md) read directly. Both
|
|
112
|
+
// parameters are overridable-for-tests options with no current production
|
|
113
|
+
// caller forwarding them (bin/engine-cli.mjs's runFromCli() only forwards
|
|
114
|
+
// `backend`/`model`), so this only guards a future caller/config typo —
|
|
115
|
+
// reject rather than silently clamp, matching INVALID_BACKEND's precedent.
|
|
116
|
+
//
|
|
117
|
+
// forge#2329: guard for finite numbers BEFORE the relational check below.
|
|
118
|
+
// Every relational comparison against NaN evaluates to false in JS, so a
|
|
119
|
+
// NaN in either parameter silently passes `leaseRenewIntervalMs >=
|
|
120
|
+
// leaseTtlMs` (both `NaN >= x` and `x >= NaN` are false) — reopening the
|
|
121
|
+
// exact gap this whole guard exists to close, just via a different bad
|
|
122
|
+
// input. Infinity/-Infinity are "numbers" per `typeof` but are equally
|
|
123
|
+
// nonsensical as a lease duration/interval and are not reliably caught by
|
|
124
|
+
// the relational check either. Non-number types (strings, null, objects)
|
|
125
|
+
// are coerced by `>=` instead of rejected, which can silently poison
|
|
126
|
+
// downstream arithmetic (`now() + leaseTtlMs`) with string concatenation
|
|
127
|
+
// instead of numeric addition. Same placement precedent as the relational
|
|
128
|
+
// check itself: synchronous, before any state I/O or timer creation, so it
|
|
129
|
+
// cannot interact with the async lease-renewal heartbeat or the
|
|
130
|
+
// crash-and-relaunch write-then-throw architecture (bin/tests/engine-crash.test.mjs).
|
|
131
|
+
for (const [name, value] of [["leaseTtlMs", leaseTtlMs], ["leaseRenewIntervalMs", leaseRenewIntervalMs]]) {
|
|
132
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
133
|
+
throw Object.assign(
|
|
134
|
+
new Error(
|
|
135
|
+
`Invalid lease config: ${name} must be a finite number, got ${typeof value === "number" ? value : `${typeof value} (${JSON.stringify(value)})`}.`,
|
|
136
|
+
),
|
|
137
|
+
{ code: "INVALID_LEASE_CONFIG" },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (leaseRenewIntervalMs >= leaseTtlMs) {
|
|
142
|
+
throw Object.assign(
|
|
143
|
+
new Error(
|
|
144
|
+
`Invalid lease config: leaseRenewIntervalMs (${leaseRenewIntervalMs}) must be less than leaseTtlMs (${leaseTtlMs}), or a live run's lease can expire before the next renewal.`,
|
|
145
|
+
),
|
|
146
|
+
{ code: "INVALID_LEASE_CONFIG" },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
56
150
|
const projector = makeProjector(io);
|
|
57
151
|
|
|
58
152
|
// 1. Load + reconcile (GitHub wins).
|
|
@@ -72,9 +166,6 @@ export async function runIssue(opts) {
|
|
|
72
166
|
if (!state) {
|
|
73
167
|
state = freshState(issue, lane);
|
|
74
168
|
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
169
|
} else if (action === "hydrate") {
|
|
79
170
|
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
80
171
|
// local run-log to match the remote compact index so downstream
|
|
@@ -83,8 +174,20 @@ export async function runIssue(opts) {
|
|
|
83
174
|
// and regress committed/issue/v (C2).
|
|
84
175
|
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
85
176
|
state = deriveState(readLog(dir, issue));
|
|
86
|
-
await projector.writeState(issue, state);
|
|
87
177
|
}
|
|
178
|
+
// forge#2239: claim the lease unconditionally here — before the phase loop
|
|
179
|
+
// starts, for EVERY reconcile action ("fresh", "remirror", "hydrate", and
|
|
180
|
+
// "local"). Previously only "fresh"/"remirror"/"hydrate" wrote any state at
|
|
181
|
+
// all at this point, and none of them claimed a real (non-null) lease — the
|
|
182
|
+
// lease was only ever written retroactively after a phase's PHASE_COMMIT
|
|
183
|
+
// (see the write inside the loop below), leaving the entire first phase
|
|
184
|
+
// (and, for the "local" action, every phase before the first commit)
|
|
185
|
+
// publishing lease:null. That is indistinguishable from a dead run to both
|
|
186
|
+
// the I3 concurrency guard above and the stall-recovery scan in
|
|
187
|
+
// commands/orchestrate/phase-3-dependency.md. This write MUST stay after the I3 guard
|
|
188
|
+
// check above (commit 541a3e5 fixed the reverse ordering as a real bug —
|
|
189
|
+
// do not reintroduce it).
|
|
190
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
88
191
|
|
|
89
192
|
// 2. Drive phases until terminal.
|
|
90
193
|
//
|
|
@@ -98,6 +201,66 @@ export async function runIssue(opts) {
|
|
|
98
201
|
// the real branch from the `FORGE:BUILDER` comment instead.
|
|
99
202
|
let phase;
|
|
100
203
|
while ((phase = pickPhase(state))) {
|
|
204
|
+
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
205
|
+
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
206
|
+
// progress) — never the issue's live GitHub state/labels — so a phase
|
|
207
|
+
// could run to completion against an issue that was independently closed
|
|
208
|
+
// (e.g. by a human, or by the investigate phase's own marker path) or
|
|
209
|
+
// labeled `workflow:invalid`/`needs-human` after this run's local state
|
|
210
|
+
// was last derived. `close` is deliberately exempt: it is the one phase
|
|
211
|
+
// whose entire job IS to read and act on this exact state (see its
|
|
212
|
+
// reconcile/detectOutcome in bin/engine/phases.mjs), so guarding it here
|
|
213
|
+
// would be redundant and could race with its own read of the same data.
|
|
214
|
+
//
|
|
215
|
+
// Cost: one extra `gh issue view` per loop iteration (skipped only for
|
|
216
|
+
// `close`, which already pays this cost itself) — bounded by the phase
|
|
217
|
+
// count (currently 6), not the retry budget inside a single phase.
|
|
218
|
+
//
|
|
219
|
+
// Fail-open on a snapshot error (`!snap.ok`): a transient `gh`/network
|
|
220
|
+
// failure here must not block a healthy run any more than the identical
|
|
221
|
+
// fail-open behavior in `close`'s own `reconcile` (bin/engine/phases.mjs)
|
|
222
|
+
// or the `reconcile` try/catch just below in this same loop.
|
|
223
|
+
if (phase.id !== "close") {
|
|
224
|
+
let snap;
|
|
225
|
+
try {
|
|
226
|
+
snap = await issueSnapshot(issue, io);
|
|
227
|
+
} catch {
|
|
228
|
+
snap = { ok: false, state: null, labels: [] };
|
|
229
|
+
}
|
|
230
|
+
if (snap.ok) {
|
|
231
|
+
// `workflow:invalid`, or CLOSED without `workflow:merged`, means the
|
|
232
|
+
// issue is dead — nothing a further phase does can be consequential.
|
|
233
|
+
// Reuses the existing "invalid" terminal reason (TERMINAL_REASONS)
|
|
234
|
+
// rather than inventing a new one; #2353 makes the same choice for
|
|
235
|
+
// `close`'s own CLOSED-not-merged case.
|
|
236
|
+
const isDead = snap.labels.includes("workflow:invalid") ||
|
|
237
|
+
(snap.state === "CLOSED" && !snap.labels.includes("workflow:merged"));
|
|
238
|
+
if (isDead) {
|
|
239
|
+
const detail = `issue ${issue} is ${snap.state === "CLOSED" ? "closed" : "open"} ` +
|
|
240
|
+
`with divergent state before phase ${phase.id} (labels: ${snap.labels.join(", ") || "none"})`;
|
|
241
|
+
return await terminate(state, "invalid", detail);
|
|
242
|
+
}
|
|
243
|
+
// `needs-human` is a PAUSE, not a death sentence — a human may still
|
|
244
|
+
// resolve it, and /orchestrate's classify_predecessor_state() already
|
|
245
|
+
// treats this label as GATED (not FAILED), so terminating here with
|
|
246
|
+
// the same reason composes with that existing contract instead of
|
|
247
|
+
// conflating "paused pending a human" with "dead". Deliberately does
|
|
248
|
+
// NOT check `workflow:invalid`/CLOSED here — those are handled above.
|
|
249
|
+
if (snap.labels.includes("needs-human")) {
|
|
250
|
+
const detail = `issue ${issue} carries needs-human — pausing before phase ${phase.id}`;
|
|
251
|
+
return await terminate(state, "needs-human", detail);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// forge#2321: tracks whether `phase_enter` was actually emitted for this
|
|
256
|
+
// loop iteration. Re-declared `false` on every iteration (never hoisted
|
|
257
|
+
// above the loop) so a reconcile-satisfied phase can never inherit a
|
|
258
|
+
// stale `true` from a previous iteration's actually-run phase. Only the
|
|
259
|
+
// committed-exit emission below needs this guard — the blocked-exit
|
|
260
|
+
// emission is unreachable from the `reconciled.satisfied` branch, since
|
|
261
|
+
// that branch always yields `status: "committed"` (see below), never
|
|
262
|
+
// "blocked".
|
|
263
|
+
let phaseEntered = false;
|
|
101
264
|
let reconciled;
|
|
102
265
|
try {
|
|
103
266
|
reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
|
|
@@ -111,18 +274,164 @@ export async function runIssue(opts) {
|
|
|
111
274
|
outcome = { status: "committed", outputs: reconciled.outputs || {} };
|
|
112
275
|
} else {
|
|
113
276
|
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
114
|
-
|
|
277
|
+
// forge#2240: the phase is actually about to run (not a resume no-op)
|
|
278
|
+
// — this is the one point in the loop where "entering phase X" is true.
|
|
279
|
+
phaseEntered = true;
|
|
280
|
+
emitProgress({ event: "phase_enter", phase: phase.id });
|
|
281
|
+
// forge#2239: renew the lease immediately before running this phase's
|
|
282
|
+
// runner, then keep renewing it on a heartbeat for as long as the
|
|
283
|
+
// runner is in flight. A single phase can legitimately run longer than
|
|
284
|
+
// leaseTtlMs — without renewal, a healthy, still-running phase would
|
|
285
|
+
// eventually publish an *expired* (not just null) lease and get reaped
|
|
286
|
+
// by stall recovery even though the run is fine. The interval is
|
|
287
|
+
// `.unref()`'d so it can never keep the process alive on its own, and
|
|
288
|
+
// is always cleared in `finally` so it cannot outlive this phase's
|
|
289
|
+
// execution — covers the committed path, the blocked path, and the
|
|
290
|
+
// engine-error fail-fast throw caught just below.
|
|
291
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
292
|
+
// forge#2239 (review finding): `projector.writeState` is a plain
|
|
293
|
+
// read-body/edit-body round trip with no CAS — whichever `gh issue
|
|
294
|
+
// edit` call actually lands last on GitHub wins, regardless of dispatch
|
|
295
|
+
// order. A fire-and-forget renewal write left in flight when this
|
|
296
|
+
// phase finishes could land AFTER the post-commit write or even after
|
|
297
|
+
// terminate()'s `lease: null` write, resurrecting a phantom lease on an
|
|
298
|
+
// already-terminated run. Track the most recently dispatched renewal's
|
|
299
|
+
// promise and await it in `finally` (below) before letting the loop
|
|
300
|
+
// proceed — this guarantees no renewal write is still in flight when
|
|
301
|
+
// control moves on to the next write (commit or terminate).
|
|
302
|
+
// forge#2348 (review finding): the above join-tracking scheme only
|
|
303
|
+
// ever holds ONE promise in `pendingRenewal` — it protects against a
|
|
304
|
+
// single in-flight write landing late, but has no backpressure against
|
|
305
|
+
// a SECOND renewal tick firing while the first write is still in
|
|
306
|
+
// flight (write round trip > leaseRenewIntervalMs). Without a guard,
|
|
307
|
+
// `pendingRenewal` would simply be overwritten with the newer write's
|
|
308
|
+
// promise, silently orphaning the earlier one from every join point
|
|
309
|
+
// (both this closure's own `finally` below and the #2338 catch-path
|
|
310
|
+
// join) — the earlier write could then land on GitHub after a later
|
|
311
|
+
// write, or after the terminate()/commit write joins only the latest
|
|
312
|
+
// promise and proceeds. Guarding here so at most one renewal write is
|
|
313
|
+
// ever in flight keeps the existing single-variable join points
|
|
314
|
+
// correct and sufficient: skip this tick entirely if a previous
|
|
315
|
+
// renewal write hasn't settled yet, and clear `pendingRenewal` back to
|
|
316
|
+
// null once it does (success or failure) so the guard never
|
|
317
|
+
// permanently wedges future renewals.
|
|
318
|
+
let pendingRenewal = null;
|
|
319
|
+
const renewLease = () => {
|
|
320
|
+
if (pendingRenewal) return;
|
|
321
|
+
pendingRenewal = projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } }).catch(() => {
|
|
322
|
+
// Best-effort: a transient renewal failure must not crash the run.
|
|
323
|
+
// The next scheduled renewal (or the post-commit write) will retry.
|
|
324
|
+
}).finally(() => {
|
|
325
|
+
pendingRenewal = null;
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
const renewTimer = setInterval(renewLease, leaseRenewIntervalMs);
|
|
329
|
+
if (typeof renewTimer.unref === "function") renewTimer.unref();
|
|
330
|
+
try {
|
|
331
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
|
|
332
|
+
} catch (e) {
|
|
333
|
+
// forge#2261: NO_API_KEY/NO_SDK/CLI_BACKEND_FAILED are fail-fast
|
|
334
|
+
// rethrown by runPhaseWithRetry() (see its own catch below) instead
|
|
335
|
+
// of being retried — but until now that throw was never caught here,
|
|
336
|
+
// so it propagated all the way out of runIssue() uncaught (through
|
|
337
|
+
// bin/engine-cli.mjs's runFromCli(), which also has no try/catch)
|
|
338
|
+
// and only landed in bin/forgedock.mjs's outermost `run-issue` case,
|
|
339
|
+
// which just prints the message to stderr and exits 1 — no terminal
|
|
340
|
+
// state or label was ever written, leaving the issue stuck on
|
|
341
|
+
// whatever workflow label it already had. Catch it here instead and
|
|
342
|
+
// reach a clean terminal state: "engine-error" is deliberately NOT
|
|
343
|
+
// "needs-human" — this is the engine/tool breaking, not a genuine
|
|
344
|
+
// human-judgment block (see #2244/#2261). Any other thrown error is
|
|
345
|
+
// a true unexpected crash and keeps propagating unchanged.
|
|
346
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") {
|
|
347
|
+
// forge#2241: when the runner attached a session-limit reset time
|
|
348
|
+
// (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
|
|
349
|
+
// for a genuine session-limit CLI_BACKEND_FAILED — never
|
|
350
|
+
// fabricated), append it so the terminal state is legible without
|
|
351
|
+
// reading raw logs. Purely additive: the base detail string is
|
|
352
|
+
// unchanged, and this appends nothing when e.resetAt is absent.
|
|
353
|
+
const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
|
|
354
|
+
const detail = `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
|
|
355
|
+
// forge#2240 (review finding): this fail-fast path previously left
|
|
356
|
+
// phase_exit unreported — a caller tailing progress output would see
|
|
357
|
+
// "→ phase X started" and then nothing, dangling exactly on the
|
|
358
|
+
// phase that actually failed. Report it as blocked before
|
|
359
|
+
// terminating so the progress trail stays complete on this path too.
|
|
360
|
+
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail });
|
|
361
|
+
// forge#2338 (review finding): `return await terminate(...)` here
|
|
362
|
+
// sits INSIDE this catch block, so per try/catch/finally semantics
|
|
363
|
+
// its expression is fully evaluated — including terminate()'s own
|
|
364
|
+
// `lease: null` write completing — BEFORE the `finally` block below
|
|
365
|
+
// ever runs `clearInterval`/`await pendingRenewal`. A renewal write
|
|
366
|
+
// already dispatched before this throw can therefore land on
|
|
367
|
+
// GitHub AFTER terminate()'s `lease: null` write, resurrecting a
|
|
368
|
+
// phantom lease on an already-terminated run — reopening the exact
|
|
369
|
+
// race #2239 was written to close, just on this one call site the
|
|
370
|
+
// #2239 fix (07b3b8a) didn't cover. Explicitly join here, before
|
|
371
|
+
// calling terminate(), rather than relying on `finally` (too
|
|
372
|
+
// late). The `finally` block's identical calls remain below as a
|
|
373
|
+
// harmless idempotent safety net for the `throw e;` path.
|
|
374
|
+
clearInterval(renewTimer);
|
|
375
|
+
if (pendingRenewal) await pendingRenewal;
|
|
376
|
+
return await terminate(state, "engine-error", detail);
|
|
377
|
+
}
|
|
378
|
+
throw e;
|
|
379
|
+
} finally {
|
|
380
|
+
clearInterval(renewTimer);
|
|
381
|
+
// Join any renewal write already dispatched before this phase's
|
|
382
|
+
// outcome is allowed to reach a commit/terminate write — closes the
|
|
383
|
+
// TOCTOU window above. `pendingRenewal` already swallows its own
|
|
384
|
+
// rejection (see renewLease()), so this await never throws.
|
|
385
|
+
if (pendingRenewal) await pendingRenewal;
|
|
386
|
+
}
|
|
115
387
|
}
|
|
116
388
|
|
|
117
|
-
if (outcome.status === "blocked")
|
|
389
|
+
if (outcome.status === "blocked") {
|
|
390
|
+
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
391
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
392
|
+
}
|
|
118
393
|
|
|
119
394
|
// committed
|
|
120
|
-
|
|
395
|
+
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
396
|
+
// actually emitted for this phase this iteration. A reconcile-satisfied
|
|
397
|
+
// phase (short-circuited above without ever entering the `else` branch)
|
|
398
|
+
// never "started" this run — emitting phase_exit for it produced a
|
|
399
|
+
// dangling exit with no preceding enter (bin/engine-cli.mjs prints
|
|
400
|
+
// "✓ phase X committed" with no prior "→ phase X started" line).
|
|
401
|
+
// Suppressing the unmatched exit is correct here rather than fabricating
|
|
402
|
+
// a synthetic phase_enter, since the phase's runner genuinely never ran.
|
|
403
|
+
if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
|
|
404
|
+
// forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
|
|
405
|
+
// from the injected runner()'s (== bin/runner.mjs's runCommand()) return
|
|
406
|
+
// value — null when the backend doesn't report usage (CLI backend today)
|
|
407
|
+
// or when detectOutcome() never ran (this call site is only reached on
|
|
408
|
+
// the "committed" path, so that case doesn't apply here). Kept as a
|
|
409
|
+
// sibling of `outputs` rather than nested inside it, since `outputs` is
|
|
410
|
+
// owned by phase.detectOutcome() (bin/engine/phases.mjs).
|
|
411
|
+
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {}, usage: outcome.usage ?? null });
|
|
121
412
|
state = deriveState(readLog(dir, issue));
|
|
122
413
|
if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
|
|
123
|
-
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() +
|
|
414
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
124
415
|
|
|
125
|
-
|
|
416
|
+
// forge#2379: the "investigate" phase reporting terminalReason "decomposed"
|
|
417
|
+
// is a HANDOFF to the "decompose" phase (bin/engine/phases.mjs), not a
|
|
418
|
+
// dead end — decompose is what actually dispatches work-on/decompose
|
|
419
|
+
// (sub-issue fan-out, FORGE:DECOMPOSED posting). Before this change,
|
|
420
|
+
// "decomposed" being a member of TERMINAL_REASONS meant the run
|
|
421
|
+
// terminated the instant investigate reported it, and decompose's own
|
|
422
|
+
// entryCondition (state.terminalReason === "decomposed") was never
|
|
423
|
+
// actually reachable through pickPhase. Narrowly exempting exactly this
|
|
424
|
+
// phase/reason combination — and no other phase, no other reason — lets
|
|
425
|
+
// the loop continue to pickPhase, which now correctly selects "decompose"
|
|
426
|
+
// next (investigate.isTerminalAfter was narrowed to only "invalid" in
|
|
427
|
+
// the same change, so it no longer independently forces termination
|
|
428
|
+
// here either). Once the "decompose" phase itself later reports
|
|
429
|
+
// terminalReason "decomposed" (re-affirming it after FORGE:DECOMPOSED:COMPLETE
|
|
430
|
+
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
431
|
+
// exemption does not apply and the normal terminate() path below fires,
|
|
432
|
+
// ending the run for real.
|
|
433
|
+
const isDecomposeHandoff = phase.id === "investigate" && outcome.terminalReason === "decomposed";
|
|
434
|
+
if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason) && !isDecomposeHandoff)
|
|
126
435
|
return await terminate(state, outcome.terminalReason);
|
|
127
436
|
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
128
437
|
return await terminate(state, state.terminalReason || "merged");
|
|
@@ -134,16 +443,41 @@ export async function runIssue(opts) {
|
|
|
134
443
|
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
|
|
135
444
|
await projector.writeState(issue, final);
|
|
136
445
|
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
446
|
+
// forge#2261: a distinct label for engine/tool-level failures (broken CLI
|
|
447
|
+
// invocation, exhausted retries where the runner itself never once
|
|
448
|
+
// succeeded) — NOT needs-human. /orchestrate's classify_predecessor_state()
|
|
449
|
+
// (commands/orchestrate/phase-4-execution.md) must not treat this as GATED:
|
|
450
|
+
// there is no human decision pending, just a tool that needs to be re-run.
|
|
451
|
+
else if (reason === "engine-error") await projector.setLabel(issue, "workflow:engine-error");
|
|
137
452
|
return { terminalReason: reason, detail };
|
|
138
453
|
}
|
|
139
454
|
}
|
|
140
455
|
|
|
141
456
|
async function runPhaseWithRetry(phase, state, ctx) {
|
|
142
457
|
const { io, runner, dir, issue, commandsDir, maxAttempts, backend, model } = ctx;
|
|
458
|
+
// forge#2261: true only if EVERY attempt failed by the runner itself
|
|
459
|
+
// throwing (never once reached phase.detectOutcome()). This is the signal
|
|
460
|
+
// that distinguishes an engine/tool crash (the tool never even produced a
|
|
461
|
+
// result to evaluate) from a genuine content-level block (the tool ran
|
|
462
|
+
// fine, but the phase's own completion criteria weren't met — e.g. an
|
|
463
|
+
// unmerged PR, an unresolved branch, or a fixed-point zero-commits case).
|
|
464
|
+
// Flips to false the instant any attempt's runner() call succeeds, even if
|
|
465
|
+
// that attempt's detectOutcome() itself reports failure.
|
|
466
|
+
let allAttemptsThrew = true;
|
|
467
|
+
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
468
|
+
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
469
|
+
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
470
|
+
// backend, or null on the CLI backend / when the field is absent). Reset
|
|
471
|
+
// is unnecessary since a thrown attempt never reaches the assignment
|
|
472
|
+
// below — `lastUsage` simply stays whatever the previous successful
|
|
473
|
+
// attempt (if any) set it to, which is correct: it always reflects the
|
|
474
|
+
// most recent attempt that actually produced a result.
|
|
475
|
+
let lastUsage = null;
|
|
143
476
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
144
477
|
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
478
|
+
let result;
|
|
145
479
|
try {
|
|
146
|
-
await runner({
|
|
480
|
+
result = await runner({
|
|
147
481
|
commandsDir, commandName: phase.command, args: [String(issue)],
|
|
148
482
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
149
483
|
// runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
|
|
@@ -154,13 +488,34 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
154
488
|
// A missing API key / SDK is a config error, not a transient phase
|
|
155
489
|
// failure — surface it distinctly instead of burning retries and
|
|
156
490
|
// escalating to needs-human as if the LLM run itself misbehaved.
|
|
157
|
-
|
|
158
|
-
|
|
491
|
+
//
|
|
492
|
+
// forge#2259: a non-zero exit from the nested `claude` CLI
|
|
493
|
+
// (bin/runner.mjs's runCliBackend(), which sets err.code =
|
|
494
|
+
// "CLI_BACKEND_FAILED" on any non-zero `result.status`) is likewise
|
|
495
|
+
// not a transient phase failure — same argv/cwd/session state
|
|
496
|
+
// reproduces the identical crash on every attempt. Retrying it
|
|
497
|
+
// `maxAttempts` times burns the entire retry budget (observed live in
|
|
498
|
+
// #2244: 3 nested-CLI invocations, zero PRs/commits) on a failure that
|
|
499
|
+
// was never going to succeed. Fail fast instead, mirroring the
|
|
500
|
+
// NO_API_KEY/NO_SDK precedent (commit 570cb10 / #2054 established this
|
|
501
|
+
// exact dispatch pattern for INVALID_BACKEND). This does not assume
|
|
502
|
+
// *why* the CLI exited 1 (the quota/session-limit theory in #2244 is
|
|
503
|
+
// unproven) — a deterministic tool crash should not be retried as if
|
|
504
|
+
// transient regardless of its root cause.
|
|
505
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
506
|
+
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
507
|
+
// (and therefore no usage data) was ever produced for this attempt.
|
|
508
|
+
// Do not fabricate a value; omitting the field (rather than a stale
|
|
509
|
+
// `lastUsage` from a prior attempt) keeps this event's usage
|
|
510
|
+
// trustworthy as "usage actually observed on this attempt".
|
|
511
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts });
|
|
159
512
|
continue;
|
|
160
513
|
}
|
|
514
|
+
allAttemptsThrew = false;
|
|
515
|
+
lastUsage = result?.usage ?? null;
|
|
161
516
|
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 });
|
|
517
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
|
|
518
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
|
|
164
519
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
165
520
|
// state-derived fixed point — re-running the phase's runner is
|
|
166
521
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -172,11 +527,28 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
172
527
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
173
528
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
174
529
|
if (outcome.retryable === false) {
|
|
175
|
-
return { status: "blocked", detail: outcome.detail };
|
|
530
|
+
return { status: "blocked", detail: outcome.detail, usage: lastUsage };
|
|
176
531
|
}
|
|
177
532
|
}
|
|
178
533
|
// Exhausted transient retries → escalate (spec §7).
|
|
179
|
-
|
|
534
|
+
// forge#2261: if the runner itself threw on every single attempt (it never
|
|
535
|
+
// once produced a result for detectOutcome() to evaluate), this is an
|
|
536
|
+
// engine/tool failure, not a content-level judgment call — tag the outcome
|
|
537
|
+
// so runIssue()'s terminate() writes a distinct label instead of
|
|
538
|
+
// needs-human. If at least one attempt reached detectOutcome() (the tool
|
|
539
|
+
// ran, the phase just isn't done), this stays the existing untagged shape,
|
|
540
|
+
// which runIssue() defaults to "needs-human" — unchanged behavior for every
|
|
541
|
+
// genuine content-level block (unmerged PR, unresolved branch, a transient
|
|
542
|
+
// git error inside commitsAhead(), etc.).
|
|
543
|
+
if (allAttemptsThrew) {
|
|
544
|
+
return {
|
|
545
|
+
status: "blocked",
|
|
546
|
+
detail: `phase ${phase.id} failed after ${maxAttempts} attempts (runner threw every attempt)`,
|
|
547
|
+
reason: "engine-error",
|
|
548
|
+
usage: null,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts`, usage: lastUsage };
|
|
180
552
|
}
|
|
181
553
|
|
|
182
554
|
/**
|