pi-crew 0.9.62 → 0.9.64
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 +92 -0
- package/README.md +1 -0
- package/agents/critic.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +162 -67
- package/package.json +5 -2
- package/src/agents/agent-config.ts +4 -0
- package/src/agents/agent-serializer.ts +1 -0
- package/src/agents/discover-agents.ts +8 -0
- package/src/config/role-tools.ts +49 -1
- package/src/prompt/prompt-runtime.ts +6 -0
- package/src/prompt/scratchpad-lifecycle.ts +605 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
- package/src/runtime/child-pi/child-pi.ts +5 -0
- package/src/runtime/model/pi-args.ts +1 -1
- package/src/runtime/recovery/crash-recovery.ts +1 -1
- package/src/runtime/scratchpad/README.md +184 -0
- package/src/runtime/scratchpad/engine.ts +610 -0
- package/src/runtime/scratchpad/guest.ts +360 -0
- package/src/runtime/scratchpad/index.ts +22 -0
- package/src/runtime/scratchpad/protocol.ts +88 -0
- package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
- package/src/runtime/scratchpad/transform.ts +363 -0
- package/src/runtime/task-runner/child-executor.ts +48 -31
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cell transform: TypeScript cell → sloppy-mode async body run under
|
|
3
|
+
* `with (proxy)` against the persistent namespace.
|
|
4
|
+
*
|
|
5
|
+
* - Types are stripped with esbuild's transform (pi-crew's runtime
|
|
6
|
+
* dependency; Bun.Transpiler is not available here). esbuild does not run
|
|
7
|
+
* dead-code elimination in transform mode, so side-effect-free trailing
|
|
8
|
+
* expressions — exactly what this transform captures as the cell result —
|
|
9
|
+
* survive untouched.
|
|
10
|
+
* - Top-level imports become awaited dynamic imports, since the body is a
|
|
11
|
+
* function body rather than a module. They are rewritten from the ORIGINAL
|
|
12
|
+
* source before type stripping: esbuild elides unused imports, and the
|
|
13
|
+
* reference behaviour binds every imported name into the namespace so it
|
|
14
|
+
* persists across cells. (The pipeline's ImportDeclaration branch is kept
|
|
15
|
+
* for fidelity and as a fallback for any import that survives.)
|
|
16
|
+
* - Top-level declarations become plain assignments, so each binding reaches
|
|
17
|
+
* the namespace at its own statement site. Two behaviours depend on this:
|
|
18
|
+
* a closure must observe later rebinding of a name, and names bound before a
|
|
19
|
+
* cell throws or is cancelled must survive. Copying the bindings out once at
|
|
20
|
+
* the end of the cell delivers neither — that copy never runs on the paths
|
|
21
|
+
* that matter.
|
|
22
|
+
* - A trailing expression statement is captured as the cell result.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ClassDeclaration, FunctionDeclaration, ImportDeclaration, Node, Pattern, Program, VariableDeclaration } from "acorn";
|
|
26
|
+
import { parse, tokenizer } from "acorn";
|
|
27
|
+
import { transformSync } from "esbuild";
|
|
28
|
+
|
|
29
|
+
export interface TransformedCell {
|
|
30
|
+
/** Body statements to run inside the async `with` wrapper. */
|
|
31
|
+
body: string;
|
|
32
|
+
/** Top-level names this cell binds into the namespace. */
|
|
33
|
+
declaredNames: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TransformOptions {
|
|
37
|
+
/** Identifier the wrapper binds the cell context to. */
|
|
38
|
+
ctxName?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// esbuild's transform strips types but never drops side-effect-free trailing
|
|
42
|
+
// expressions (no DCE in transform mode) — the exact thing we capture as the
|
|
43
|
+
// cell result.
|
|
44
|
+
function stripTypes(code: string): string {
|
|
45
|
+
return transformSync(code, { loader: "ts" }).code;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── top-level import extraction ──────────────────────────────────────────────
|
|
49
|
+
// esbuild elides unused imports during transform, which would silently drop
|
|
50
|
+
// `import { a } from "m"` cells that bind a name without using it in the same
|
|
51
|
+
// cell (names must persist across cells). So imports are rewritten into
|
|
52
|
+
// awaited dynamic imports BEFORE type stripping, using acorn's tokenizer to
|
|
53
|
+
// find them robustly (strings, regexes, templates, and dynamic import() /
|
|
54
|
+
// import.meta never produce a false statement).
|
|
55
|
+
|
|
56
|
+
interface LexToken {
|
|
57
|
+
label: string;
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
/** Raw source text (token.value is the parsed value; the raw slice is what
|
|
61
|
+
* the fragment rebuild needs). */
|
|
62
|
+
value: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ImportStatement {
|
|
66
|
+
/** Start of the `import` keyword. */
|
|
67
|
+
start: number;
|
|
68
|
+
/** End of the statement (module specifier, or trailing `;` when present). */
|
|
69
|
+
end: number;
|
|
70
|
+
/** First and last token indices (inclusive) in the global token array. */
|
|
71
|
+
firstToken: number;
|
|
72
|
+
lastToken: number;
|
|
73
|
+
/** Whole-statement `import type …` — erased, binds nothing at runtime. */
|
|
74
|
+
typeOnly: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function lexTopLevel(code: string): LexToken[] {
|
|
78
|
+
const tokens: LexToken[] = [];
|
|
79
|
+
const tok = tokenizer(code, { ecmaVersion: "latest" });
|
|
80
|
+
let current: { type: { label: string }; start: number; end: number };
|
|
81
|
+
while ((current = tok.getToken() as { type: { label: string }; start: number; end: number }).type.label !== "eof") {
|
|
82
|
+
tokens.push({
|
|
83
|
+
label: String(current.type.label),
|
|
84
|
+
start: current.start,
|
|
85
|
+
end: current.end,
|
|
86
|
+
value: code.slice(current.start, current.end),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return tokens;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findImportStatements(tokens: LexToken[]): ImportStatement[] {
|
|
93
|
+
const found: ImportStatement[] = [];
|
|
94
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
95
|
+
if (tokens[i].label !== "import") continue;
|
|
96
|
+
const prev = i > 0 ? tokens[i - 1].label : null;
|
|
97
|
+
const next = i + 1 < tokens.length ? tokens[i + 1].label : null;
|
|
98
|
+
// Dynamic import(...), import.meta, and property access `x.import` are
|
|
99
|
+
// expressions, not statements.
|
|
100
|
+
if (prev === "." || next === null || next === "(" || next === ".") continue;
|
|
101
|
+
|
|
102
|
+
const j = i + 1;
|
|
103
|
+
let lastToken = -1;
|
|
104
|
+
let typeOnly = false;
|
|
105
|
+
// Whole-statement `import type …` — `type` followed by a string, `{`,
|
|
106
|
+
// or `*` (a default binding named `type` is followed by `from`).
|
|
107
|
+
if (tokens[j].label === "name" && tokens[j].value === "type") {
|
|
108
|
+
const after = j + 1 < tokens.length ? tokens[j + 1].label : null;
|
|
109
|
+
if (after === "string" || after === "{" || after === "*") typeOnly = true;
|
|
110
|
+
}
|
|
111
|
+
if (tokens[j].label === "string") {
|
|
112
|
+
// Side-effect import: `import "mod";`
|
|
113
|
+
lastToken = j;
|
|
114
|
+
} else {
|
|
115
|
+
// Scan for `from` at bracket depth 0, then the module specifier.
|
|
116
|
+
let depth = 0;
|
|
117
|
+
for (let k = j; k < tokens.length; k++) {
|
|
118
|
+
const label = tokens[k].label;
|
|
119
|
+
if (label === "{" || label === "(" || label === "[") depth++;
|
|
120
|
+
else if (label === "}" || label === ")" || label === "]") depth--;
|
|
121
|
+
else if (depth === 0 && label === "name" && tokens[k].value === "from") {
|
|
122
|
+
const spec = k + 1;
|
|
123
|
+
if (spec < tokens.length && tokens[spec].label === "string") lastToken = spec;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (lastToken === -1) continue; // malformed — leave for the pipeline to surface
|
|
129
|
+
// Consume an optional trailing semicolon into the statement.
|
|
130
|
+
const afterString = lastToken + 1;
|
|
131
|
+
if (afterString < tokens.length && tokens[afterString].label === ";") lastToken = afterString;
|
|
132
|
+
|
|
133
|
+
found.push({ start: tokens[i].start, end: tokens[lastToken].end, firstToken: i, lastToken, typeOnly });
|
|
134
|
+
}
|
|
135
|
+
return found;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Token indices to drop from a named-import fragment for inline `type`
|
|
140
|
+
* specifiers: `import { type X, join } from "m"` → `import { join } from "m"`.
|
|
141
|
+
* The skipped specifier (plus its optional `as` alias) has no runtime binding,
|
|
142
|
+
* exactly like TypeScript's own type-specifier erasure.
|
|
143
|
+
*/
|
|
144
|
+
function inlineTypeSkipIndices(tokens: LexToken[], firstToken: number, lastToken: number): Set<number> {
|
|
145
|
+
const skip = new Set<number>();
|
|
146
|
+
let depth = 0;
|
|
147
|
+
for (let k = firstToken + 1; k <= lastToken; k++) {
|
|
148
|
+
const tk = tokens[k];
|
|
149
|
+
if (tk.label === "{") depth++;
|
|
150
|
+
else if (tk.label === "}") depth--;
|
|
151
|
+
if (depth < 1) continue;
|
|
152
|
+
// Only a `type` followed by a binding name is a modifier; `{ type }` and
|
|
153
|
+
// `{ type as x }` import a binding literally named `type`.
|
|
154
|
+
if (tk.label !== "name" || tk.value !== "type") continue;
|
|
155
|
+
const nxt = k + 1 <= lastToken ? tokens[k + 1] : undefined;
|
|
156
|
+
if (nxt?.label !== "name" || nxt.value === "as") continue;
|
|
157
|
+
skip.add(k);
|
|
158
|
+
skip.add(k + 1);
|
|
159
|
+
let m = k + 2;
|
|
160
|
+
// `type X as Y`
|
|
161
|
+
if (m <= lastToken && tokens[m].label === "name" && tokens[m].value === "as") {
|
|
162
|
+
skip.add(m);
|
|
163
|
+
if (m + 1 <= lastToken) skip.add(m + 1);
|
|
164
|
+
m += 2;
|
|
165
|
+
}
|
|
166
|
+
// Drop the separator only when more specifiers follow; a trailing comma
|
|
167
|
+
// after the last kept specifier stays valid.
|
|
168
|
+
if (m <= lastToken && tokens[m].label === ",") {
|
|
169
|
+
const after = m + 1 <= lastToken ? tokens[m + 1] : undefined;
|
|
170
|
+
if (after && after.label !== "}") skip.add(m);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return skip;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function importFragment(code: string, stmt: ImportStatement, tokens: LexToken[], skip: Set<number>): string {
|
|
177
|
+
if (skip.size === 0) return code.slice(stmt.start, stmt.end);
|
|
178
|
+
// Rebuild without the skipped tokens; whitespace/comments are dropped but
|
|
179
|
+
// single spaces keep the fragment parseable.
|
|
180
|
+
const parts: string[] = [];
|
|
181
|
+
for (let k = stmt.firstToken; k <= stmt.lastToken; k++) {
|
|
182
|
+
if (skip.has(k)) continue;
|
|
183
|
+
parts.push(tokens[k].value);
|
|
184
|
+
}
|
|
185
|
+
return parts.join(" ");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function rewriteImport(code: string, stmt: ImportStatement, tokens: LexToken[]): { replacement: string; declaredNames: string[] } {
|
|
189
|
+
if (stmt.typeOnly) return { replacement: "", declaredNames: [] };
|
|
190
|
+
const skip = inlineTypeSkipIndices(tokens, stmt.firstToken, stmt.lastToken);
|
|
191
|
+
const fragment = importFragment(code, stmt, tokens, skip);
|
|
192
|
+
let node: ImportDeclaration;
|
|
193
|
+
try {
|
|
194
|
+
const program: Program = parse(fragment, { ecmaVersion: "latest", sourceType: "module" });
|
|
195
|
+
node = program.body[0] as ImportDeclaration;
|
|
196
|
+
} catch {
|
|
197
|
+
// Unparseable — leave the original text for the pipeline to surface.
|
|
198
|
+
return { replacement: code.slice(stmt.start, stmt.end), declaredNames: [] };
|
|
199
|
+
}
|
|
200
|
+
const declaredNames: string[] = [];
|
|
201
|
+
for (const spec of node.specifiers) declaredNames.push(spec.local.name);
|
|
202
|
+
// The block wrapper keeps the assignment from being mistaken for the cell's
|
|
203
|
+
// trailing expression: the trailing-expression capture must not treat an
|
|
204
|
+
// import's dynamic-import assignment as a result.
|
|
205
|
+
return { replacement: `{ ${importReplacement(node)} }`, declaredNames };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectPatternNames(pattern: Pattern, into: string[]): void {
|
|
209
|
+
switch (pattern.type) {
|
|
210
|
+
case "Identifier":
|
|
211
|
+
into.push(pattern.name);
|
|
212
|
+
break;
|
|
213
|
+
case "ObjectPattern":
|
|
214
|
+
for (const prop of pattern.properties) {
|
|
215
|
+
if (prop.type === "RestElement") collectPatternNames(prop.argument, into);
|
|
216
|
+
else collectPatternNames(prop.value, into);
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
case "ArrayPattern":
|
|
220
|
+
for (const element of pattern.elements) if (element) collectPatternNames(element, into);
|
|
221
|
+
break;
|
|
222
|
+
case "AssignmentPattern":
|
|
223
|
+
collectPatternNames(pattern.left, into);
|
|
224
|
+
break;
|
|
225
|
+
case "RestElement":
|
|
226
|
+
collectPatternNames(pattern.argument, into);
|
|
227
|
+
break;
|
|
228
|
+
default:
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function importReplacement(node: ImportDeclaration): string {
|
|
234
|
+
const moduleText = JSON.stringify(String(node.source.value));
|
|
235
|
+
const namespaceSpecifier = node.specifiers.find((s) => s.type === "ImportNamespaceSpecifier");
|
|
236
|
+
const defaultSpecifier = node.specifiers.find((s) => s.type === "ImportDefaultSpecifier");
|
|
237
|
+
const namedSpecifiers = node.specifiers.filter((s) => s.type === "ImportSpecifier");
|
|
238
|
+
|
|
239
|
+
// Assignments, not declarations: imported bindings must land in the
|
|
240
|
+
// namespace so they persist across cells like any other name.
|
|
241
|
+
const parts: string[] = [];
|
|
242
|
+
if (namespaceSpecifier) parts.push(`${namespaceSpecifier.local.name} = await import(${moduleText});`); // LAZY: codegen emits guest-cell import syntax, not a pi-crew dynamic import
|
|
243
|
+
const destructured: string[] = [];
|
|
244
|
+
if (defaultSpecifier) destructured.push(`default: ${defaultSpecifier.local.name}`);
|
|
245
|
+
for (const spec of namedSpecifiers) {
|
|
246
|
+
const imported = spec.imported.type === "Identifier" ? spec.imported.name : String(spec.imported.value);
|
|
247
|
+
destructured.push(imported === spec.local.name ? imported : `${JSON.stringify(imported)}: ${spec.local.name}`);
|
|
248
|
+
}
|
|
249
|
+
if (destructured.length > 0) parts.push(`({ ${destructured.join(", ")} } = await import(${moduleText}));`); // LAZY: codegen emits guest-cell import syntax
|
|
250
|
+
if (parts.length === 0) parts.push(`await import(${moduleText});`); // LAZY: codegen emits guest-cell import syntax
|
|
251
|
+
return parts.join(" ");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Rewrite `let/const/var` into assignments so each binding reaches the
|
|
256
|
+
* namespace as it executes. Patterns keep their shape; object patterns need
|
|
257
|
+
* parentheses to stay expressions.
|
|
258
|
+
*/
|
|
259
|
+
function variableReplacement(decl: VariableDeclaration, source: string): string {
|
|
260
|
+
const statements: string[] = [];
|
|
261
|
+
for (const declarator of decl.declarations) {
|
|
262
|
+
const target = source.slice(declarator.id.start, declarator.id.end);
|
|
263
|
+
if (!declarator.init) {
|
|
264
|
+
// `let x;` — bind the name so later reads resolve.
|
|
265
|
+
statements.push(`${target} = undefined;`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const init = source.slice(declarator.init.start, declarator.init.end);
|
|
269
|
+
statements.push(declarator.id.type === "ObjectPattern" ? `(${target} = ${init});` : `${target} = ${init};`);
|
|
270
|
+
}
|
|
271
|
+
return statements.join(" ");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function transformCell(code: string, options: TransformOptions = {}): TransformedCell {
|
|
275
|
+
const ctxName = options.ctxName ?? "__ctx";
|
|
276
|
+
|
|
277
|
+
// Top-level imports are rewritten into awaited dynamic imports BEFORE type
|
|
278
|
+
// stripping: esbuild elides unused imports, and the reference behaviour
|
|
279
|
+
// binds every imported name into the namespace so it persists across cells.
|
|
280
|
+
let importDeclared: string[] = [];
|
|
281
|
+
let rewritten = code;
|
|
282
|
+
try {
|
|
283
|
+
const tokens = lexTopLevel(code);
|
|
284
|
+
const imports = findImportStatements(tokens);
|
|
285
|
+
if (imports.length > 0) {
|
|
286
|
+
importDeclared = [];
|
|
287
|
+
const pieces: string[] = [];
|
|
288
|
+
let cursor = 0;
|
|
289
|
+
for (const stmt of imports) {
|
|
290
|
+
pieces.push(rewritten.slice(cursor, stmt.start));
|
|
291
|
+
const rewrite = rewriteImport(code, stmt, tokens);
|
|
292
|
+
importDeclared.push(...rewrite.declaredNames);
|
|
293
|
+
pieces.push(rewrite.replacement);
|
|
294
|
+
cursor = stmt.end;
|
|
295
|
+
}
|
|
296
|
+
pieces.push(rewritten.slice(cursor));
|
|
297
|
+
rewritten = pieces.join("");
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Tokenizing the original source failed (genuine syntax error). Skip
|
|
301
|
+
// the pre-rewrite; type stripping below surfaces the error.
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const js = stripTypes(rewritten);
|
|
305
|
+
const program: Program = parse(js, { ecmaVersion: "latest", sourceType: "module", allowAwaitOutsideFunction: true });
|
|
306
|
+
|
|
307
|
+
const declaredNames: string[] = [];
|
|
308
|
+
const replacements: { start: number; end: number; text: string }[] = [];
|
|
309
|
+
const topLevel = program.body;
|
|
310
|
+
|
|
311
|
+
for (const node of topLevel) {
|
|
312
|
+
switch (node.type) {
|
|
313
|
+
case "ImportDeclaration": {
|
|
314
|
+
const decl = node as ImportDeclaration;
|
|
315
|
+
for (const spec of decl.specifiers) declaredNames.push(spec.local.name);
|
|
316
|
+
replacements.push({ start: decl.start, end: decl.end, text: importReplacement(decl) });
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case "ExportNamedDeclaration":
|
|
320
|
+
case "ExportDefaultDeclaration":
|
|
321
|
+
case "ExportAllDeclaration":
|
|
322
|
+
throw new SyntaxError("export statements are not supported in cells");
|
|
323
|
+
case "VariableDeclaration": {
|
|
324
|
+
const decl = node as VariableDeclaration;
|
|
325
|
+
for (const declarator of decl.declarations) collectPatternNames(declarator.id, declaredNames);
|
|
326
|
+
replacements.push({ start: decl.start, end: decl.end, text: variableReplacement(decl, js) });
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "FunctionDeclaration":
|
|
330
|
+
case "ClassDeclaration": {
|
|
331
|
+
const decl = node as FunctionDeclaration | ClassDeclaration;
|
|
332
|
+
if (!decl.id) break;
|
|
333
|
+
declaredNames.push(decl.id.name);
|
|
334
|
+
// A named function/class expression keeps self-reference (recursion)
|
|
335
|
+
// while making the binding proxy-backed and failure-surviving.
|
|
336
|
+
const sourceText = js.slice(decl.start, decl.end);
|
|
337
|
+
replacements.push({ start: decl.start, end: decl.end, text: `${decl.id.name} = ${sourceText};` });
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
default:
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Capture a trailing expression statement as the cell result.
|
|
346
|
+
const last = topLevel[topLevel.length - 1] as Node | undefined;
|
|
347
|
+
if (last && last.type === "ExpressionStatement") {
|
|
348
|
+
const expression = (last as unknown as { expression: Node }).expression;
|
|
349
|
+
const expressionText = js.slice(expression.start, expression.end);
|
|
350
|
+
replacements.push({ start: last.start, end: last.end, text: `${ctxName}.setResult((${expressionText}));` });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
replacements.sort((a, b) => a.start - b.start);
|
|
354
|
+
let body = "";
|
|
355
|
+
let cursor = 0;
|
|
356
|
+
for (const replacement of replacements) {
|
|
357
|
+
body += js.slice(cursor, replacement.start) + replacement.text;
|
|
358
|
+
cursor = replacement.end;
|
|
359
|
+
}
|
|
360
|
+
body += js.slice(cursor);
|
|
361
|
+
|
|
362
|
+
return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])] };
|
|
363
|
+
}
|
|
@@ -259,6 +259,51 @@ export function detectRetryableModelFailureFromOutput(parsed: ParsedPiJsonOutput
|
|
|
259
259
|
* UI event-bus handle (may be undefined).
|
|
260
260
|
* @returns The branch execution result.
|
|
261
261
|
*/
|
|
262
|
+
// Quick Win 11 (Pattern 11 — error-as-data contract): the per-attempt outcome
|
|
263
|
+
// assembly, extracted as PURE functions so the precedence contract is directly
|
|
264
|
+
// unit-testable. Logic is identical to the former inline blocks; runChildProcessTask
|
|
265
|
+
// calls these instead of inlining. E008 (modelExhausted, post-loop) stays in the
|
|
266
|
+
// caller — putting it here would feed isRetryableModelFailure per-attempt and
|
|
267
|
+
// change the retry chain (MINOR-3).
|
|
268
|
+
//
|
|
269
|
+
// Precedence: evidenceStatus = cancelled > failed (error || non-zero exit) >
|
|
270
|
+
// completed. error = childResult.error > non-zero-exit message, THEN E007
|
|
271
|
+
// (timedOut) overrides unconditionally, THEN 429-detection only when !error.
|
|
272
|
+
|
|
273
|
+
export type AttemptEvidenceStatus = "cancelled" | "failed" | "completed";
|
|
274
|
+
|
|
275
|
+
export function evidenceStatusFor(childResult: ChildPiRunResult): AttemptEvidenceStatus {
|
|
276
|
+
return childResult.exitStatus?.cancelled
|
|
277
|
+
? "cancelled"
|
|
278
|
+
: childResult.error || (childResult.exitCode && childResult.exitCode !== 0)
|
|
279
|
+
? "failed"
|
|
280
|
+
: "completed";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function attemptErrorFor(
|
|
284
|
+
childResult: ChildPiRunResult,
|
|
285
|
+
parsedOutput: ParsedPiJsonOutput | undefined,
|
|
286
|
+
taskId: string,
|
|
287
|
+
): string | undefined {
|
|
288
|
+
let err: string | undefined =
|
|
289
|
+
childResult.error ||
|
|
290
|
+
(childResult.exitCode && childResult.exitCode !== 0
|
|
291
|
+
? childResult.stderr || `Child Pi exited with ${childResult.exitCode}`
|
|
292
|
+
: undefined);
|
|
293
|
+
// E1/E7: a child timeout surfaces a structured CrewError (E007) — unconditional
|
|
294
|
+
// override of any hard error.
|
|
295
|
+
if (childResult.exitStatus?.timedOut) {
|
|
296
|
+
err = errors.childTimeout({ taskId, stderr: childResult.stderr }).message;
|
|
297
|
+
}
|
|
298
|
+
// 429/rate-limit: only when no error was set above AND the transcript carries
|
|
299
|
+
// only retryable model-failure messages with no real output.
|
|
300
|
+
if (!err && parsedOutput) {
|
|
301
|
+
const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
|
|
302
|
+
if (rateLimitErr) err = rateLimitErr;
|
|
303
|
+
}
|
|
304
|
+
return err;
|
|
305
|
+
}
|
|
306
|
+
|
|
262
307
|
export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<TaskExecutionResult> {
|
|
263
308
|
const input = ctx.input;
|
|
264
309
|
const manifest: TeamRunManifest = ctx.manifest;
|
|
@@ -499,6 +544,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
499
544
|
runId: manifest.runId,
|
|
500
545
|
agentId: task.id,
|
|
501
546
|
artifactsRoot: manifest.artifactsRoot,
|
|
547
|
+
attempt: i,
|
|
502
548
|
steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
503
549
|
onSpawn: (pid) => {
|
|
504
550
|
try {
|
|
@@ -613,11 +659,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
613
659
|
input.signal.removeEventListener("abort", externalAbortListener);
|
|
614
660
|
}
|
|
615
661
|
}
|
|
616
|
-
const evidenceStatus = childResult
|
|
617
|
-
? "cancelled"
|
|
618
|
-
: childResult.error || (childResult.exitCode && childResult.exitCode !== 0)
|
|
619
|
-
? "failed"
|
|
620
|
-
: "completed";
|
|
662
|
+
const evidenceStatus = evidenceStatusFor(childResult);
|
|
621
663
|
terminalEvidence = [
|
|
622
664
|
...terminalEvidence,
|
|
623
665
|
{
|
|
@@ -668,32 +710,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
668
710
|
parsedOutput = parsePiJsonOutput(transcriptText);
|
|
669
711
|
rawFinalText = childResult.rawFinalText;
|
|
670
712
|
intermediateFindings = childResult.intermediateFindings;
|
|
671
|
-
error =
|
|
672
|
-
childResult.error ||
|
|
673
|
-
(childResult.exitCode && childResult.exitCode !== 0
|
|
674
|
-
? childResult.stderr || `Child Pi exited with ${childResult.exitCode}`
|
|
675
|
-
: undefined);
|
|
676
|
-
// E1/E7 (Round 15): when the child timed out, surface a structured
|
|
677
|
-
// CrewError (E007) so users get a code + actionable help hint instead
|
|
678
|
-
// of a bare 'no new output for N ms'. We keep .message as the task error.
|
|
679
|
-
if (childResult.exitStatus?.timedOut) {
|
|
680
|
-
error = errors.childTimeout({
|
|
681
|
-
taskId: task.id,
|
|
682
|
-
stderr: childResult.stderr,
|
|
683
|
-
}).message;
|
|
684
|
-
}
|
|
685
|
-
// 429/rate-limit fix (PI_CREW_TOOLING_429_NOTE.md): a worker can exit
|
|
686
|
-
// code 0 with NO hard error, but the transcript is full of
|
|
687
|
-
// `message_end` events with `errorMessage: "429 ... overloaded"` and
|
|
688
|
-
// empty content. The model never produced a tool call, so the worker
|
|
689
|
-
// "completed" without doing anything. Detect this: if no error was set
|
|
690
|
-
// above AND the parsed output carries a retryable model-failure message
|
|
691
|
-
// AND there is no real output text, surface it as an error so the
|
|
692
|
-
// model-fallback chain can retry on another model.
|
|
693
|
-
if (!error && parsedOutput) {
|
|
694
|
-
const rateLimitErr = detectRetryableModelFailureFromOutput(parsedOutput);
|
|
695
|
-
if (rateLimitErr) error = rateLimitErr;
|
|
696
|
-
}
|
|
713
|
+
error = attemptErrorFor(childResult, parsedOutput, task.id);
|
|
697
714
|
persistHeartbeat(true);
|
|
698
715
|
persistChildProgress({ type: "attempt_finished" }, true);
|
|
699
716
|
const attempt: ModelAttemptSummary = {
|