forgedock 1.2.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.
Files changed (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
package/bin/engine.mjs CHANGED
@@ -5,12 +5,20 @@
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";
12
12
 
13
- const DEFAULT_MAX_ATTEMPTS = 3;
13
+ // Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
14
+ // diagnostics without duplicating the retry budget constant.
15
+ export const DEFAULT_MAX_ATTEMPTS = 3;
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;
14
22
 
15
23
  /**
16
24
  * @param {object} opts
@@ -21,6 +29,22 @@ const DEFAULT_MAX_ATTEMPTS = 3;
21
29
  * @param {string} [opts.model] - Model id (forge#2028). Forwarded to every phase's
22
30
  * `runner()` call when supplied; only applies on the "api" backend. Omit to keep
23
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.
24
48
  */
25
49
  export async function runIssue(opts) {
26
50
  const { issue, dir, agentId, lane = "staging", io, runner,
@@ -30,7 +54,30 @@ export async function runIssue(opts) {
30
54
  // call (forge#2028 / MAT-3). Left undefined by default so existing
31
55
  // callers keep runner.mjs's own "auto" ladder default unchanged —
32
56
  // this is purely additive pass-through, not a new default.
33
- backend, model } = opts;
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
+ };
34
81
 
35
82
  // forge#2054: validate `backend` before anything else — before state is
36
83
  // read/written and before the phase/retry loop begins below. An invalid
@@ -51,6 +98,55 @@ export async function runIssue(opts) {
51
98
  );
52
99
  }
53
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
+
54
150
  const projector = makeProjector(io);
55
151
 
56
152
  // 1. Load + reconcile (GitHub wins).
@@ -70,9 +166,6 @@ export async function runIssue(opts) {
70
166
  if (!state) {
71
167
  state = freshState(issue, lane);
72
168
  appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
73
- await projector.writeState(issue, state);
74
- } else if (action === "remirror") {
75
- await projector.writeState(issue, state);
76
169
  } else if (action === "hydrate") {
77
170
  // GitHub is ahead of (or the only source of) local state. Rebuild the
78
171
  // local run-log to match the remote compact index so downstream
@@ -81,15 +174,93 @@ export async function runIssue(opts) {
81
174
  // and regress committed/issue/v (C2).
82
175
  rewriteLog(dir, issue, eventsFromIndex(state));
83
176
  state = deriveState(readLog(dir, issue));
84
- await projector.writeState(issue, state);
85
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 } });
86
191
 
87
192
  // 2. Drive phases until terminal.
193
+ //
194
+ // Note: the build phase's branch is NOT precomputed here. The real branch
195
+ // name is slug-derived from the issue title by `commands/work-on/build.md`
196
+ // (Phase B1A) and cannot be guessed ahead of time — a prior guessed default
197
+ // (`fix/pipeline-{issue}`) was never communicated to the builder and never
198
+ // matched the branch it actually created, so `commitsAhead()` always ran
199
+ // against a nonexistent ref and silently evaluated to 0 (forge#2174). The
200
+ // build phase's `reconcile`/`detectOutcome` (bin/engine/phases.mjs) resolve
201
+ // the real branch from the `FORGE:BUILDER` comment instead.
88
202
  let phase;
89
203
  while ((phase = pickPhase(state))) {
90
- // Every issue's build works on a deterministic branch; set it before build runs.
91
- if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
92
-
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;
93
264
  let reconciled;
94
265
  try {
95
266
  reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
@@ -103,18 +274,164 @@ export async function runIssue(opts) {
103
274
  outcome = { status: "committed", outputs: reconciled.outputs || {} };
104
275
  } else {
105
276
  if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
106
- outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts, backend, model });
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
+ }
107
387
  }
108
388
 
109
- if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
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
+ }
110
393
 
111
394
  // committed
112
- appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {} });
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 });
113
412
  state = deriveState(readLog(dir, issue));
114
413
  if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
115
- await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + 600000 } });
414
+ await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
116
415
 
117
- if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason))
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)
118
435
  return await terminate(state, outcome.terminalReason);
119
436
  if (phase.isTerminalAfter && phase.isTerminalAfter(state))
120
437
  return await terminate(state, state.terminalReason || "merged");
@@ -126,16 +443,41 @@ export async function runIssue(opts) {
126
443
  const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
127
444
  await projector.writeState(issue, final);
128
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");
129
452
  return { terminalReason: reason, detail };
130
453
  }
131
454
  }
132
455
 
133
456
  async function runPhaseWithRetry(phase, state, ctx) {
134
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;
135
476
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
136
477
  appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
478
+ let result;
137
479
  try {
138
- await runner({
480
+ result = await runner({
139
481
  commandsDir, commandName: phase.command, args: [String(issue)],
140
482
  // Only forwarded when explicitly provided — omitting them preserves
141
483
  // runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
@@ -146,16 +488,67 @@ async function runPhaseWithRetry(phase, state, ctx) {
146
488
  // A missing API key / SDK is a config error, not a transient phase
147
489
  // failure — surface it distinctly instead of burning retries and
148
490
  // escalating to needs-human as if the LLM run itself misbehaved.
149
- if (e.code === "NO_API_KEY" || e.code === "NO_SDK") throw e;
150
- appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message });
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 });
151
512
  continue;
152
513
  }
514
+ allAttemptsThrew = false;
515
+ lastUsage = result?.usage ?? null;
153
516
  const outcome = await phase.detectOutcome(state, io);
154
- if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
155
- 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 });
519
+ // forge#2176: a phase's detectOutcome can mark a failure as a known,
520
+ // state-derived fixed point — re-running the phase's runner is
521
+ // guaranteed to reproduce the identical failure (e.g. the build phase's
522
+ // builder already completed and will no-op on any re-invocation, per
523
+ // commands/work-on/build.md's own `BUILD_RESULT: status: ALREADY_DONE`
524
+ // early-exit). Honor that signal by stopping immediately rather than
525
+ // burning the remaining attempt budget on a guaranteed-identical result.
526
+ // Absent/undefined `retryable` defaults to retryable (existing behavior
527
+ // for every phase that doesn't opt in — investigate/context/architect/
528
+ // review/close — is unchanged, preserving transient-failure retries).
529
+ if (outcome.retryable === false) {
530
+ return { status: "blocked", detail: outcome.detail, usage: lastUsage };
531
+ }
156
532
  }
157
533
  // Exhausted transient retries → escalate (spec §7).
158
- return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
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 };
159
552
  }
160
553
 
161
554
  /**