@dieulc/workflow 0.9.1 → 0.10.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,11 @@ 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,
1443
1615
  ): string {
1444
1616
  if (paused) return "🔍 review paused — /review-resume to continue";
1445
- if (!state) return "";
1617
+ if (!state) return starting ? "🔍 review · starting" : "";
1446
1618
  return reviewStateSummary(state);
1447
1619
  }
1448
1620
 
@@ -1460,14 +1632,97 @@ export function reviewStatusVisible(input: {
1460
1632
  roundInFlight?: boolean;
1461
1633
  roundActive?: boolean;
1462
1634
  paused?: boolean;
1635
+ /**
1636
+ * The retained round state's phase, when one exists. A retained *terminal*
1637
+ * phase must not make the line visible during a new round's cold boot
1638
+ * (`roundActive` flips before the first live phase event).
1639
+ */
1640
+ phase?: ReviewPhase;
1641
+ }): boolean {
1642
+ if (input.paused === true) return true;
1643
+ if (input.roundInFlight !== true && input.roundActive !== true) return false;
1644
+ // `undefined` means the round has not emitted a state yet; there is nothing
1645
+ // stale to paint and the lock/working message still describe the round.
1646
+ return input.phase === undefined || reviewIsActive(input.phase);
1647
+ }
1648
+
1649
+ /**
1650
+ * The text for the live review status surface, or `""` when nothing is live.
1651
+ *
1652
+ * Single source for the footer segment and the review widget: a retained
1653
+ * `reviewState` is used for `/review-status` and the summary band, but it is
1654
+ * never painted as a live line once its round is over. While paused it shows
1655
+ * the paused form (never the retained terminal summary).
1656
+ */
1657
+ export function reviewStatusText(input: {
1658
+ state: ReviewState | undefined;
1659
+ roundInFlight?: boolean;
1660
+ roundActive?: boolean;
1661
+ paused?: boolean;
1662
+ }): string {
1663
+ const visible = reviewStatusVisible({
1664
+ roundInFlight: input.roundInFlight,
1665
+ roundActive: input.roundActive,
1666
+ paused: input.paused,
1667
+ phase: input.state?.phase,
1668
+ });
1669
+ return visible
1670
+ ? reviewFooterSegment(
1671
+ input.state,
1672
+ input.paused === true,
1673
+ input.roundInFlight === true,
1674
+ )
1675
+ : "";
1676
+ }
1677
+
1678
+ /**
1679
+ * Whether a pause snapshot belongs to the current review cycle.
1680
+ *
1681
+ * Pauses are in-memory and cycle-tagged. A snapshot from an older cycle must
1682
+ * never keep the review line/lock alive or be resumed after new work started.
1683
+ */
1684
+ export function reviewPauseIsCurrent(input: {
1685
+ pauseCycle: number | undefined;
1686
+ currentCycle: number;
1463
1687
  }): boolean {
1464
1688
  return (
1465
- input.roundInFlight === true ||
1466
- input.roundActive === true ||
1467
- input.paused === true
1689
+ typeof input.pauseCycle === "number" &&
1690
+ input.pauseCycle === input.currentCycle
1468
1691
  );
1469
1692
  }
1470
1693
 
1694
+ /** Result of consuming one review round from the cycle budget. */
1695
+ export interface ReviewRoundBudget {
1696
+ /** The round number this attempt would use (1-based). */
1697
+ round: number;
1698
+ /** False when the cycle's `rounds` cap has already been consumed. */
1699
+ allowed: boolean;
1700
+ }
1701
+
1702
+ /**
1703
+ * Consume one round from a cycle's budget.
1704
+ *
1705
+ * A new cycle starts at round 1 regardless of the previous cycle's count; this
1706
+ * is the fix for the session-wide counter that made a second plan skip review
1707
+ * with `round limit (1) reached`. Within a cycle the existing cap applies.
1708
+ */
1709
+ export function nextReviewRound(input: {
1710
+ round: number;
1711
+ maxRounds: number;
1712
+ newCycle?: boolean;
1713
+ }): ReviewRoundBudget {
1714
+ const maxRaw = Number.isFinite(input.maxRounds)
1715
+ ? Math.trunc(input.maxRounds)
1716
+ : 1;
1717
+ const max = Math.max(1, maxRaw);
1718
+ const roundRaw = Number.isFinite(input.round)
1719
+ ? Math.trunc(input.round)
1720
+ : 0;
1721
+ const current = Math.max(0, roundRaw);
1722
+ const round = (input.newCycle === true ? 0 : current) + 1;
1723
+ return { round, allowed: round <= max };
1724
+ }
1725
+
1471
1726
  // ── Mode-switch lock (plan 2026-10-25) ───────────────────────────────
1472
1727
  //
1473
1728
  // While a review owns the turn, mode transitions are refused: switching to
@@ -2109,6 +2364,12 @@ export interface CreateReviewSessionOptions {
2109
2364
  reviewPrompt: string;
2110
2365
  /** Reviewer-only tools, built by the caller. */
2111
2366
  customTools: unknown[];
2367
+ /**
2368
+ * SDK retries per reviewer model (0-2). Applied through a runtime-only child
2369
+ * SettingsManager override so the recovery chain advances after one doomed
2370
+ * long request instead of spending the parent's 3-retry budget first.
2371
+ */
2372
+ providerRetries?: number;
2112
2373
  /**
2113
2374
  * Parent-side provider-registration surface (structural, feature-detected),
2114
2375
  * found with `findParentProviderSurface(ctx)` — the session's model registry
@@ -2169,6 +2430,53 @@ function readActiveToolNames(session: unknown): string[] | undefined {
2169
2430
  }
2170
2431
  }
2171
2432
 
2433
+ function clampProviderRetries(value: number | undefined): number {
2434
+ if (typeof value !== "number" || !Number.isFinite(value)) return 1;
2435
+ return Math.min(2, Math.max(0, Math.trunc(value)));
2436
+ }
2437
+
2438
+ /**
2439
+ * Build the child's runtime-only SettingsManager so its SDK retry budget is
2440
+ * bounded. `applyOverrides` is in-memory: the shared settings.json is never
2441
+ * written. Feature-detected for older Pi runtimes; a failure here means the
2442
+ * child keeps the parent's settings and the chain simply waits that budget out.
2443
+ */
2444
+ function createChildSettingsManager(
2445
+ sdk: unknown,
2446
+ cwd: string,
2447
+ agentDir: string,
2448
+ providerRetries: number | undefined,
2449
+ ): unknown | undefined {
2450
+ try {
2451
+ const holder = sdk as {
2452
+ SettingsManager?: { create?: (...args: unknown[]) => unknown };
2453
+ };
2454
+ const SettingsManager = holder?.SettingsManager;
2455
+ const create = SettingsManager?.create;
2456
+ if (typeof create !== "function") return undefined;
2457
+ const manager = create.call(SettingsManager, cwd, agentDir) as {
2458
+ getRetrySettings?: () => Record<string, unknown>;
2459
+ applyOverrides?: (overrides: unknown) => void;
2460
+ };
2461
+ if (!manager || typeof manager !== "object") return undefined;
2462
+ if (typeof manager.applyOverrides !== "function") return manager;
2463
+ const base =
2464
+ typeof manager.getRetrySettings === "function"
2465
+ ? manager.getRetrySettings()
2466
+ : { enabled: true, maxRetries: 3, baseDelayMs: 2_000, maxAgentDelayMs: 60_000 };
2467
+ manager.applyOverrides({
2468
+ retry: {
2469
+ ...base,
2470
+ enabled: true,
2471
+ maxRetries: clampProviderRetries(providerRetries),
2472
+ },
2473
+ });
2474
+ return manager;
2475
+ } catch {
2476
+ return undefined;
2477
+ }
2478
+ }
2479
+
2172
2480
  function wrapAgentSession(
2173
2481
  raw: unknown,
2174
2482
  modelLabel: string,
@@ -2344,6 +2652,14 @@ export async function createReviewSession(
2344
2652
  // custom tools too, so a built-ins-only list would silently remove
2345
2653
  // `review_submit_plan` and make every review fail (see REVIEW_BUILTIN_TOOLS).
2346
2654
  const expectedTools = reviewToolAllowlist(options.customTools);
2655
+ // The child gets its own retry budget. The shared settings file is not
2656
+ // touched: applyOverrides is runtime-only.
2657
+ const settingsManager = createChildSettingsManager(
2658
+ sdk,
2659
+ options.cwd,
2660
+ options.agentDir,
2661
+ options.providerRetries,
2662
+ );
2347
2663
 
2348
2664
  process.env[REVIEW_CHILD_MARKER_ENV] = "1";
2349
2665
  let created: { session?: unknown; modelFallbackMessage?: string };
@@ -2358,6 +2674,7 @@ export async function createReviewSession(
2358
2674
  customTools: options.customTools as never,
2359
2675
  resourceLoader: loader,
2360
2676
  sessionManager,
2677
+ ...(settingsManager ? { settingsManager } : {}),
2361
2678
  sessionStartEvent: { type: "session_start", reason: "startup" },
2362
2679
  } as never);
2363
2680
  } finally {