@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/README.md +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
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 (
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
1551
|
-
// Build
|
|
1552
|
-
//
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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 =
|
|
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
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
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
|
|
1705
|
-
|
|
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:
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
|
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
|
|
1728
|
-
*
|
|
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
|
|
1765
|
-
if (
|
|
1766
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
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(
|
|
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 {
|