forgedock 1.12.1 → 1.13.1
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/engine/phases.mjs +54 -12
- package/bin/engine/runlog.mjs +12 -0
- package/bin/engine-cli.mjs +3 -1
- package/bin/engine.mjs +64 -16
- package/bin/hooks/interactive-engine.mjs +135 -212
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/runner.mjs +234 -21
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +7 -4
- package/commands/orchestrate/phase-1-resolve.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +14 -19
- package/commands/orchestrate/phase-4-execution.md +50 -38
- package/commands/orchestrate/phase-6-report.md +6 -0
- package/commands/orchestrate/safety.md +1 -1
- package/commands/pipeline-health/phase-2-metrics.md +39 -0
- package/commands/pipeline-health/phase-5-report.md +1 -0
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +2 -2
- package/commands/review-pr-agents/api.md +1 -1
- package/commands/review-pr-agents/auth.md +1 -1
- package/commands/review-pr-agents/infra.md +9 -3
- package/commands/review-pr-agents/protocols.md +19 -5
- package/commands/review-pr-agents/scraper.md +4 -1
- package/commands/review-pr-agents/security.md +17 -5
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +302 -8
- package/commands/review-pr.md +254 -34
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +95 -3
- package/commands/work-on/remediate.md +115 -13
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +29 -0
- package/docs/RELEASE-NOTES.md +45 -0
- package/docs/spec/forge-protocol-v1.md +22 -1
- package/docs/spec/review-protocol.md +24 -4
- package/package.json +2 -1
- package/scripts/check-blast-radius.sh +164 -0
- package/scripts/check-blast-radius.test.sh +183 -0
- package/scripts/check-contract-scope.sh +137 -0
- package/scripts/check-contract-scope.test.sh +83 -0
- package/scripts/check-spec-bash.sh +60 -3
- package/scripts/check-spec-bash.test.sh +56 -0
- package/scripts/check-spec-markers.sh +29 -0
- package/scripts/check-test-wiring.sh +57 -0
- package/scripts/check-test-wiring.test.sh +70 -0
- package/scripts/classify-finding.sh +72 -8
- package/scripts/classify-finding.test.sh +77 -8
- package/scripts/close-dossier-no-main-commit.test.sh +12 -12
- package/scripts/diff-size.test.sh +3 -2
- package/scripts/doctor-pipeline-state.sh +35 -0
- package/scripts/forge-annotation.sh +3 -0
- package/scripts/forge-root.test.sh +70 -1
- package/scripts/forge-run.sh +2 -2
- package/scripts/recovery-claim-live.test.sh +10 -7
- package/scripts/remediate-basesync.test.sh +103 -0
- package/scripts/review-delta.sh +206 -0
- package/scripts/review-delta.test.sh +213 -0
- package/scripts/review-pr-gate2.test.sh +97 -0
- package/scripts/review-pr-staging-scope.test.sh +99 -0
- package/scripts/run-shell-tests.sh +115 -0
- package/scripts/select-fix-targets.sh +17 -5
package/bin/engine/phases.mjs
CHANGED
|
@@ -577,7 +577,8 @@ export const PHASES = [
|
|
|
577
577
|
// (needs-human = back-compat for older runs). The PR label is back-compat only.
|
|
578
578
|
// The PR number from GitHub (openPrFor) always wins over parsed text.
|
|
579
579
|
const outputs = { pr: pr.number };
|
|
580
|
-
|
|
580
|
+
// Opt-in: only an explicit escalation signal sets handoff:true. A PR number alone never hands off.
|
|
581
|
+
const escalated = { status: "blocked", detail: "review escalated", outputs, handoff: true };
|
|
581
582
|
const rr = parseReviewResult(result?.text);
|
|
582
583
|
// forge#3530: carry the review's remediation kind (router Phase 4R parity) so the
|
|
583
584
|
// remediate phase can post the matching bound marker. Untrusted model output: only
|
|
@@ -671,13 +672,24 @@ export const PHASES = [
|
|
|
671
672
|
const match = matches.length ? matches[matches.length - 1] : null;
|
|
672
673
|
const reGateOutcome = match ? match[1] : null;
|
|
673
674
|
switch (reGateOutcome) {
|
|
674
|
-
case "AUTO-LANDED":
|
|
675
|
-
// remediate.md
|
|
676
|
-
//
|
|
677
|
-
// issue
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
675
|
+
case "AUTO-LANDED": {
|
|
676
|
+
// forge#3624: remediate.md M8 drives close itself on this outcome, but an
|
|
677
|
+
// engine phase session may skip or fail that delegated close and nothing
|
|
678
|
+
// verified it. Check the issue's real state: CLOSED + workflow:merged means
|
|
679
|
+
// the run is genuinely finished (terminal merged). Anything else, including
|
|
680
|
+
// an unreadable snapshot, hands off to `close` (idempotent via its reconcile).
|
|
681
|
+
// The reason stays "merged" either way so the commit replaces the review's
|
|
682
|
+
// sticky "needs-human" and close.buildArgs accepts --terminal-state merged.
|
|
683
|
+
let closed = false;
|
|
684
|
+
try {
|
|
685
|
+
const snap = await issueSnapshot(state.issue, io);
|
|
686
|
+
closed = snap.ok && snap.state === "CLOSED" &&
|
|
687
|
+
snap.labels.includes(PHASE_MARKERS.close.completionLabel);
|
|
688
|
+
} catch { closed = false; }
|
|
689
|
+
return closed
|
|
690
|
+
? { status: "committed", terminalReason: "merged", outputs: { reGateOutcome } }
|
|
691
|
+
: { status: "committed", terminalReason: "merged", outputs: { reGateOutcome, closeHandoff: true } };
|
|
692
|
+
}
|
|
681
693
|
case "HELD-AWAITING-MERGE":
|
|
682
694
|
return { status: "committed", terminalReason: "awaiting-merge", outputs: { reGateOutcome } };
|
|
683
695
|
case "RE-ESCALATED":
|
|
@@ -718,12 +730,15 @@ export const PHASES = [
|
|
|
718
730
|
},
|
|
719
731
|
entryCondition: (s) => s.committed.includes("review"),
|
|
720
732
|
async reconcile(state, io) {
|
|
721
|
-
// Idempotent resume: issue already
|
|
733
|
+
// Idempotent resume: issue already CLOSED → skip the LLM re-run. forge#3644: the
|
|
734
|
+
// workflow:merged label alone is NOT proof close ran — remediate M7 labels the issue
|
|
735
|
+
// merged right after `gh pr merge`, while a staging-base PR leaves it OPEN. Treating
|
|
736
|
+
// OPEN + workflow:merged as done made close.reconcile swallow remediate's closeHandoff
|
|
737
|
+
// (remediate's own check, in its AUTO-LANDED branch, requires CLOSED + the label, a
|
|
738
|
+
// strict subset of what satisfies this predicate on a CLOSED issue).
|
|
722
739
|
const snap = await issueSnapshot(state.issue, io);
|
|
723
740
|
if (!snap.ok) return { satisfied: false };
|
|
724
|
-
return
|
|
725
|
-
? { satisfied: true }
|
|
726
|
-
: { satisfied: false };
|
|
741
|
+
return snap.state === "CLOSED" ? { satisfied: true } : { satisfied: false };
|
|
727
742
|
},
|
|
728
743
|
async detectOutcome(state, io) {
|
|
729
744
|
const snap = await issueSnapshot(state.issue, io);
|
|
@@ -749,6 +764,33 @@ export const PHASES = [
|
|
|
749
764
|
},
|
|
750
765
|
];
|
|
751
766
|
|
|
767
|
+
/**
|
|
768
|
+
* forge#3570: confirm a phase's outcome from GitHub ground truth, through the
|
|
769
|
+
* same trusted (`isTrustedComment`) + anchored (header-led, full-line sentinel)
|
|
770
|
+
* marker model the headless engine uses. For callers that only know a phase id
|
|
771
|
+
* (the interactive SubagentStop hook) and must never trust transcript text.
|
|
772
|
+
*
|
|
773
|
+
* `context` / `architect` are not engine phases (forge#3499) but the interactive
|
|
774
|
+
* path still tracks them: committed iff a trusted, anchored `:COMPLETE` comment
|
|
775
|
+
* exists. Unknown phase ids fail. Fetch errors propagate (the caller fails open).
|
|
776
|
+
*
|
|
777
|
+
* @param {string} phaseId
|
|
778
|
+
* @param {object} state
|
|
779
|
+
* @param {object} io
|
|
780
|
+
* @returns {Promise<{status: string, detail?: string, terminalReason?: string, outputs?: object}>}
|
|
781
|
+
*/
|
|
782
|
+
export async function detectTrustedOutcome(phaseId, state, io) {
|
|
783
|
+
const phase = PHASES.find((p) => p.id === phaseId);
|
|
784
|
+
if (phase) return phase.detectOutcome(state, io);
|
|
785
|
+
if (phaseId === "context" || phaseId === "architect") {
|
|
786
|
+
const m = PHASE_MARKERS[phaseId];
|
|
787
|
+
const { comments } = await issueMarkers(state.issue, io);
|
|
788
|
+
if (hasMarker(comments, m.header, m.completionMarker)) return { status: "committed", outputs: {} };
|
|
789
|
+
return { status: "failed", detail: `no ${m.completionMarker} marker` };
|
|
790
|
+
}
|
|
791
|
+
return { status: "failed", detail: `unknown phase: ${phaseId}` };
|
|
792
|
+
}
|
|
793
|
+
|
|
752
794
|
/**
|
|
753
795
|
* Parse the LAST `REVIEW_RESULT:` block from a phase's final reply (forge#3521).
|
|
754
796
|
* Untrusted model output: only the fixed keys below are read, via anchored
|
package/bin/engine/runlog.mjs
CHANGED
|
@@ -92,6 +92,18 @@ export function deriveState(events) {
|
|
|
92
92
|
case "RUN_REOPEN":
|
|
93
93
|
s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
|
|
94
94
|
break;
|
|
95
|
+
// forge#3562: an explicit `run-issue --retry` on a `phase-complete` run
|
|
96
|
+
// (multi-phase issue: this phase merged, more remain) starts a NEW cycle.
|
|
97
|
+
// RUN_REOPEN keeps `committed`, so pickPhase would find nothing to run and
|
|
98
|
+
// re-terminate; a new cycle clears committed/branch/pr so it restarts at
|
|
99
|
+
// investigate with a fresh branch. `v` is bumped like RUN_REOPEN, and
|
|
100
|
+
// lastCommitReason is reset so a stale handoff reason cannot leak into
|
|
101
|
+
// the new cycle through a later RUN_REOPEN.
|
|
102
|
+
case "RUN_NEXT_CYCLE":
|
|
103
|
+
s.terminal = false; s.terminalReason = null; s.committed = [];
|
|
104
|
+
s.branch = null; s.pr = null; s.remediationKind = null;
|
|
105
|
+
lastCommitReason = null; s.v = e.seq;
|
|
106
|
+
break;
|
|
95
107
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
96
108
|
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
|
97
109
|
// pause is, by construction, not yet committed (the engine is about to
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -340,7 +340,9 @@ export function lastLocalRun(dir) {
|
|
|
340
340
|
* or its built-in default).
|
|
341
341
|
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
342
342
|
* (e.g. a session-limit hit) and resume from its last committed
|
|
343
|
-
* phase.
|
|
343
|
+
* phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
|
|
344
|
+
* more remain) starts its next cycle from investigate. Other
|
|
345
|
+
* terminal reasons are not reopened.
|
|
344
346
|
*/
|
|
345
347
|
export async function runFromCli(argv, deps = {}) {
|
|
346
348
|
const issue = parseInt(argv[0], 10);
|
package/bin/engine.mjs
CHANGED
|
@@ -225,8 +225,11 @@ function makeProgressEmitter(onProgress) {
|
|
|
225
225
|
* `maxSessionLimitPauses`.
|
|
226
226
|
* @param {boolean} [opts.retry] - forge#3511: explicit opt-in. When the run's
|
|
227
227
|
* terminal reason is `engine-error`, reopen it (RUN_REOPEN) and resume from
|
|
228
|
-
* the last committed phase.
|
|
229
|
-
*
|
|
228
|
+
* the last committed phase. forge#3562: when it is `phase-complete` (a
|
|
229
|
+
* multi-phase issue whose phase merged with more remaining), start a new
|
|
230
|
+
* cycle (RUN_NEXT_CYCLE) that resets committed/branch/pr and restarts at
|
|
231
|
+
* investigate. Any other terminal reason is left untouched and returns
|
|
232
|
+
* `not-retryable`.
|
|
230
233
|
*/
|
|
231
234
|
export async function runIssue(opts) {
|
|
232
235
|
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
@@ -297,15 +300,35 @@ export async function runIssue(opts) {
|
|
|
297
300
|
// lease write below, which then publishes `terminal:false` at the reopen's
|
|
298
301
|
// higher `v`. Only `engine-error` is ever reopened; needs-human / merged /
|
|
299
302
|
// decomposed / etc. terminals are left untouched.
|
|
303
|
+
// forge#3562: `phase-complete` is also retryable, but as a NEW cycle
|
|
304
|
+
// (RUN_NEXT_CYCLE clears committed) rather than a reopen of the same one.
|
|
300
305
|
let reopened = false;
|
|
301
306
|
if (retry && state?.terminal) {
|
|
302
|
-
if (state.terminalReason !== "engine-error") {
|
|
307
|
+
if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete") {
|
|
303
308
|
return {
|
|
304
309
|
terminalReason: "not-retryable",
|
|
305
|
-
detail: `issue ${issue} ended ${state.terminalReason}; --retry only
|
|
310
|
+
detail: `issue ${issue} ended ${state.terminalReason}; --retry only resumes an engine-error or phase-complete run`,
|
|
306
311
|
};
|
|
307
312
|
}
|
|
308
|
-
|
|
313
|
+
// forge#3562 (SEC-3): a new cycle discards `committed`, so check the live
|
|
314
|
+
// issue first. A CLOSED issue has nothing left to run; an unreadable
|
|
315
|
+
// snapshot fails closed rather than resetting a possibly-finished run.
|
|
316
|
+
if (state.terminalReason === "phase-complete") {
|
|
317
|
+
let snap;
|
|
318
|
+
try { snap = await issueSnapshot(issue, io); } catch { snap = { ok: false, state: null }; }
|
|
319
|
+
if (!snap.ok || snap.state === "CLOSED") {
|
|
320
|
+
return {
|
|
321
|
+
terminalReason: "not-retryable",
|
|
322
|
+
detail: snap.ok
|
|
323
|
+
? `issue ${issue} is CLOSED; --retry does not start a new cycle on a closed issue`
|
|
324
|
+
: `issue ${issue} state could not be read; --retry did not start a new cycle`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
appendEvent(dir, issue, {
|
|
329
|
+
event: state.terminalReason === "phase-complete" ? "RUN_NEXT_CYCLE" : "RUN_REOPEN",
|
|
330
|
+
issue,
|
|
331
|
+
});
|
|
309
332
|
state = deriveState(readLog(dir, issue));
|
|
310
333
|
reopened = true;
|
|
311
334
|
}
|
|
@@ -565,7 +588,7 @@ export async function runIssue(opts) {
|
|
|
565
588
|
// "needs-human" — this is the engine/tool breaking, not a genuine
|
|
566
589
|
// human-judgment block (see #2244/#2261). Any other thrown error is
|
|
567
590
|
// a true unexpected crash and keeps propagating unchanged.
|
|
568
|
-
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") {
|
|
591
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED" || e.code === "CLI_MAX_TURNS") {
|
|
569
592
|
// forge#2241: when the runner attached a session-limit reset time
|
|
570
593
|
// (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
|
|
571
594
|
// for a genuine session-limit CLI_BACKEND_FAILED — never
|
|
@@ -573,7 +596,9 @@ export async function runIssue(opts) {
|
|
|
573
596
|
// reading raw logs. Purely additive: the base detail string is
|
|
574
597
|
// unchanged, and this appends nothing when e.resetAt is absent.
|
|
575
598
|
const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
|
|
576
|
-
const detail =
|
|
599
|
+
const detail = e.code === "CLI_MAX_TURNS"
|
|
600
|
+
? `phase ${phase.id}: CLI_MAX_TURNS - max-turns exhausted${e.numTurns != null ? ` after ${e.numTurns} turns` : ""}: ${e.message}`
|
|
601
|
+
: `phase ${phase.id}: ${e.code} - ${e.message}${resetSuffix}`;
|
|
577
602
|
// forge#2240 (review finding): this fail-fast path previously left
|
|
578
603
|
// phase_exit unreported — a caller tailing progress output would see
|
|
579
604
|
// "→ phase X started" and then nothing, dangling exactly on the
|
|
@@ -613,8 +638,11 @@ export async function runIssue(opts) {
|
|
|
613
638
|
// without ever resolving a PR (e.g. retries exhausted before one existed)
|
|
614
639
|
// has nothing to remediate and stays a plain needs-human stop — it must not
|
|
615
640
|
// fall through to `close`, which would otherwise be the next eligible phase.
|
|
641
|
+
// The handoff is opt-in: only an explicit escalation signal (outcome.handoff
|
|
642
|
+
// === true, set by review detectOutcome) permits it. The PR number merely
|
|
643
|
+
// supplies the remediate argument and is never itself a permission.
|
|
616
644
|
const isRemediationHandoff = outcome.status === "blocked" &&
|
|
617
|
-
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff
|
|
645
|
+
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff === true &&
|
|
618
646
|
(outcome.outputs?.pr ?? state.pr) != null;
|
|
619
647
|
if (outcome.status === "blocked") {
|
|
620
648
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
@@ -634,7 +662,7 @@ export async function runIssue(opts) {
|
|
|
634
662
|
}
|
|
635
663
|
// forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
|
|
636
664
|
// from the injected runner()'s (== bin/runner.mjs's runCommand()) return
|
|
637
|
-
// value — null when the backend doesn't report usage (CLI backend
|
|
665
|
+
// value — null when the backend doesn't report usage (the CLI backend parses it from its JSON envelope)
|
|
638
666
|
// or when detectOutcome() never ran (this call site is only reached on
|
|
639
667
|
// the "committed" path, so that case doesn't apply here). Kept as a
|
|
640
668
|
// sibling of `outputs` rather than nested inside it, since `outputs` is
|
|
@@ -653,6 +681,8 @@ export async function runIssue(opts) {
|
|
|
653
681
|
const commitReason = blockedReason ?? outcome.terminalReason ?? null;
|
|
654
682
|
appendEvent(dir, issue, {
|
|
655
683
|
event: "PHASE_COMMIT", phase: phase.id, outputs: commitOutputs, usage: outcome.usage ?? null,
|
|
684
|
+
// forge#3589: model + turn count per phase (null when the backend does not report them).
|
|
685
|
+
model: outcome.model ?? null, turns: outcome.turns ?? null,
|
|
656
686
|
...(commitReason ? { terminalReason: commitReason } : {}),
|
|
657
687
|
});
|
|
658
688
|
state = deriveState(readLog(dir, issue));
|
|
@@ -679,10 +709,14 @@ export async function runIssue(opts) {
|
|
|
679
709
|
// ending the run for real.
|
|
680
710
|
// build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
|
|
681
711
|
const isDecomposeHandoff = (phase.id === "investigate" || phase.id === "build") && terminalReason === "decomposed";
|
|
712
|
+
// forge#3624: remediate AUTO-LANDED whose issue is not yet CLOSED+workflow:merged hands off
|
|
713
|
+
// to `close` instead of ending the run (close is idempotent). Exempts BOTH terminate paths.
|
|
714
|
+
const isCloseHandoff = phase.id === "remediate" && outcome.status === "committed" &&
|
|
715
|
+
outcome.outputs?.closeHandoff === true && terminalReason === "merged";
|
|
682
716
|
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
683
|
-
!isDecomposeHandoff && !isRemediationHandoff)
|
|
717
|
+
!isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
|
|
684
718
|
return await terminate(state, terminalReason, outcome.detail);
|
|
685
|
-
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
719
|
+
if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
|
|
686
720
|
return await terminate(state, state.terminalReason || "merged");
|
|
687
721
|
}
|
|
688
722
|
return await terminate(state, state.terminalReason || "merged");
|
|
@@ -730,7 +764,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
730
764
|
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
731
765
|
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
732
766
|
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
733
|
-
// backend
|
|
767
|
+
// backend or the CLI JSON envelope, null when the field is absent). Reset
|
|
734
768
|
// is unnecessary since a thrown attempt never reaches the assignment
|
|
735
769
|
// below — `lastUsage` simply stays whatever the previous successful
|
|
736
770
|
// attempt (if any) set it to, which is correct: it always reflects the
|
|
@@ -765,6 +799,16 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
765
799
|
// *why* the CLI exited 1 (the quota/session-limit theory in #2244 is
|
|
766
800
|
// unproven) — a deterministic tool crash should not be retried as if
|
|
767
801
|
// transient regardless of its root cause.
|
|
802
|
+
// forge#3589: CLI_MAX_TURNS (turn bound exhausted) is likewise deterministic and is
|
|
803
|
+
// NOT a commit: record the exhausted attempt, then rethrow so runIssue terminates
|
|
804
|
+
// `engine-error` with no PHASE_COMMIT — `--retry` then re-enters this same phase.
|
|
805
|
+
if (e.code === "CLI_MAX_TURNS") {
|
|
806
|
+
appendEvent(dir, issue, {
|
|
807
|
+
event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message, maxAttempts,
|
|
808
|
+
turns: e.numTurns ?? null, usage: e.usage ?? null,
|
|
809
|
+
});
|
|
810
|
+
throw e;
|
|
811
|
+
}
|
|
768
812
|
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
769
813
|
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
770
814
|
// (and therefore no usage data) was ever produced for this attempt.
|
|
@@ -776,9 +820,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
776
820
|
}
|
|
777
821
|
allAttemptsThrew = false;
|
|
778
822
|
lastUsage = result?.usage ?? null;
|
|
823
|
+
// forge#3589: model/turns recorded alongside usage (additive; null when absent).
|
|
824
|
+
const lastModel = result?.cliModel ?? result?.model ?? null;
|
|
825
|
+
const lastTurns = result?.turns ?? null;
|
|
779
826
|
const outcome = await phase.detectOutcome(state, io, result);
|
|
780
|
-
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
|
|
781
|
-
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage });
|
|
827
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage, model: lastModel, turns: lastTurns };
|
|
828
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail, maxAttempts, usage: lastUsage, model: lastModel, turns: lastTurns });
|
|
782
829
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
783
830
|
// state-derived fixed point — re-running the phase's runner is
|
|
784
831
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -790,8 +837,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
790
837
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
791
838
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
792
839
|
if (outcome.retryable === false) {
|
|
793
|
-
// forge#3521: keep outcome.outputs (e.g. the PR number)
|
|
794
|
-
//
|
|
840
|
+
// forge#3521: keep outcome.outputs (e.g. the PR number) for persistence and
|
|
841
|
+
// forward `handoff` so only an explicit opt-in escalation is honoured; a
|
|
842
|
+
// no-signal failure carries no handoff and stops at needs-human.
|
|
795
843
|
return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
|
|
796
844
|
}
|
|
797
845
|
}
|