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.
Files changed (58) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. 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
- * @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).
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
- export async function runIssue(opts) {
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
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
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") return await terminate(state, "needs-human", outcome.detail);
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
- appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {} });
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() + 600000 } });
548
+ await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
124
549
 
125
- if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason))
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
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK") throw e;
158
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message });
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
- return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
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
  }