forgedock 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/phases-args.test.mjs +2 -1
- package/bin/engine/phases.mjs +349 -90
- package/bin/engine/runlog.mjs +25 -2
- package/bin/engine-cli.mjs +4 -1
- package/bin/engine.mjs +72 -19
- package/bin/hooks/interactive-engine.mjs +135 -209
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +10 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/orchestrate-preflight.mjs +1 -1
- package/bin/runner.mjs +258 -23
- package/bin/watch-utils.mjs +1 -0
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +8 -5
- 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 +80 -53
- 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/recover-orphans.md +5 -5
- 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 +3 -3
- package/commands/review-pr-agents/protocols.md +15 -6
- package/commands/review-pr-agents/scraper.md +1 -1
- package/commands/review-pr-agents/security.md +5 -5
- package/commands/review-pr-agents.md +12 -12
- package/commands/review-pr-staging.md +226 -8
- package/commands/review-pr.md +394 -53
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +106 -7
- package/commands/work-on/decompose.md +2 -2
- package/commands/work-on/investigate.md +14 -4
- package/commands/work-on/remediate.md +137 -25
- package/commands/work-on/review.md +25 -9
- package/commands/work-on.md +15 -15
- 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/label-state-machine.md +6 -1
- package/docs/spec/review-protocol.md +20 -5
- package/package.json +2 -1
- package/packages/protocol/src/phases.js +21 -0
- 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 +103 -13
- 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-domain-map.test.sh +87 -0
- package/scripts/review-pr-gate2.test.sh +84 -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/scripts/transition-label.sh +3 -1
package/bin/engine/runlog.mjs
CHANGED
|
@@ -57,7 +57,10 @@ export function deriveState(events) {
|
|
|
57
57
|
/** @type {import("./phases.mjs").RunState} */
|
|
58
58
|
const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
|
|
59
59
|
phase: null, branch: null, pr: null, terminal: false,
|
|
60
|
-
terminalReason: null, lease: null, lastRateLimit: null
|
|
60
|
+
terminalReason: null, lease: null, lastRateLimit: null,
|
|
61
|
+
remediationKind: null };
|
|
62
|
+
// forge#3528: handoff reason of the LAST PHASE_COMMIT only (replaced, not sticky).
|
|
63
|
+
let lastCommitReason = null;
|
|
61
64
|
for (const e of events) {
|
|
62
65
|
switch (e.event) {
|
|
63
66
|
case "RUN_START":
|
|
@@ -71,6 +74,11 @@ export function deriveState(events) {
|
|
|
71
74
|
// forge#3504: a blocked phase persists its reason so a resumed run still routes to
|
|
72
75
|
// remediate instead of defaulting to a "merged" close.
|
|
73
76
|
if (e.terminalReason) s.terminalReason = e.terminalReason;
|
|
77
|
+
lastCommitReason = e.terminalReason ?? null;
|
|
78
|
+
// forge#3530: the review's remediation kind must survive resume. A later review
|
|
79
|
+
// commit replaces it; a committed remediate consumes it, so it is never reused.
|
|
80
|
+
if (e.phase === "review") s.remediationKind = e.outputs?.remediation ?? null;
|
|
81
|
+
else if (e.phase === "remediate") s.remediationKind = null;
|
|
74
82
|
break;
|
|
75
83
|
case "RUN_TERMINAL":
|
|
76
84
|
s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
|
|
@@ -78,8 +86,23 @@ export function deriveState(events) {
|
|
|
78
86
|
// forge#3511: an explicit `run-issue --retry` reopens an engine-error
|
|
79
87
|
// terminal. Bumping `v` makes the local state newer than the stale
|
|
80
88
|
// remote terminal index so reconcile remirrors instead of re-terminating.
|
|
89
|
+
// forge#3528: RUN_TERMINAL overwrote the persisted handoff reason
|
|
90
|
+
// (needs-human / decomposed); restore the last commit's reason so the
|
|
91
|
+
// retry re-enters remediate/decompose instead of close/build.
|
|
81
92
|
case "RUN_REOPEN":
|
|
82
|
-
s.terminal = false; s.terminalReason =
|
|
93
|
+
s.terminal = false; s.terminalReason = lastCommitReason; s.v = e.seq;
|
|
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;
|
|
83
106
|
break;
|
|
84
107
|
// forge#2524: a session-limit pause is purely informational — it does
|
|
85
108
|
// NOT touch committed/terminal/terminalReason. The phase that hit the
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@ export const ACTIVE_WORKFLOW_LABELS = [
|
|
|
26
26
|
"workflow:ready-to-build",
|
|
27
27
|
"workflow:building",
|
|
28
28
|
"workflow:in-review",
|
|
29
|
+
"workflow:remediating",
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
const pexec = promisify(execFile);
|
|
@@ -339,7 +340,9 @@ export function lastLocalRun(dir) {
|
|
|
339
340
|
* or its built-in default).
|
|
340
341
|
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
341
342
|
* (e.g. a session-limit hit) and resume from its last committed
|
|
342
|
-
* 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.
|
|
343
346
|
*/
|
|
344
347
|
export async function runFromCli(argv, deps = {}) {
|
|
345
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
|
}
|
|
@@ -359,10 +382,13 @@ export async function runIssue(opts) {
|
|
|
359
382
|
// decompose) and before RUN_TERMINAL leaves its persisted reason on the commit event. Resume
|
|
360
383
|
// must honour that reason instead of picking the next phase (close would throw in buildArgs
|
|
361
384
|
// and end as engine-error; build would run on a decomposed parent). Review's `needs-human`
|
|
362
|
-
// is a handoff to remediate, so only these
|
|
385
|
+
// is a handoff to remediate, so only these committed phases trigger the guard.
|
|
363
386
|
if (state.terminalReason && TERMINAL_REASONS.includes(state.terminalReason) &&
|
|
364
387
|
((state.committed.includes("remediate") && !["merged", "decomposed", "invalid"].includes(state.terminalReason)) ||
|
|
365
|
-
(state.committed.includes("decompose") && state.terminalReason === "decomposed")
|
|
388
|
+
(state.committed.includes("decompose") && state.terminalReason === "decomposed") ||
|
|
389
|
+
// forge#3512: investigate persists `invalid` on its own commit. Only when investigate is
|
|
390
|
+
// the LAST committed phase: a later remediate/close commit carrying `invalid` is a handoff.
|
|
391
|
+
(state.committed[state.committed.length - 1] === "investigate" && state.terminalReason === "invalid")))
|
|
366
392
|
return await terminate(state, state.terminalReason);
|
|
367
393
|
// forge#2352: state-vs-GitHub divergence guard. Every phase's own
|
|
368
394
|
// `entryCondition` only ever checked `state.committed` (local run-log
|
|
@@ -562,7 +588,7 @@ export async function runIssue(opts) {
|
|
|
562
588
|
// "needs-human" — this is the engine/tool breaking, not a genuine
|
|
563
589
|
// human-judgment block (see #2244/#2261). Any other thrown error is
|
|
564
590
|
// a true unexpected crash and keeps propagating unchanged.
|
|
565
|
-
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") {
|
|
566
592
|
// forge#2241: when the runner attached a session-limit reset time
|
|
567
593
|
// (bin/runner.mjs's extractSessionLimitResetTime(), only ever set
|
|
568
594
|
// for a genuine session-limit CLI_BACKEND_FAILED — never
|
|
@@ -570,7 +596,9 @@ export async function runIssue(opts) {
|
|
|
570
596
|
// reading raw logs. Purely additive: the base detail string is
|
|
571
597
|
// unchanged, and this appends nothing when e.resetAt is absent.
|
|
572
598
|
const resetSuffix = e.resetAt ? ` (resets: ${e.resetAt})` : "";
|
|
573
|
-
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}`;
|
|
574
602
|
// forge#2240 (review finding): this fail-fast path previously left
|
|
575
603
|
// phase_exit unreported — a caller tailing progress output would see
|
|
576
604
|
// "→ phase X started" and then nothing, dangling exactly on the
|
|
@@ -610,8 +638,11 @@ export async function runIssue(opts) {
|
|
|
610
638
|
// without ever resolving a PR (e.g. retries exhausted before one existed)
|
|
611
639
|
// has nothing to remediate and stays a plain needs-human stop — it must not
|
|
612
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.
|
|
613
644
|
const isRemediationHandoff = outcome.status === "blocked" &&
|
|
614
|
-
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff
|
|
645
|
+
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human" && outcome.handoff === true &&
|
|
615
646
|
(outcome.outputs?.pr ?? state.pr) != null;
|
|
616
647
|
if (outcome.status === "blocked") {
|
|
617
648
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
@@ -631,7 +662,7 @@ export async function runIssue(opts) {
|
|
|
631
662
|
}
|
|
632
663
|
// forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
|
|
633
664
|
// from the injected runner()'s (== bin/runner.mjs's runCommand()) return
|
|
634
|
-
// 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)
|
|
635
666
|
// or when detectOutcome() never ran (this call site is only reached on
|
|
636
667
|
// the "committed" path, so that case doesn't apply here). Kept as a
|
|
637
668
|
// sibling of `outputs` rather than nested inside it, since `outputs` is
|
|
@@ -650,6 +681,8 @@ export async function runIssue(opts) {
|
|
|
650
681
|
const commitReason = blockedReason ?? outcome.terminalReason ?? null;
|
|
651
682
|
appendEvent(dir, issue, {
|
|
652
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,
|
|
653
686
|
...(commitReason ? { terminalReason: commitReason } : {}),
|
|
654
687
|
});
|
|
655
688
|
state = deriveState(readLog(dir, issue));
|
|
@@ -674,11 +707,16 @@ export async function runIssue(opts) {
|
|
|
674
707
|
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
675
708
|
// exemption does not apply and the normal terminate() path below fires,
|
|
676
709
|
// ending the run for real.
|
|
677
|
-
|
|
710
|
+
// build also hands off: the B5.5 size gate (NEEDS_DECOMPOSE) reports "decomposed".
|
|
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";
|
|
678
716
|
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
679
|
-
!isDecomposeHandoff && !isRemediationHandoff)
|
|
717
|
+
!isDecomposeHandoff && !isRemediationHandoff && !isCloseHandoff)
|
|
680
718
|
return await terminate(state, terminalReason, outcome.detail);
|
|
681
|
-
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
719
|
+
if (phase.isTerminalAfter && !isCloseHandoff && phase.isTerminalAfter(state))
|
|
682
720
|
return await terminate(state, state.terminalReason || "merged");
|
|
683
721
|
}
|
|
684
722
|
return await terminate(state, state.terminalReason || "merged");
|
|
@@ -726,7 +764,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
726
764
|
// forge#2377: the last successful attempt's `usage` (from runner()'s ==
|
|
727
765
|
// runCommand()'s resolved value — {input_tokens, output_tokens,
|
|
728
766
|
// cache_creation_input_tokens, cache_read_input_tokens} on the API
|
|
729
|
-
// backend
|
|
767
|
+
// backend or the CLI JSON envelope, null when the field is absent). Reset
|
|
730
768
|
// is unnecessary since a thrown attempt never reaches the assignment
|
|
731
769
|
// below — `lastUsage` simply stays whatever the previous successful
|
|
732
770
|
// attempt (if any) set it to, which is correct: it always reflects the
|
|
@@ -761,6 +799,16 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
761
799
|
// *why* the CLI exited 1 (the quota/session-limit theory in #2244 is
|
|
762
800
|
// unproven) — a deterministic tool crash should not be retried as if
|
|
763
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
|
+
}
|
|
764
812
|
if (e.code === "NO_API_KEY" || e.code === "NO_SDK" || e.code === "CLI_BACKEND_FAILED") throw e;
|
|
765
813
|
// forge#2377: no `usage` field here — the runner threw, so no result
|
|
766
814
|
// (and therefore no usage data) was ever produced for this attempt.
|
|
@@ -772,9 +820,12 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
772
820
|
}
|
|
773
821
|
allAttemptsThrew = false;
|
|
774
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;
|
|
775
826
|
const outcome = await phase.detectOutcome(state, io, result);
|
|
776
|
-
if (outcome.status === "committed" || outcome.status === "blocked") return { ...outcome, usage: lastUsage };
|
|
777
|
-
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 });
|
|
778
829
|
// forge#2176: a phase's detectOutcome can mark a failure as a known,
|
|
779
830
|
// state-derived fixed point — re-running the phase's runner is
|
|
780
831
|
// guaranteed to reproduce the identical failure (e.g. the build phase's
|
|
@@ -786,8 +837,9 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
786
837
|
// for every phase that doesn't opt in — investigate/context/architect/
|
|
787
838
|
// review/close — is unchanged, preserving transient-failure retries).
|
|
788
839
|
if (outcome.retryable === false) {
|
|
789
|
-
// forge#3521: keep outcome.outputs (e.g. the PR number)
|
|
790
|
-
//
|
|
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.
|
|
791
843
|
return { status: "blocked", detail: outcome.detail, outputs: outcome.outputs, handoff: outcome.handoff, usage: lastUsage };
|
|
792
844
|
}
|
|
793
845
|
}
|
|
@@ -822,6 +874,7 @@ function eventsFromIndex(idx) {
|
|
|
822
874
|
const outputs = {};
|
|
823
875
|
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
824
876
|
if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
|
|
877
|
+
if (phase === "review" && idx.remediationKind) outputs.remediation = idx.remediationKind;
|
|
825
878
|
events.push({ event: "PHASE_COMMIT", phase, outputs });
|
|
826
879
|
}
|
|
827
880
|
// forge#3506: a non-terminal reason is a handoff (e.g. review -> remediate). Replay it on
|