pi-crew 0.9.8 → 0.9.10
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/CHANGELOG.md +311 -0
- package/README.md +2 -2
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/register.ts +94 -21
- package/src/extension/registration/subagent-helpers.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +9 -0
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/batch-barrier.ts +145 -0
- package/src/runtime/child-pi.ts +135 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/crash-classification.ts +208 -0
- package/src/runtime/custom-tools/irc-tool.ts +47 -7
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-agent-manager.ts +185 -0
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/process-lifecycle.ts +481 -0
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/subagent-manager.ts +6 -0
- package/src/runtime/task-output-context.ts +209 -24
- package/src/runtime/task-runner.ts +76 -15
- package/src/runtime/tool-output-pruner.ts +334 -0
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/state/types.ts +5 -0
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
|
@@ -4,6 +4,9 @@ import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../stat
|
|
|
4
4
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
5
5
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
6
6
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
7
|
+
import { pruneToolOutputs, type ToolResultEntry, type FileEditEvent, DEFAULT_PRUNE_CONFIG } from "./tool-output-pruner.ts";
|
|
8
|
+
import { applyCompactPipeline } from "./compact-pipeline.ts";
|
|
9
|
+
import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compact-stages/index.ts";
|
|
7
10
|
|
|
8
11
|
export interface DependencyContextEntry {
|
|
9
12
|
taskId: string;
|
|
@@ -18,7 +21,14 @@ export interface DependencyContextEntry {
|
|
|
18
21
|
|
|
19
22
|
export interface DependencyOutputContext {
|
|
20
23
|
dependencies: DependencyContextEntry[];
|
|
21
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Each shared artifact read, truncated for inline injection. When truncation
|
|
26
|
+
* is materially lossy (file size > 2× MAX_RESULT_INLINE_BYTES) the FULL
|
|
27
|
+
* content is also teed to `${artifactsRoot}/tee/${taskId}-${name}.full.txt`
|
|
28
|
+
* and the path is exposed via `fullOutputPath` so the downstream worker
|
|
29
|
+
* can `read` it back if it needs the dropped middle.
|
|
30
|
+
*/
|
|
31
|
+
sharedReads: Array<{ name: string; path: string; content: string; fullOutputPath?: string }>;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
function containedExists(filePath: string, baseDir?: string): boolean {
|
|
@@ -38,35 +48,127 @@ function containedExists(filePath: string, baseDir?: string): boolean {
|
|
|
38
48
|
* (24K/40K/80K) which truncated the same artifact differently depending on
|
|
39
49
|
* which code path read it.
|
|
40
50
|
*/
|
|
41
|
-
const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
51
|
+
export const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Read a file and return its content, truncating to a head+tail slice if it
|
|
55
|
+
* exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
|
|
56
|
+
* sequences are preserved by reading the full file as a UTF-8 string and
|
|
57
|
+
* slicing by character count (not raw bytes).
|
|
58
|
+
*/
|
|
59
|
+
export interface TeeRecoveryOptions {
|
|
60
|
+
/** Absolute path to write the full (non-truncated) content to. */
|
|
61
|
+
fullOutputPath: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ReadIfSmallTeeResult {
|
|
65
|
+
/** Truncated content (or full content when no truncation). */
|
|
66
|
+
content: string;
|
|
67
|
+
/** Set only when tee was actually written (file size > 2× threshold + write succeeded). */
|
|
68
|
+
fullOutputPath?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sanitize a taskId / artifactName into a flat tee filename. Any character
|
|
73
|
+
* outside [A-Za-z0-9._-] is replaced with underscore so the resulting path
|
|
74
|
+
* is always single-segment and cannot escape the tee directory.
|
|
75
|
+
*/
|
|
76
|
+
function safeTeeName(taskId: string, artifactName: string): string {
|
|
77
|
+
const safe = (s: string): string => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
78
|
+
return `${safe(taskId)}-${safe(artifactName)}.full.txt`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Canonical tee path for a shared artifact read.
|
|
83
|
+
*
|
|
84
|
+
* Format: `${artifactsRoot}/tee/${taskId}-${artifactName}.full.txt`
|
|
85
|
+
*
|
|
86
|
+
* The downstream worker prompt includes this path so the worker can `read`
|
|
87
|
+
* the full content when it needs the dropped middle.
|
|
88
|
+
*/
|
|
89
|
+
export function teePathForArtifact(artifactsRoot: string, taskId: string, artifactName: string): string {
|
|
90
|
+
return path.join(artifactsRoot, "tee", safeTeeName(taskId, artifactName));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort tee write. Returns true on success, false on any error (write
|
|
95
|
+
* failures are silent — tee is enhancement, never a hard dependency). The
|
|
96
|
+
* truncated inline content is still returned by the caller either way.
|
|
97
|
+
*/
|
|
98
|
+
function writeTeeFile(fullOutputPath: string, content: string): boolean {
|
|
45
99
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
100
|
+
fs.mkdirSync(path.dirname(fullOutputPath), { recursive: true });
|
|
101
|
+
fs.writeFileSync(fullOutputPath, content, "utf-8");
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read a file with optional tee-recovery (P1-A). Returns the truncated
|
|
110
|
+
* content AND (when tee was actually written) the absolute path to the full
|
|
111
|
+
* file. Returns undefined if the file cannot be read at all.
|
|
112
|
+
*
|
|
113
|
+
* Tee threshold: only when content.length > 2 * MAX_RESULT_INLINE_BYTES
|
|
114
|
+
* (the head+tail is materially lossy — small over-threshold files are not
|
|
115
|
+
* teed because the inline content is mostly intact and the worker can live
|
|
116
|
+
* with the 75/25 split). File content is read once and reused for both the
|
|
117
|
+
* pipeline (truncation) and the tee write (full file).
|
|
118
|
+
*
|
|
119
|
+
* Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
|
|
120
|
+
* blank collapse BEFORE truncation, important-line preservation (P0-B)
|
|
121
|
+
* inside TruncationStage, marker wording matches the pre-P1-A `readIfSmall`
|
|
122
|
+
* output exactly (L4 backward-compat).
|
|
123
|
+
*/
|
|
124
|
+
export function readIfSmallWithTee(
|
|
125
|
+
filePath: string,
|
|
126
|
+
opts: { baseDir?: string; tee?: TeeRecoveryOptions } = {},
|
|
127
|
+
): ReadIfSmallTeeResult | undefined {
|
|
128
|
+
const maxChars = MAX_RESULT_INLINE_BYTES;
|
|
129
|
+
try {
|
|
130
|
+
const safePath = opts.baseDir ? resolveRealContainedPath(opts.baseDir, filePath) : filePath;
|
|
131
|
+
const content = fs.readFileSync(safePath, "utf-8");
|
|
132
|
+
if (content.length > maxChars) {
|
|
133
|
+
let fullOutputPath: string | undefined;
|
|
134
|
+
// Tee only when truncation is materially lossy (>2× threshold).
|
|
135
|
+
if (opts.tee && content.length > maxChars * 2) {
|
|
136
|
+
if (writeTeeFile(opts.tee.fullOutputPath, content)) {
|
|
137
|
+
fullOutputPath = opts.tee.fullOutputPath;
|
|
138
|
+
}
|
|
61
139
|
}
|
|
62
|
-
|
|
140
|
+
const result = applyCompactPipeline(content, [
|
|
141
|
+
ANSI_STRIP_STAGE,
|
|
142
|
+
BLANK_COLLAPSE_STAGE,
|
|
143
|
+
new TruncationStage(maxChars, {
|
|
144
|
+
preserveImportant: true,
|
|
145
|
+
marker: { verb: "truncated", unit: "chars", headSeparator: "\n\n", tailSeparator: "\n" },
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
return fullOutputPath ? { content: result.text, fullOutputPath } : { content: result.text };
|
|
63
149
|
}
|
|
64
|
-
return
|
|
150
|
+
return { content };
|
|
65
151
|
} catch {
|
|
66
152
|
return undefined;
|
|
67
153
|
}
|
|
68
154
|
}
|
|
69
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Read a file and return its content, truncating to a head+tail slice if it
|
|
158
|
+
* exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
|
|
159
|
+
* sequences are preserved by reading the full file as a UTF-8 string and
|
|
160
|
+
* slicing by character count (not raw bytes).
|
|
161
|
+
*
|
|
162
|
+
* Thin wrapper around {@link readIfSmallWithTee} for backward compatibility
|
|
163
|
+
* — callers that do not need tee-recovery metadata get just the content
|
|
164
|
+
* string. New tee-recovery call sites should use {@link readIfSmallWithTee}
|
|
165
|
+
* directly so they can include the full output path in the worker prompt.
|
|
166
|
+
*/
|
|
167
|
+
export function readIfSmall(filePath: string, baseDir?: string): string | undefined {
|
|
168
|
+
const result = readIfSmallWithTee(filePath, { baseDir });
|
|
169
|
+
return result?.content;
|
|
170
|
+
}
|
|
171
|
+
|
|
70
172
|
function safeSharedName(name: string): string {
|
|
71
173
|
const normalized = name.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
72
174
|
if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid shared artifact name: ${name}`);
|
|
@@ -111,6 +213,56 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
|
|
|
111
213
|
return { inputTokens, outputTokens, durationMs };
|
|
112
214
|
}
|
|
113
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Apply staleness-aware pruning to shared reads before they are injected
|
|
218
|
+
* into a downstream worker's prompt. Converts shared reads to generic
|
|
219
|
+
* {@link ToolResultEntry}s (toolName="read") and file edits from dependency
|
|
220
|
+
* artifacts, then delegates to {@link pruneToolOutputs}. Superseded reads
|
|
221
|
+
* (same base file re-read, or file edited by a later dependency) are replaced
|
|
222
|
+
* with compact digest notices, reducing context bloat.
|
|
223
|
+
*
|
|
224
|
+
* OPT-IN: the default prune config protects recent results and only fires
|
|
225
|
+
* when minimum-savings hysteresis is met, so small/unique reads pass through
|
|
226
|
+
* unchanged.
|
|
227
|
+
*/
|
|
228
|
+
function pruneSharedReads(
|
|
229
|
+
reads: Array<{ name: string; path: string; content: string }>,
|
|
230
|
+
dependencies: DependencyContextEntry[],
|
|
231
|
+
artifactsRoot: string,
|
|
232
|
+
): Array<{ name: string; path: string; content: string }> {
|
|
233
|
+
if (reads.length === 0) return reads;
|
|
234
|
+
// Convert shared reads to tool result entries (ordered oldest → newest
|
|
235
|
+
// by position in the reads array — earlier entries are "older").
|
|
236
|
+
const entries: ToolResultEntry[] = reads.map((read, index) => ({
|
|
237
|
+
id: `shared-read-${index}`,
|
|
238
|
+
toolName: "read",
|
|
239
|
+
target: read.path,
|
|
240
|
+
content: read.content,
|
|
241
|
+
}));
|
|
242
|
+
// Collect file edit events from dependency artifacts produced to shared/.
|
|
243
|
+
// A dependency that wrote a shared file after an earlier read invalidates
|
|
244
|
+
// that read (the content is now stale relative to the latest version).
|
|
245
|
+
// Artifact entries from listTaskArtifacts() are already relative to
|
|
246
|
+
// artifactsRoot (e.g. "shared/foo.md"), so resolve directly against
|
|
247
|
+
// artifactsRoot — NOT against a "shared" subdirectory (which would
|
|
248
|
+
// double-prefix to <artifactsRoot>/shared/shared/foo.md).
|
|
249
|
+
const fileEdits: FileEditEvent[] = [];
|
|
250
|
+
for (let depIndex = 0; depIndex < dependencies.length; depIndex++) {
|
|
251
|
+
const dep = dependencies[depIndex]!;
|
|
252
|
+
const produced = dep.artifactsProduced ?? [];
|
|
253
|
+
for (const artifact of produced) {
|
|
254
|
+
if (typeof artifact !== "string") continue;
|
|
255
|
+
// Map artifact path (relative to artifactsRoot) to absolute and
|
|
256
|
+
// check against read targets.
|
|
257
|
+
fileEdits.push({ target: path.resolve(artifactsRoot, artifact), index: reads.length + depIndex });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const pruned = pruneToolOutputs(entries, DEFAULT_PRUNE_CONFIG);
|
|
261
|
+
if (pruned.prunedCount === 0) return reads;
|
|
262
|
+
// Map pruned entries back to the shared-read shape.
|
|
263
|
+
return pruned.results.map((entry, index) => ({ ...reads[index]!, content: entry.content }));
|
|
264
|
+
}
|
|
265
|
+
|
|
114
266
|
export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks: TeamTaskState[], task: TeamTaskState, step: WorkflowStep): DependencyOutputContext {
|
|
115
267
|
const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
|
|
116
268
|
const byId = new Map(tasks.map((item) => [item.id, item]));
|
|
@@ -127,10 +279,35 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
127
279
|
usage: aggregateUsage(item),
|
|
128
280
|
};
|
|
129
281
|
});
|
|
130
|
-
const
|
|
282
|
+
const rawSharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
|
|
131
283
|
const filePath = sharedPath(manifest, name);
|
|
132
|
-
|
|
284
|
+
// P1-A tee-recovery: when the shared artifact is large enough that the
|
|
285
|
+
// 75/25 head+tail split is materially lossy (>2× MAX_RESULT_INLINE_BYTES),
|
|
286
|
+
// tee the full content to ${artifactsRoot}/tee/${taskId}-${name}.full.txt
|
|
287
|
+
// and expose the path so the downstream worker can `read` the full file
|
|
288
|
+
// if it needs the dropped middle. The truncated content is still
|
|
289
|
+
// included inline; tee is an enhancement, not a hard dependency. Tee
|
|
290
|
+
// write is best-effort (writeTeeFile swallows I/O errors and the result
|
|
291
|
+
// simply omits fullOutputPath in that case).
|
|
292
|
+
const teePath = teePathForArtifact(manifest.artifactsRoot, task.id, name);
|
|
293
|
+
const teeResult = readIfSmallWithTee(filePath, {
|
|
294
|
+
baseDir: path.resolve(manifest.artifactsRoot, "shared"),
|
|
295
|
+
tee: { fullOutputPath: teePath },
|
|
296
|
+
});
|
|
297
|
+
if (teeResult === undefined) return { name, path: filePath, content: "" };
|
|
298
|
+
const entry: { name: string; path: string; content: string; fullOutputPath?: string } = {
|
|
299
|
+
name,
|
|
300
|
+
path: filePath,
|
|
301
|
+
content: teeResult.content,
|
|
302
|
+
};
|
|
303
|
+
if (teeResult.fullOutputPath) entry.fullOutputPath = teeResult.fullOutputPath;
|
|
304
|
+
return entry;
|
|
133
305
|
}).filter((item) => item.content.trim().length > 0);
|
|
306
|
+
// Apply staleness-aware pruning to shared reads: drops superseded reads
|
|
307
|
+
// (same file re-read with different selectors) and replaces stale large
|
|
308
|
+
// outputs with compact digest notices before injecting into the worker
|
|
309
|
+
// prompt. OPT-IN: default config protects recent results.
|
|
310
|
+
const sharedReads = pruneSharedReads(rawSharedReads, dependencies, manifest.artifactsRoot);
|
|
134
311
|
return { dependencies, sharedReads };
|
|
135
312
|
}
|
|
136
313
|
|
|
@@ -147,7 +324,15 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
|
|
|
147
324
|
}
|
|
148
325
|
if (context.sharedReads.length) {
|
|
149
326
|
parts.push("# Shared Run Context Reads", "");
|
|
150
|
-
for (const read of context.sharedReads)
|
|
327
|
+
for (const read of context.sharedReads) {
|
|
328
|
+
parts.push(`## shared/${read.name}`, `Path: ${read.path}`);
|
|
329
|
+
// P1-A tee-recovery hint: when the file was materially truncated
|
|
330
|
+
// (>2× threshold) the full content was teed to fullOutputPath so the
|
|
331
|
+
// worker can read the dropped middle if needed. The path is inside
|
|
332
|
+
// artifactsRoot/tee/ and goes through the normal permission gate.
|
|
333
|
+
if (read.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
|
|
334
|
+
parts.push("", read.content.trim(), "");
|
|
335
|
+
}
|
|
151
336
|
}
|
|
152
337
|
return parts.join("\n").trim();
|
|
153
338
|
}
|
|
@@ -763,7 +763,30 @@ export async function runTeamTask(
|
|
|
763
763
|
"",
|
|
764
764
|
);
|
|
765
765
|
if (!error) break;
|
|
766
|
-
|
|
766
|
+
let nextModel = attemptModels[i + 1];
|
|
767
|
+
// FIX 1 (task packet 01_01-agent): when the precomputed attempt
|
|
768
|
+
// chain is exhausted but the failure is retryable, do a one-shot
|
|
769
|
+
// re-resolve via buildConfiguredModelRouting with the failed
|
|
770
|
+
// model as parent. This finds alternative providers/models the
|
|
771
|
+
// original chain missed (e.g. a registry gained new fallbacks
|
|
772
|
+
// after the precompute, or the precompute ran before the parent
|
|
773
|
+
// model was known). If a different candidate is found, use it as
|
|
774
|
+
// nextModel; otherwise fall through to the existing break.
|
|
775
|
+
if (!nextModel && isRetryableModelFailure(error)) {
|
|
776
|
+
const reResolved = buildConfiguredModelRouting({
|
|
777
|
+
overrideModel: undefined,
|
|
778
|
+
stepModel: undefined,
|
|
779
|
+
teamRoleModel: undefined,
|
|
780
|
+
agentModel: undefined,
|
|
781
|
+
fallbackModels: undefined,
|
|
782
|
+
parentModel: attempt.model,
|
|
783
|
+
modelRegistry: input.modelRegistry,
|
|
784
|
+
cwd: task.cwd,
|
|
785
|
+
scopeModelsPatterns: await resolveTaskScopeModelsPatterns(task.cwd),
|
|
786
|
+
});
|
|
787
|
+
const alt = reResolved.candidates.find((c) => c !== attempt.model);
|
|
788
|
+
if (alt) nextModel = alt;
|
|
789
|
+
}
|
|
767
790
|
if (!nextModel || !isRetryableModelFailure(error)) break;
|
|
768
791
|
logs.push(formatModelAttemptNote(attempt, nextModel), "");
|
|
769
792
|
}
|
|
@@ -1368,19 +1391,57 @@ async function resolveTaskScopeModelsPatterns(cwd: string): Promise<string[]> {
|
|
|
1368
1391
|
* or when there are no retryable error messages.
|
|
1369
1392
|
*/
|
|
1370
1393
|
export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput): string | undefined {
|
|
1394
|
+
// Primary signal: pre-extracted `errorMessages` (from pi-json-output parser).
|
|
1395
|
+
// The parser already filters to non-empty trimmed strings from message_end
|
|
1396
|
+
// events.
|
|
1371
1397
|
const messages = parsed.errorMessages;
|
|
1372
|
-
if (
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1398
|
+
if (messages && messages.length > 0) {
|
|
1399
|
+
// Find the first retryable model-failure message
|
|
1400
|
+
// (429 / rate-limit / overloaded / 5xx / ...).
|
|
1401
|
+
const retryable = messages.find((m) => isRetryableModelFailure(m));
|
|
1402
|
+
if (retryable) {
|
|
1403
|
+
// Did the run actually produce real output despite the transient errors?
|
|
1404
|
+
// If finalText / textEvents / patches exist, the model recovered and we
|
|
1405
|
+
// should NOT mark the run as failed — only flag it when the worker
|
|
1406
|
+
// yielded nothing (the 429-only case from the bug report).
|
|
1407
|
+
const hasRealOutput =
|
|
1408
|
+
(parsed.finalText?.trim().length ?? 0) > 0 ||
|
|
1409
|
+
parsed.textEvents.some((t) => t.trim().length > 0) ||
|
|
1410
|
+
(parsed.patches?.length ?? 0) > 0;
|
|
1411
|
+
if (hasRealOutput) return undefined;
|
|
1412
|
+
return `Model returned only retryable errors and no output: ${retryable}`;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// Secondary signal (FIX 3, task packet 01_01-agent): inspect a raw
|
|
1416
|
+
// `messageEndEvents` (or `transcript`) array on the parsed output. The
|
|
1417
|
+
// ParsedPiJsonOutput type does not currently declare this field, so we
|
|
1418
|
+
// read it through a local extension cast. Callers that pass it (tests, a
|
|
1419
|
+
// future parser that captures the full event stream) get a second chance
|
|
1420
|
+
// to surface retryable failures. Primary path still wins when it matches.
|
|
1421
|
+
const raw = parsed as ParsedPiJsonOutput & {
|
|
1422
|
+
messageEndEvents?: unknown;
|
|
1423
|
+
transcript?: unknown;
|
|
1424
|
+
};
|
|
1425
|
+
const eventSource = Array.isArray(raw.messageEndEvents)
|
|
1426
|
+
? raw.messageEndEvents
|
|
1427
|
+
: Array.isArray(raw.transcript)
|
|
1428
|
+
? raw.transcript
|
|
1429
|
+
: undefined;
|
|
1430
|
+
if (!eventSource || eventSource.length === 0) return undefined;
|
|
1431
|
+
for (const candidate of eventSource) {
|
|
1432
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
1433
|
+
const event = candidate as { stopReason?: unknown; errorMessage?: unknown };
|
|
1434
|
+
if (event.stopReason !== "error") continue;
|
|
1435
|
+
if (typeof event.errorMessage !== "string" || event.errorMessage.length === 0) continue;
|
|
1436
|
+
if (!isRetryableModelFailure(event.errorMessage)) continue;
|
|
1437
|
+
// Same real-output gate as the primary signal — don't flag runs that
|
|
1438
|
+
// recovered with real final text / patches.
|
|
1439
|
+
const hasRealOutput =
|
|
1440
|
+
(parsed.finalText?.trim().length ?? 0) > 0 ||
|
|
1441
|
+
parsed.textEvents.some((t) => t.trim().length > 0) ||
|
|
1442
|
+
(parsed.patches?.length ?? 0) > 0;
|
|
1443
|
+
if (hasRealOutput) return undefined;
|
|
1444
|
+
return `Model returned only retryable errors and no output: ${event.errorMessage}`;
|
|
1445
|
+
}
|
|
1446
|
+
return undefined;
|
|
1386
1447
|
}
|