@dieulc/workflow 0.9.2 → 0.11.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/review.ts CHANGED
@@ -64,6 +64,15 @@ export const REVIEW_SUBMITTED_ENTRY_TYPE = "workflow-review-submitted";
64
64
  // ── Step 2: Review Mode configuration ────────────────────────────────
65
65
 
66
66
  export type ReviewFallbackOnError = "skip" | "block";
67
+ export type ReviewProviderRecovery = "fallback" | "off";
68
+ export type ReviewChunkedSubmit = "auto" | "off";
69
+
70
+ /** A reviewer model reference used by the recovery chain or an explicit fallback. */
71
+ export interface ReviewFallbackModel {
72
+ provider: string;
73
+ id: string;
74
+ thinking: string;
75
+ }
67
76
 
68
77
  export interface ReviewModeConfig {
69
78
  /** Master toggle. When false every Review Mode code path is a no-op. */
@@ -76,6 +85,22 @@ export interface ReviewModeConfig {
76
85
  verify: boolean;
77
86
  /** What to do if the reviewer model is unavailable or the review fails. */
78
87
  fallbackOnError: ReviewFallbackOnError;
88
+ /**
89
+ * Provider recovery: after a recoverable stream failure, try the fallback
90
+ * chain instead of failing the round immediately. `off` restores the old
91
+ * single-model behaviour.
92
+ */
93
+ providerRecovery: ReviewProviderRecovery;
94
+ /** SDK retries per reviewer model before the chain advances (0-2). */
95
+ providerRetries: number;
96
+ /** Explicit first fallback; the runtime still dedupes and clamps it. */
97
+ fallbackModel?: ReviewFallbackModel;
98
+ /** Chunked rewrite submission for long plans (default automatic by size). */
99
+ chunkedSubmit: ReviewChunkedSubmit;
100
+ /** Max characters per `review_plan_chunk`; also sets the auto threshold. */
101
+ chunkChars: number;
102
+ /** Overall recovery budget; 0 uses the round deadline. */
103
+ recoveryBudgetMs: number;
79
104
  /** Max `review_explore` subagents per review round (cost ceiling). */
80
105
  exploreBudget: number;
81
106
  /**
@@ -103,6 +128,11 @@ export const DEFAULT_REVIEW_MODE_CONFIG: Readonly<ReviewModeConfig> = {
103
128
  passes: 2,
104
129
  verify: true,
105
130
  fallbackOnError: "skip",
131
+ providerRecovery: "fallback",
132
+ providerRetries: 1,
133
+ chunkedSubmit: "auto",
134
+ chunkChars: 6_000,
135
+ recoveryBudgetMs: 0,
106
136
  exploreBudget: 1,
107
137
  timeoutMs: 300_000,
108
138
  contextChars: 15_000,
@@ -125,6 +155,83 @@ const CONTEXT_ENTRIES_MAX = 120;
125
155
  const CONTEXT_TOOL_CHARS_MIN = 200;
126
156
  const CONTEXT_TOOL_CHARS_MAX = 4_000;
127
157
 
158
+ /**
159
+ * Build the automatic fallback list: the parent session's model first (it just
160
+ * planned successfully), then the base reviewer and planner roles from
161
+ * roles.json (ignoring the active profile). The caller supplies the clamp so
162
+ * this stays independent of Pi's model registry.
163
+ */
164
+ export function buildReviewerFallbacks(input: {
165
+ parentModel?: ReviewFallbackModel;
166
+ baseReviewer?: ReviewFallbackModel;
167
+ basePlanner?: ReviewFallbackModel;
168
+ clampThinking?: (model: ReviewFallbackModel) => string;
169
+ }): ReviewFallbackModel[] {
170
+ const out: ReviewFallbackModel[] = [];
171
+ const seen = new Set<string>();
172
+ const push = (candidate: ReviewFallbackModel | undefined): void => {
173
+ if (!candidate) return;
174
+ const provider = candidate.provider.trim();
175
+ const id = candidate.id.trim();
176
+ if (!provider || !id) return;
177
+ const key = `${provider}/${id}`;
178
+ if (seen.has(key)) return;
179
+ seen.add(key);
180
+ let thinking = candidate.thinking.trim() || "high";
181
+ if (input.clampThinking) {
182
+ try {
183
+ thinking = input.clampThinking({ ...candidate, provider, id }) || thinking;
184
+ } catch {
185
+ /* a registry miss keeps the stored level */
186
+ }
187
+ }
188
+ out.push({ provider, id, thinking });
189
+ };
190
+ push(input.parentModel);
191
+ push(input.baseReviewer);
192
+ push(input.basePlanner);
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * The bounded attempt chain for one review round: the selected reviewer model,
198
+ * an explicit fallback, then the automatic fallback list. Duplicate
199
+ * provider/id pairs are rejected so a retry never repeats the same model.
200
+ */
201
+ export function buildReviewerAttempts(input: {
202
+ base?: ReviewFallbackModel;
203
+ explicitFallback?: ReviewFallbackModel;
204
+ fallbacks?: readonly ReviewFallbackModel[];
205
+ max?: number;
206
+ }): ReviewFallbackModel[] {
207
+ const out: ReviewFallbackModel[] = [];
208
+ const seen = new Set<string>();
209
+ const push = (candidate: ReviewFallbackModel | undefined): void => {
210
+ if (!candidate) return;
211
+ const provider = candidate.provider.trim();
212
+ const id = candidate.id.trim();
213
+ if (!provider || !id) return;
214
+ const key = `${provider}/${id}`;
215
+ if (seen.has(key)) return;
216
+ seen.add(key);
217
+ out.push({
218
+ provider,
219
+ id,
220
+ thinking: candidate.thinking.trim() || "high",
221
+ });
222
+ };
223
+ push(input.base);
224
+ push(input.explicitFallback);
225
+ for (const model of input.fallbacks ?? []) push(model);
226
+ return out.slice(0, Math.max(1, Math.trunc(input.max ?? 4)));
227
+ }
228
+ const PROVIDER_RETRIES_MIN = 0;
229
+ const PROVIDER_RETRIES_MAX = 2;
230
+ const CHUNK_CHARS_MIN = 1_000;
231
+ const CHUNK_CHARS_MAX = 20_000;
232
+ const RECOVERY_BUDGET_MIN = 0;
233
+ const RECOVERY_BUDGET_MAX = 3_600_000;
234
+
128
235
  /** A JSON object parsed from an untrusted source (settings file, fragment). */
129
236
  export type JsonObject = Record<string, unknown>;
130
237
 
@@ -149,6 +256,19 @@ function asBool(value: unknown, fallback: boolean): boolean {
149
256
  return typeof value === "boolean" ? value : fallback;
150
257
  }
151
258
 
259
+ function sanitizeFallbackModel(value: unknown): ReviewFallbackModel | undefined {
260
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
261
+ const raw = value as Record<string, unknown>;
262
+ const provider = typeof raw.provider === "string" ? raw.provider.trim() : "";
263
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
264
+ if (!provider || !id) return undefined;
265
+ const thinking =
266
+ typeof raw.thinking === "string" && raw.thinking.trim()
267
+ ? raw.thinking.trim()
268
+ : "high";
269
+ return { provider, id, thinking };
270
+ }
271
+
152
272
  /**
153
273
  * Coerce an untrusted settings fragment into a complete ReviewModeConfig.
154
274
  * Accepts an object, or the boolean shorthand `true`/`false` for
@@ -168,12 +288,42 @@ export function coerceReviewModeConfig(
168
288
  r.fallbackOnError === "block" || r.fallbackOnError === "skip"
169
289
  ? r.fallbackOnError
170
290
  : base.fallbackOnError;
291
+ const providerRecovery: ReviewProviderRecovery =
292
+ r.providerRecovery === "off" || r.providerRecovery === "fallback"
293
+ ? r.providerRecovery
294
+ : base.providerRecovery;
295
+ const chunkedSubmit: ReviewChunkedSubmit =
296
+ r.chunkedSubmit === "off" || r.chunkedSubmit === "auto"
297
+ ? r.chunkedSubmit
298
+ : base.chunkedSubmit;
299
+ const fallbackModel = sanitizeFallbackModel(r.fallbackModel);
171
300
  return {
172
301
  enabled: asBool(r.enabled, base.enabled),
173
302
  rounds: clampInt(r.rounds, ROUNDS_MIN, ROUNDS_MAX, base.rounds),
174
303
  passes: clampInt(r.passes, PASSES_MIN, PASSES_MAX, base.passes),
175
304
  verify: asBool(r.verify, base.verify),
176
305
  fallbackOnError,
306
+ providerRecovery,
307
+ providerRetries: clampInt(
308
+ r.providerRetries,
309
+ PROVIDER_RETRIES_MIN,
310
+ PROVIDER_RETRIES_MAX,
311
+ base.providerRetries,
312
+ ),
313
+ ...(fallbackModel ? { fallbackModel } : {}),
314
+ chunkedSubmit,
315
+ chunkChars: clampInt(
316
+ r.chunkChars,
317
+ CHUNK_CHARS_MIN,
318
+ CHUNK_CHARS_MAX,
319
+ base.chunkChars,
320
+ ),
321
+ recoveryBudgetMs: clampInt(
322
+ r.recoveryBudgetMs,
323
+ RECOVERY_BUDGET_MIN,
324
+ RECOVERY_BUDGET_MAX,
325
+ base.recoveryBudgetMs,
326
+ ),
177
327
  exploreBudget: clampInt(
178
328
  r.exploreBudget,
179
329
  EXPLORE_MIN,
@@ -478,6 +628,22 @@ export function formatReviewStatusLines(input: ReviewStatusInput): string[] {
478
628
  c.timeoutMs,
479
629
  input.timeoutSource,
480
630
  )}`,
631
+ ` recovery : ${c.providerRecovery}${
632
+ c.providerRecovery === "fallback"
633
+ ? ` (${c.providerRetries} SDK retry/model; budget ${
634
+ c.recoveryBudgetMs === 0
635
+ ? "round deadline"
636
+ : `${Math.round(c.recoveryBudgetMs / 1000)}s`
637
+ }${
638
+ c.fallbackModel
639
+ ? `; explicit ${c.fallbackModel.provider}/${c.fallbackModel.id} (${c.fallbackModel.thinking})`
640
+ : ""
641
+ })`
642
+ : ""
643
+ }`,
644
+ ` chunked submit : ${c.chunkedSubmit}${
645
+ c.chunkedSubmit === "auto" ? ` (${c.chunkChars} chars/chunk)` : ""
646
+ }`,
481
647
  ];
482
648
  if (
483
649
  input.timeoutSource !== "default" &&
@@ -682,6 +848,10 @@ export interface ReviewState {
682
848
  rewrittenPlan?: string;
683
849
  /** Reviewer passes completed in this round. */
684
850
  passes: ReviewPassResult[];
851
+ /** Recovery attempts made in this round, in order (model labels). */
852
+ recoveryAttempts?: string[];
853
+ /** Last provider error text captured during the round. */
854
+ providerError?: string;
685
855
  /** Number of `review_explore` subagents used this round. */
686
856
  exploreUsed: number;
687
857
  /** Input tokens reported by the child session, when available. */
@@ -1440,9 +1610,14 @@ export function reviewEscapeAction(input: {
1440
1610
  export function reviewFooterSegment(
1441
1611
  state: ReviewState | undefined,
1442
1612
  paused: boolean,
1613
+ /** A round owns the turn before its first state event (cold boot). */
1614
+ starting = false,
1615
+ /** The reviewer finished; the plan is waiting for Approve/Revise. */
1616
+ handoff = false,
1443
1617
  ): string {
1444
1618
  if (paused) return "🔍 review paused — /review-resume to continue";
1445
- if (!state) return "";
1619
+ if (handoff) return "🔍 review · awaiting decision";
1620
+ if (!state) return starting ? "🔍 review · starting" : "";
1446
1621
  return reviewStateSummary(state);
1447
1622
  }
1448
1623
 
@@ -1460,6 +1635,8 @@ export function reviewStatusVisible(input: {
1460
1635
  roundInFlight?: boolean;
1461
1636
  roundActive?: boolean;
1462
1637
  paused?: boolean;
1638
+ /** The reviewer finished and the browser/inline handoff owns the turn. */
1639
+ handoff?: boolean;
1463
1640
  /**
1464
1641
  * The retained round state's phase, when one exists. A retained *terminal*
1465
1642
  * phase must not make the line visible during a new round's cold boot
@@ -1468,6 +1645,7 @@ export function reviewStatusVisible(input: {
1468
1645
  phase?: ReviewPhase;
1469
1646
  }): boolean {
1470
1647
  if (input.paused === true) return true;
1648
+ if (input.handoff === true) return true;
1471
1649
  if (input.roundInFlight !== true && input.roundActive !== true) return false;
1472
1650
  // `undefined` means the round has not emitted a state yet; there is nothing
1473
1651
  // stale to paint and the lock/working message still describe the round.
@@ -1487,14 +1665,23 @@ export function reviewStatusText(input: {
1487
1665
  roundInFlight?: boolean;
1488
1666
  roundActive?: boolean;
1489
1667
  paused?: boolean;
1668
+ handoff?: boolean;
1490
1669
  }): string {
1491
1670
  const visible = reviewStatusVisible({
1492
1671
  roundInFlight: input.roundInFlight,
1493
1672
  roundActive: input.roundActive,
1494
1673
  paused: input.paused,
1674
+ handoff: input.handoff,
1495
1675
  phase: input.state?.phase,
1496
1676
  });
1497
- return visible ? reviewFooterSegment(input.state, input.paused === true) : "";
1677
+ return visible
1678
+ ? reviewFooterSegment(
1679
+ input.state,
1680
+ input.paused === true,
1681
+ input.roundInFlight === true,
1682
+ input.handoff === true,
1683
+ )
1684
+ : "";
1498
1685
  }
1499
1686
 
1500
1687
  /**
@@ -1545,18 +1732,77 @@ export function nextReviewRound(input: {
1545
1732
  return { round, allowed: round <= max };
1546
1733
  }
1547
1734
 
1548
- // ── Mode-switch lock (plan 2026-10-25) ───────────────────────────────
1735
+ // ── Review turn + mode-switch lock (plan 2026-11-14) ─────────────────
1736
+ //
1737
+ // Review is an exclusive turn, not a background job: from the moment the
1738
+ // planner hands its plan to the reviewer until the browser/inline decision is
1739
+ // applied, no prompt and no mode transition is accepted. The turn is an
1740
+ // explicit state (unlike the old derived lock) because the handoff phase has
1741
+ // no running child session and must still be locked.
1742
+ //
1743
+ // idle -> running -> paused -> running -> ... -> handoff -> transition -> idle
1744
+ // \-> handoff (reviewer failed/skipped) -> transition -> idle
1549
1745
  //
1550
- // While a review owns the turn, mode transitions are refused: switching to
1551
- // Build mid-review would swap the tool set and suspend the reviewer, and
1552
- // switching to Default would leave the reviewer running outside Plan Mode.
1553
- // The lock is a PROJECTION over existing state (never persisted), so every
1554
- // existing exit — land, fail, pause/resume, abort, suspend, teardown —
1555
- // releases it automatically. The post-review handoff prompt is deliberately
1556
- // NOT part of the lock: the independent reviewer has already finished.
1746
+ // `transition` is the short, internal window while a decision stops the
1747
+ // reviewer, applies Build/Plan and sends the follow-up. It is locked so a
1748
+ // queued prompt cannot race the mode change.
1749
+
1750
+ /** The exclusive Review turn. `idle` means no review owns the session. */
1751
+ export type ReviewTurnPhase =
1752
+ | "idle"
1753
+ | "running"
1754
+ | "paused"
1755
+ | "handoff"
1756
+ | "transition";
1757
+
1758
+ export function reviewTurnOwns(phase: ReviewTurnPhase): boolean {
1759
+ return phase !== "idle";
1760
+ }
1761
+
1762
+ /** Commands a user may run while Review owns the turn. */
1763
+ export function reviewInputAllowed(text: string): boolean {
1764
+ const value = text.trim();
1765
+ if (!value.startsWith("/")) return false;
1766
+ return (
1767
+ /^\/review(?:\s|-|$)/i.test(value) ||
1768
+ /^\/model(?:\s|$)/i.test(value) ||
1769
+ /^\/thinking(?:\s|$)/i.test(value) ||
1770
+ /^\/profile(?:\s|$)/i.test(value) ||
1771
+ /^\/role(?:\s|$)/i.test(value)
1772
+ );
1773
+ }
1774
+
1775
+ export type ReviewInputDecision =
1776
+ | { action: "allow" }
1777
+ | { action: "block"; notice: string };
1778
+
1779
+ /** One decision for `pi.on("input")`; pure so the matrix is testable. */
1780
+ export function reviewInputDecision(
1781
+ text: string,
1782
+ phase: ReviewTurnPhase,
1783
+ ): ReviewInputDecision {
1784
+ if (!reviewTurnOwns(phase)) return { action: "allow" };
1785
+ if (reviewInputAllowed(text)) return { action: "allow" };
1786
+ const state =
1787
+ phase === "paused"
1788
+ ? "paused"
1789
+ : phase === "handoff"
1790
+ ? "awaiting a decision"
1791
+ : phase === "transition"
1792
+ ? "applying the decision"
1793
+ : "running";
1794
+ return {
1795
+ action: "block",
1796
+ notice: `Review is ${state} — prompts are locked. /review-pause, /review-resume, /review-abort, or change model/profile.`,
1797
+ };
1798
+ }
1557
1799
 
1558
1800
  /** Which mode-switch lock is active, if any. Derived, never persisted. */
1559
- export type ReviewModeLockKind = "running" | "paused";
1801
+ export type ReviewModeLockKind =
1802
+ | "running"
1803
+ | "paused"
1804
+ | "handoff"
1805
+ | "transition";
1560
1806
 
1561
1807
  export interface ReviewModeLockInput {
1562
1808
  /** `activeReview !== undefined` — set before the child session boots. */
@@ -1567,6 +1813,10 @@ export interface ReviewModeLockInput {
1567
1813
  paused?: boolean;
1568
1814
  /** The gated handoff phase; `"auditing"` counts as running. */
1569
1815
  gatePhase?: ReviewHandoffPhase;
1816
+ /** The browser/inline decision owns the session. */
1817
+ handoff?: boolean;
1818
+ /** A decision is being applied (stop reviewer -> mode -> follow-up). */
1819
+ transition?: boolean;
1570
1820
  }
1571
1821
 
1572
1822
  /**
@@ -1589,9 +1839,30 @@ export function reviewModeLockKind(
1589
1839
  }
1590
1840
  if (input.gatePhase === "paused") return "paused";
1591
1841
  if (input.gatePhase === "auditing") return "running";
1842
+ if (input.transition === true) return "transition";
1843
+ if (input.handoff === true) return "handoff";
1592
1844
  return undefined;
1593
1845
  }
1594
1846
 
1847
+ /**
1848
+ * The narrower "a reviewer round or pause owns the turn" predicate.
1849
+ * Decision handlers must use this (not the full lock): the handoff they are
1850
+ * about to consume is not a stale decision.
1851
+ */
1852
+ export function reviewRoundOwnsTurn(input: {
1853
+ roundInFlight?: boolean;
1854
+ roundActive?: boolean;
1855
+ paused?: boolean;
1856
+ gatePhase?: ReviewHandoffPhase;
1857
+ }): boolean {
1858
+ return (
1859
+ input.paused === true ||
1860
+ input.roundInFlight === true ||
1861
+ input.roundActive === true ||
1862
+ input.gatePhase === "auditing"
1863
+ );
1864
+ }
1865
+
1595
1866
  /**
1596
1867
  * Phase to use when `/review-resume` starts a paused gated round.
1597
1868
  * A gated pause becomes an active audit again; a manual review (idle gate)
@@ -1625,9 +1896,16 @@ export function reviewHandoffParks(input: {
1625
1896
 
1626
1897
  /** One short notice for a blocked mode transition. */
1627
1898
  export function reviewModeLockNotice(kind: ReviewModeLockKind): string {
1628
- return kind === "paused"
1629
- ? "Review is paused — mode switching stays locked. /review-resume to continue, /review-abort to stop, /review-mode off to end review."
1630
- : "Review is running — mode switching is locked. ESC or /review-pause to pause, /review-resume to continue, /review-abort to stop, /review-mode off to end review.";
1899
+ if (kind === "paused") {
1900
+ return "Review is paused — mode switching stays locked. /review-resume to continue, /review-abort to stop.";
1901
+ }
1902
+ if (kind === "handoff") {
1903
+ return "Review decision pending — mode switching is locked. Approve or annotate in the review tab, or /review-abort to skip.";
1904
+ }
1905
+ if (kind === "transition") {
1906
+ return "Review decision is being applied — mode switching is briefly locked.";
1907
+ }
1908
+ return "Review is running — mode switching is locked. ESC or /review-pause to pause, /review-resume to continue, /review-abort to stop.";
1631
1909
  }
1632
1910
 
1633
1911
  /** Footer marker appended to the single `"workflow"` status while locked. */
@@ -1696,23 +1974,80 @@ export function truncateErrorText(input: unknown, max: number = MAX_ERROR_TEXT):
1696
1974
  }
1697
1975
 
1698
1976
  /**
1699
- * Extract the provider error message when a run stopped on an error: scan an
1700
- * `agent_end` event's messages from the end for the last assistant message
1701
- * with `stopReason === "error"`. Structural on purpose — SDK message shapes
1702
- * are not imported here. Returns undefined for clean runs and junk input.
1977
+ * True when an error text describes an *abort*, not a provider failure.
1978
+ *
1979
+ * Review Mode deliberately aborts the reviewer's tail after `review_pass_done`
1980
+ * (and on pause/teardown). Node rejects an aborted operation with its default
1981
+ * reason (`DOMException: This operation was aborted`); provider SDKs use
1982
+ * `Request was aborted`; the Pi SDK's backoff cancellation reports
1983
+ * `Retry cancelled`. The SDK's `lazyStream` setup path can record any of those
1984
+ * as a `stopReason: "error"` assistant message, so a stop-reason check alone
1985
+ * is not enough to tell an abort from a provider fault.
1986
+ *
1987
+ * Timeouts are deliberately NOT aborts: `AbortSignal.timeout()` rejects with
1988
+ * `The operation was aborted due to timeout`, which is a transient provider
1989
+ * failure and must keep its recovery semantics.
1990
+ *
1991
+ * Matching is anchored at the start of the flattened text so a provider payload
1992
+ * that merely mentions "aborted" is not downgraded. Pure and total.
1993
+ */
1994
+ export function isAbortStopText(input: unknown): boolean {
1995
+ const text =
1996
+ typeof input === "string" ? input.replace(/\s+/g, " ").trim() : "";
1997
+ if (!text) return false;
1998
+ // Checked first: "The operation was aborted due to timeout" is a timeout.
1999
+ if (/\b(?:timed?\s*out|timeout)\b/i.test(text)) return false;
2000
+ // `AgentSession._finishCancelledRetry()` reports a cancelled backoff this
2001
+ // way; it is our own abort, never provider health.
2002
+ if (/^retry\s+cancell?ed\b/i.test(text)) return true;
2003
+ return (
2004
+ /^(?:this|the|request|operation|signal)\b[^.\n]{0,40}abort(?:ed|ing)?\b/i.test(
2005
+ text,
2006
+ ) ||
2007
+ /^abort(?:ed)?\s*error\b/i.test(text) ||
2008
+ /^aborted\b/i.test(text)
2009
+ );
2010
+ }
2011
+
2012
+ /** How the reviewer's last assistant message stopped. */
2013
+ export type ReviewStopClassification =
2014
+ | { kind: "clean" }
2015
+ | { kind: "aborted"; message?: string }
2016
+ | { kind: "provider-error"; message: string };
2017
+
2018
+ /**
2019
+ * Classify the outcome of a child run from an `agent_end` event's messages.
2020
+ *
2021
+ * Structural on purpose — SDK message shapes are not imported here. Scans
2022
+ * backwards to the last assistant message (the only one that decides the run's
2023
+ * outcome); a missing assistant message or junk input is `clean`. An error stop
2024
+ * with an abort-shaped message is `aborted` even when the SDK labelled it
2025
+ * `error` (its setup path does not check the abort signal), so callers never
2026
+ * report a host cancellation as provider health.
1703
2027
  */
1704
- export function lastAssistantErrorStop(messages: unknown): string | undefined {
1705
- if (!Array.isArray(messages)) return undefined;
2028
+ export function classifyAssistantStop(
2029
+ messages: unknown,
2030
+ ): ReviewStopClassification {
2031
+ if (!Array.isArray(messages)) return { kind: "clean" };
1706
2032
  for (let i = messages.length - 1; i >= 0; i--) {
1707
2033
  const rec = messages[i] as Record<string, unknown> | null | undefined;
1708
2034
  if (!rec || typeof rec !== "object") continue;
1709
2035
  if (rec.role !== "assistant") continue;
1710
- // Last assistant message reached: a non-error stop means a clean run.
1711
- if (rec.stopReason !== "error") return undefined;
1712
- const err = typeof rec.errorMessage === "string" ? rec.errorMessage : "";
1713
- return err || "unknown provider error";
2036
+ // Last assistant message reached: this one decides the run outcome.
2037
+ const stopReason = rec.stopReason;
2038
+ if (stopReason === "aborted") {
2039
+ const message =
2040
+ typeof rec.errorMessage === "string" ? rec.errorMessage.trim() : "";
2041
+ return message ? { kind: "aborted", message } : { kind: "aborted" };
2042
+ }
2043
+ if (stopReason !== "error") return { kind: "clean" };
2044
+ const err = typeof rec.errorMessage === "string" ? rec.errorMessage.trim() : "";
2045
+ if (isAbortStopText(err)) {
2046
+ return err ? { kind: "aborted", message: err } : { kind: "aborted" };
2047
+ }
2048
+ return { kind: "provider-error", message: err || "unknown provider error" };
1714
2049
  }
1715
- return undefined;
2050
+ return { kind: "clean" };
1716
2051
  }
1717
2052
 
1718
2053
  /**
@@ -1724,14 +2059,19 @@ export function lastAssistantErrorStop(messages: unknown): string | undefined {
1724
2059
  * - `exhaust` → the retry budget ran out (`auto_retry_end`, `success: false`);
1725
2060
  * the run is about to settle on an error. `message` may be empty when the
1726
2061
  * event carries no `finalError` — callers then keep their earlier message.
1727
- * - `stop` → the run itself ended with `stopReason === "error"` on its last
1728
- * assistant message (auto-retry disabled, or a non-retryable failure).
2062
+ * - `stop` → the run itself ended on a provider error
2063
+ * (`stopReason === "error"` with a non-abort message; auto-retry disabled,
2064
+ * or a non-retryable failure).
2065
+ * - `aborted` → the run ended on an abort (`stopReason === "aborted"`, or an
2066
+ * error stop whose message is abort-shaped — the SDK's setup path mislabels
2067
+ * a host cancellation as `error`). Never provider health.
1729
2068
  * - `none` → anything else, including a retry that recovered.
1730
2069
  */
1731
2070
  export type ReviewProviderFailure =
1732
2071
  | { kind: "retry"; message: string; retries: number }
1733
2072
  | { kind: "exhaust"; message: string; retries: number }
1734
2073
  | { kind: "stop"; message: string }
2074
+ | { kind: "aborted"; message?: string }
1735
2075
  | { kind: "none" };
1736
2076
 
1737
2077
  /** Structural decode of one provider-failure event. Never throws. */
@@ -1745,6 +2085,8 @@ export function readProviderErrorEvent(
1745
2085
  const message =
1746
2086
  typeof e.errorMessage === "string" ? e.errorMessage.trim() : "";
1747
2087
  if (!message) return { kind: "none" };
2088
+ // A cancelled retry is our own abort, not provider health.
2089
+ if (isAbortStopText(message)) return { kind: "none" };
1748
2090
  return {
1749
2091
  kind: "retry",
1750
2092
  message,
@@ -1754,6 +2096,7 @@ export function readProviderErrorEvent(
1754
2096
  if (type === "auto_retry_end") {
1755
2097
  if (e.success !== false) return { kind: "none" };
1756
2098
  const message = typeof e.finalError === "string" ? e.finalError.trim() : "";
2099
+ if (isAbortStopText(message)) return { kind: "none" };
1757
2100
  return {
1758
2101
  kind: "exhaust",
1759
2102
  message,
@@ -1761,9 +2104,14 @@ export function readProviderErrorEvent(
1761
2104
  };
1762
2105
  }
1763
2106
  if (type === "agent_end") {
1764
- const message = lastAssistantErrorStop(e.messages);
1765
- if (message === undefined) return { kind: "none" };
1766
- return { kind: "stop", message };
2107
+ const stop = classifyAssistantStop(e.messages);
2108
+ if (stop.kind === "aborted") {
2109
+ return stop.message
2110
+ ? { kind: "aborted", message: stop.message }
2111
+ : { kind: "aborted" };
2112
+ }
2113
+ if (stop.kind !== "provider-error") return { kind: "none" };
2114
+ return { kind: "stop", message: stop.message };
1767
2115
  }
1768
2116
  return { kind: "none" };
1769
2117
  }
@@ -1916,18 +2264,25 @@ function providerErrorOps(
1916
2264
  const ops: ReviewTranscriptOp[] = [
1917
2265
  { op: "new", kind: "status", text: "reviewer run ended" },
1918
2266
  ];
1919
- const failed = lastAssistantErrorStop(e.messages);
1920
- if (failed) {
2267
+ // An abort is our own cancellation (pass complete / pause / teardown),
2268
+ // never provider health. The SDK's setup path can label it `error` with an
2269
+ // abort-shaped message, so classification cannot rely on `stopReason`.
2270
+ const stop = classifyAssistantStop(e.messages);
2271
+ if (stop.kind === "provider-error") {
1921
2272
  ops.push({
1922
2273
  op: "new",
1923
2274
  kind: "error",
1924
- text: `reviewer run ended on a provider error: ${truncateErrorText(failed)}`,
2275
+ text: `reviewer run ended on a provider error: ${truncateErrorText(
2276
+ stop.message,
2277
+ )}`,
1925
2278
  });
1926
2279
  }
1927
2280
  return ops;
1928
2281
  }
1929
2282
  if (type === "auto_retry_start") {
1930
2283
  const msg = typeof e.errorMessage === "string" ? e.errorMessage : "";
2284
+ // A cancelled backoff is an abort, not a provider notice.
2285
+ if (isAbortStopText(msg)) return [];
1931
2286
  return [
1932
2287
  {
1933
2288
  op: "new",
@@ -1944,6 +2299,7 @@ function providerErrorOps(
1944
2299
  // retry recovered; the earlier notice already covers that case.
1945
2300
  if (e.success === false) {
1946
2301
  const detail = truncateErrorText(e.finalError);
2302
+ if (isAbortStopText(detail)) return [];
1947
2303
  return [
1948
2304
  {
1949
2305
  op: "new",
@@ -2186,6 +2542,12 @@ export interface CreateReviewSessionOptions {
2186
2542
  reviewPrompt: string;
2187
2543
  /** Reviewer-only tools, built by the caller. */
2188
2544
  customTools: unknown[];
2545
+ /**
2546
+ * SDK retries per reviewer model (0-2). Applied through a runtime-only child
2547
+ * SettingsManager override so the recovery chain advances after one doomed
2548
+ * long request instead of spending the parent's 3-retry budget first.
2549
+ */
2550
+ providerRetries?: number;
2189
2551
  /**
2190
2552
  * Parent-side provider-registration surface (structural, feature-detected),
2191
2553
  * found with `findParentProviderSurface(ctx)` — the session's model registry
@@ -2246,6 +2608,53 @@ function readActiveToolNames(session: unknown): string[] | undefined {
2246
2608
  }
2247
2609
  }
2248
2610
 
2611
+ function clampProviderRetries(value: number | undefined): number {
2612
+ if (typeof value !== "number" || !Number.isFinite(value)) return 1;
2613
+ return Math.min(2, Math.max(0, Math.trunc(value)));
2614
+ }
2615
+
2616
+ /**
2617
+ * Build the child's runtime-only SettingsManager so its SDK retry budget is
2618
+ * bounded. `applyOverrides` is in-memory: the shared settings.json is never
2619
+ * written. Feature-detected for older Pi runtimes; a failure here means the
2620
+ * child keeps the parent's settings and the chain simply waits that budget out.
2621
+ */
2622
+ function createChildSettingsManager(
2623
+ sdk: unknown,
2624
+ cwd: string,
2625
+ agentDir: string,
2626
+ providerRetries: number | undefined,
2627
+ ): unknown | undefined {
2628
+ try {
2629
+ const holder = sdk as {
2630
+ SettingsManager?: { create?: (...args: unknown[]) => unknown };
2631
+ };
2632
+ const SettingsManager = holder?.SettingsManager;
2633
+ const create = SettingsManager?.create;
2634
+ if (typeof create !== "function") return undefined;
2635
+ const manager = create.call(SettingsManager, cwd, agentDir) as {
2636
+ getRetrySettings?: () => Record<string, unknown>;
2637
+ applyOverrides?: (overrides: unknown) => void;
2638
+ };
2639
+ if (!manager || typeof manager !== "object") return undefined;
2640
+ if (typeof manager.applyOverrides !== "function") return manager;
2641
+ const base =
2642
+ typeof manager.getRetrySettings === "function"
2643
+ ? manager.getRetrySettings()
2644
+ : { enabled: true, maxRetries: 3, baseDelayMs: 2_000, maxAgentDelayMs: 60_000 };
2645
+ manager.applyOverrides({
2646
+ retry: {
2647
+ ...base,
2648
+ enabled: true,
2649
+ maxRetries: clampProviderRetries(providerRetries),
2650
+ },
2651
+ });
2652
+ return manager;
2653
+ } catch {
2654
+ return undefined;
2655
+ }
2656
+ }
2657
+
2249
2658
  function wrapAgentSession(
2250
2659
  raw: unknown,
2251
2660
  modelLabel: string,
@@ -2421,6 +2830,14 @@ export async function createReviewSession(
2421
2830
  // custom tools too, so a built-ins-only list would silently remove
2422
2831
  // `review_submit_plan` and make every review fail (see REVIEW_BUILTIN_TOOLS).
2423
2832
  const expectedTools = reviewToolAllowlist(options.customTools);
2833
+ // The child gets its own retry budget. The shared settings file is not
2834
+ // touched: applyOverrides is runtime-only.
2835
+ const settingsManager = createChildSettingsManager(
2836
+ sdk,
2837
+ options.cwd,
2838
+ options.agentDir,
2839
+ options.providerRetries,
2840
+ );
2424
2841
 
2425
2842
  process.env[REVIEW_CHILD_MARKER_ENV] = "1";
2426
2843
  let created: { session?: unknown; modelFallbackMessage?: string };
@@ -2435,6 +2852,7 @@ export async function createReviewSession(
2435
2852
  customTools: options.customTools as never,
2436
2853
  resourceLoader: loader,
2437
2854
  sessionManager,
2855
+ ...(settingsManager ? { settingsManager } : {}),
2438
2856
  sessionStartEvent: { type: "session_start", reason: "startup" },
2439
2857
  } as never);
2440
2858
  } finally {