pi-background-tasks 0.6.0 → 0.7.2
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/PUBLISHING.md +15 -15
- package/README.md +78 -6
- package/TESTING.md +28 -12
- package/TEST_PLAN.md +21 -12
- package/extensions/background-tasks.ts +1 -1
- package/extensions/fusion-child.ts +1 -0
- package/package.json +16 -10
- package/src/core/attested-pi-run.ts +619 -0
- package/src/core/common.ts +599 -432
- package/src/core/extension-api.ts +548 -0
- package/src/core/fusion/artifacts.ts +453 -0
- package/src/core/fusion/config.ts +371 -0
- package/src/core/fusion/context.ts +179 -0
- package/src/core/fusion/evaluation.ts +362 -0
- package/src/core/fusion/orchestrator.ts +595 -0
- package/src/core/fusion/pi-child.ts +900 -0
- package/src/core/fusion/prompts.ts +155 -0
- package/src/core/fusion/types.ts +289 -0
- package/src/core/registry.ts +1352 -786
- package/src/core/update-check.ts +69 -63
- package/src/extension.ts +880 -524
- package/src/fusion-child-extension.ts +100 -0
- package/src/fusion-extension.ts +632 -0
- package/src/testing/normalize.ts +22 -3
- package/src/ui/background-tasks-manager.ts +703 -613
- package/src/ui/fusion-model-selector.ts +322 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
FUSION_CHILD_RESULT_PREFIX,
|
|
8
|
+
FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
9
|
+
type FusionChildResultMetadata,
|
|
10
|
+
} from '../../fusion-child-extension.js';
|
|
11
|
+
import type { ResolvedFusionModel } from './types.js';
|
|
12
|
+
import {
|
|
13
|
+
FusionError,
|
|
14
|
+
type FusionChildRunResult,
|
|
15
|
+
type FusionErrorDetails,
|
|
16
|
+
type FusionStage,
|
|
17
|
+
type FusionUsage,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
import { isJsonObject, parseJsonText } from '../common.js';
|
|
20
|
+
|
|
21
|
+
// The response cap now applies to one final full answer, not cumulative Pi JSON events.
|
|
22
|
+
export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
24
|
+
export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
25
|
+
export const FUSION_CHILD_KILL_GRACE_MS = 3000;
|
|
26
|
+
export const FUSION_CHILD_SIGKILL_WAIT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
export const FUSION_CHILD_REMOVED_ENV_KEYS = [
|
|
29
|
+
'PI_SESSION_ID',
|
|
30
|
+
'PI_SESSION_FILE',
|
|
31
|
+
'PI_PROVIDER',
|
|
32
|
+
'PI_MODEL',
|
|
33
|
+
'PI_REASONING_LEVEL',
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
interface FusionReadableStream {
|
|
37
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
38
|
+
off(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FusionWritableStream {
|
|
42
|
+
write(data: Buffer, callback: (error?: Error | null) => void): boolean;
|
|
43
|
+
end(callback?: () => void): unknown;
|
|
44
|
+
once(event: 'error', listener: (error: Error) => void): unknown;
|
|
45
|
+
off(event: 'error', listener: (error: Error) => void): unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FusionChildProcess {
|
|
49
|
+
pid?: number | undefined;
|
|
50
|
+
stdin?: FusionWritableStream | null | undefined;
|
|
51
|
+
stdout?: FusionReadableStream | null | undefined;
|
|
52
|
+
stderr?: FusionReadableStream | null | undefined;
|
|
53
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
54
|
+
once(event: 'error', listener: (error: Error) => void): unknown;
|
|
55
|
+
once(
|
|
56
|
+
event: 'close',
|
|
57
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
58
|
+
): unknown;
|
|
59
|
+
off(event: 'error', listener: (error: Error) => void): unknown;
|
|
60
|
+
off(
|
|
61
|
+
event: 'close',
|
|
62
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
63
|
+
): unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type FusionChildSpawn = (
|
|
67
|
+
command: string,
|
|
68
|
+
args: string[],
|
|
69
|
+
options: SpawnOptions,
|
|
70
|
+
) => FusionChildProcess;
|
|
71
|
+
|
|
72
|
+
export type FusionKillProcess = (pid: number, signal?: NodeJS.Signals | number) => boolean;
|
|
73
|
+
|
|
74
|
+
export interface RunPiChildOptions {
|
|
75
|
+
stage: FusionStage;
|
|
76
|
+
slot?: 1 | 2 | 3;
|
|
77
|
+
attempt: number;
|
|
78
|
+
cwd: string;
|
|
79
|
+
model: ResolvedFusionModel;
|
|
80
|
+
systemPrompt: string;
|
|
81
|
+
userPrompt: string;
|
|
82
|
+
signal?: AbortSignal | undefined;
|
|
83
|
+
spawn?: FusionChildSpawn | undefined;
|
|
84
|
+
killProcess?: FusionKillProcess | undefined;
|
|
85
|
+
platform?: NodeJS.Platform | undefined;
|
|
86
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
87
|
+
stdoutLimitBytes?: number | undefined;
|
|
88
|
+
childExtensionPath?: string | undefined;
|
|
89
|
+
stderrLimitBytes?: number | undefined;
|
|
90
|
+
timeoutMs?: number | undefined;
|
|
91
|
+
killGraceMs?: number | undefined;
|
|
92
|
+
sigkillWaitMs?: number | undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface CloseRecord {
|
|
96
|
+
code: number | null;
|
|
97
|
+
signal: NodeJS.Signals | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ProcessState {
|
|
101
|
+
primaryError: FusionError | undefined;
|
|
102
|
+
cleanupErrors: string[];
|
|
103
|
+
terminationStarted: boolean;
|
|
104
|
+
termTimer: NodeJS.Timeout | undefined;
|
|
105
|
+
waitTimer: NodeJS.Timeout | undefined;
|
|
106
|
+
timeoutTimer: NodeJS.Timeout | undefined;
|
|
107
|
+
settled: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ObservedChildSnapshot {
|
|
111
|
+
usage: FusionUsage;
|
|
112
|
+
provider?: string;
|
|
113
|
+
model?: string;
|
|
114
|
+
qualifiedId?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class FusionChildRunError extends FusionError {
|
|
118
|
+
readonly events: Buffer;
|
|
119
|
+
readonly response: Buffer;
|
|
120
|
+
readonly stderr: Buffer;
|
|
121
|
+
readonly exitCode: number | null;
|
|
122
|
+
readonly signalName: NodeJS.Signals | null;
|
|
123
|
+
readonly usage: FusionUsage;
|
|
124
|
+
readonly provider: string | undefined;
|
|
125
|
+
readonly modelName: string | undefined;
|
|
126
|
+
readonly qualifiedId: string | undefined;
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
error: FusionError,
|
|
130
|
+
events: Buffer,
|
|
131
|
+
response: Buffer,
|
|
132
|
+
stderr: Buffer,
|
|
133
|
+
close: CloseRecord,
|
|
134
|
+
observed: ObservedChildSnapshot,
|
|
135
|
+
) {
|
|
136
|
+
const details: FusionErrorDetails = {
|
|
137
|
+
code: error.code,
|
|
138
|
+
transient: error.transient,
|
|
139
|
+
childCreated: error.childCreated,
|
|
140
|
+
};
|
|
141
|
+
if (error.stage !== undefined) details.stage = error.stage;
|
|
142
|
+
if (error.slot !== undefined) details.slot = error.slot;
|
|
143
|
+
if (error.attempt !== undefined) details.attempt = error.attempt;
|
|
144
|
+
if (error.artifactDir !== undefined) details.artifactDir = error.artifactDir;
|
|
145
|
+
super(error.message, details);
|
|
146
|
+
this.name = 'FusionChildRunError';
|
|
147
|
+
this.events = events;
|
|
148
|
+
this.response = response;
|
|
149
|
+
this.stderr = stderr;
|
|
150
|
+
this.exitCode = close.code;
|
|
151
|
+
this.signalName = close.signal;
|
|
152
|
+
this.usage = { ...observed.usage };
|
|
153
|
+
this.provider = observed.provider;
|
|
154
|
+
this.modelName = observed.model;
|
|
155
|
+
this.qualifiedId = observed.qualifiedId;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function fusionPiChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
160
|
+
const out: NodeJS.ProcessEnv = { ...env };
|
|
161
|
+
for (const key of FUSION_CHILD_REMOVED_ENV_KEYS) Reflect.deleteProperty(out, key);
|
|
162
|
+
out['PI_SKIP_VERSION_CHECK'] = '1';
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function resolveFusionChildExtensionPath(
|
|
167
|
+
moduleUrl = import.meta.url,
|
|
168
|
+
pathExists: (path: string) => boolean = existsSync,
|
|
169
|
+
): string {
|
|
170
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
171
|
+
const extension = modulePath.endsWith('.ts') ? 'fusion-child.ts' : 'fusion-child.js';
|
|
172
|
+
const candidate = resolve(dirname(modulePath), '../../../extensions', extension);
|
|
173
|
+
if (!pathExists(candidate)) {
|
|
174
|
+
throw new Error(`Fusion child metadata extension is missing: ${candidate}`);
|
|
175
|
+
}
|
|
176
|
+
return candidate;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildFusionPiChildArgv(
|
|
180
|
+
model: ResolvedFusionModel,
|
|
181
|
+
systemPrompt: string,
|
|
182
|
+
childExtensionPath = resolveFusionChildExtensionPath(),
|
|
183
|
+
): string[] {
|
|
184
|
+
return [
|
|
185
|
+
'--mode',
|
|
186
|
+
'text',
|
|
187
|
+
'--no-session',
|
|
188
|
+
'--no-tools',
|
|
189
|
+
'--no-extensions',
|
|
190
|
+
'--no-skills',
|
|
191
|
+
'--no-prompt-templates',
|
|
192
|
+
'--no-themes',
|
|
193
|
+
'--no-context-files',
|
|
194
|
+
'--extension',
|
|
195
|
+
childExtensionPath,
|
|
196
|
+
'--provider',
|
|
197
|
+
model.provider,
|
|
198
|
+
'--model',
|
|
199
|
+
model.model,
|
|
200
|
+
'--thinking',
|
|
201
|
+
model.thinkingLevel,
|
|
202
|
+
'--system-prompt',
|
|
203
|
+
systemPrompt,
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function addUsage(target: FusionUsage, delta: FusionUsage): void {
|
|
208
|
+
target.input += delta.input;
|
|
209
|
+
target.output += delta.output;
|
|
210
|
+
target.cacheRead += delta.cacheRead;
|
|
211
|
+
target.cacheWrite += delta.cacheWrite;
|
|
212
|
+
target.totalTokens += delta.totalTokens;
|
|
213
|
+
if (delta.costTotal !== undefined) target.costTotal = (target.costTotal ?? 0) + delta.costTotal;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
217
|
+
const FUSION_CHILD_RESULT_PREFIX_BYTES = Buffer.from(FUSION_CHILD_RESULT_PREFIX, 'utf8');
|
|
218
|
+
|
|
219
|
+
interface ParsedFusionChildStderr {
|
|
220
|
+
records: FusionChildResultMetadata[];
|
|
221
|
+
events: Buffer;
|
|
222
|
+
diagnostics: Buffer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function assertClosedRecord(
|
|
226
|
+
value: unknown,
|
|
227
|
+
keys: readonly string[],
|
|
228
|
+
label: string,
|
|
229
|
+
): Record<PropertyKey, unknown> {
|
|
230
|
+
if (!isJsonObject(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
231
|
+
const actual = Object.keys(value).sort();
|
|
232
|
+
const expected = [...keys].sort();
|
|
233
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
234
|
+
throw new Error(`${label} keys mismatch: expected ${expected.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function requireNonBlankString(
|
|
240
|
+
record: Record<PropertyKey, unknown>,
|
|
241
|
+
key: string,
|
|
242
|
+
label: string,
|
|
243
|
+
): string {
|
|
244
|
+
const value = record[key];
|
|
245
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
246
|
+
throw new Error(`${label}.${key} must be a non-blank string`);
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function requireSha256(record: Record<PropertyKey, unknown>, key: string, label: string): string {
|
|
251
|
+
const value = record[key];
|
|
252
|
+
if (typeof value !== 'string' || !SHA256_HEX_PATTERN.test(value))
|
|
253
|
+
throw new Error(`${label}.${key} must be a lowercase SHA-256 hex digest`);
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function requireUsageInteger(
|
|
258
|
+
record: Record<PropertyKey, unknown>,
|
|
259
|
+
key: string,
|
|
260
|
+
label: string,
|
|
261
|
+
): number {
|
|
262
|
+
const value = record[key];
|
|
263
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
|
264
|
+
throw new Error(`${label}.${key} must be a non-negative safe integer`);
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseCompactUsage(value: unknown): FusionUsage {
|
|
269
|
+
if (!isJsonObject(value) || Array.isArray(value))
|
|
270
|
+
throw new Error('fusion child usage must be an object');
|
|
271
|
+
const allowed = new Set(['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens', 'costTotal']);
|
|
272
|
+
for (const key of Object.keys(value)) {
|
|
273
|
+
if (!allowed.has(key)) throw new Error(`fusion child usage contains unknown key ${key}`);
|
|
274
|
+
}
|
|
275
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens']) {
|
|
276
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
277
|
+
throw new Error(`fusion child usage is missing key ${key}`);
|
|
278
|
+
}
|
|
279
|
+
const record = value;
|
|
280
|
+
const usage: FusionUsage = {
|
|
281
|
+
input: requireUsageInteger(record, 'input', 'fusion child usage'),
|
|
282
|
+
output: requireUsageInteger(record, 'output', 'fusion child usage'),
|
|
283
|
+
cacheRead: requireUsageInteger(record, 'cacheRead', 'fusion child usage'),
|
|
284
|
+
cacheWrite: requireUsageInteger(record, 'cacheWrite', 'fusion child usage'),
|
|
285
|
+
totalTokens: requireUsageInteger(record, 'totalTokens', 'fusion child usage'),
|
|
286
|
+
};
|
|
287
|
+
const costTotal = record['costTotal'];
|
|
288
|
+
if (costTotal !== undefined) {
|
|
289
|
+
if (typeof costTotal !== 'number' || !Number.isFinite(costTotal) || costTotal < 0)
|
|
290
|
+
throw new Error('fusion child usage.costTotal must be a non-negative finite number');
|
|
291
|
+
usage.costTotal = costTotal;
|
|
292
|
+
}
|
|
293
|
+
return usage;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseChildResultMetadata(value: unknown): FusionChildResultMetadata {
|
|
297
|
+
const record = assertClosedRecord(
|
|
298
|
+
value,
|
|
299
|
+
['schema_version', 'provider', 'model', 'stop_reason', 'text_blocks', 'text_sha256', 'usage'],
|
|
300
|
+
'fusion child result',
|
|
301
|
+
);
|
|
302
|
+
if (record['schema_version'] !== FUSION_CHILD_RESULT_SCHEMA_VERSION)
|
|
303
|
+
throw new Error('fusion child result schema_version mismatch');
|
|
304
|
+
const textBlocksValue = record['text_blocks'];
|
|
305
|
+
if (!Array.isArray(textBlocksValue))
|
|
306
|
+
throw new Error('fusion child result.text_blocks must be an array');
|
|
307
|
+
const textBlocks = textBlocksValue.map((value, index) => {
|
|
308
|
+
const label = `fusion child result.text_blocks[${String(index)}]`;
|
|
309
|
+
const block = assertClosedRecord(value, ['utf8_bytes', 'sha256'], label);
|
|
310
|
+
return {
|
|
311
|
+
utf8_bytes: requireUsageInteger(block, 'utf8_bytes', label),
|
|
312
|
+
sha256: requireSha256(block, 'sha256', label),
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
const usage = parseCompactUsage(record['usage']);
|
|
316
|
+
return {
|
|
317
|
+
schema_version: FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
318
|
+
provider: requireNonBlankString(record, 'provider', 'fusion child result'),
|
|
319
|
+
model: requireNonBlankString(record, 'model', 'fusion child result'),
|
|
320
|
+
stop_reason: requireNonBlankString(record, 'stop_reason', 'fusion child result'),
|
|
321
|
+
text_blocks: textBlocks,
|
|
322
|
+
text_sha256: requireSha256(record, 'text_sha256', 'fusion child result'),
|
|
323
|
+
usage,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function parseFusionChildStderr(stderr: Buffer): ParsedFusionChildStderr {
|
|
328
|
+
const records: FusionChildResultMetadata[] = [];
|
|
329
|
+
const diagnostics: Buffer[] = [];
|
|
330
|
+
let cursor = 0;
|
|
331
|
+
for (;;) {
|
|
332
|
+
const frameStart = stderr.indexOf(FUSION_CHILD_RESULT_PREFIX_BYTES, cursor);
|
|
333
|
+
if (frameStart < 0) {
|
|
334
|
+
if (cursor < stderr.length) diagnostics.push(stderr.subarray(cursor));
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
if (frameStart > cursor) diagnostics.push(stderr.subarray(cursor, frameStart));
|
|
338
|
+
const payloadStart = frameStart + FUSION_CHILD_RESULT_PREFIX_BYTES.length;
|
|
339
|
+
const newline = stderr.indexOf(10, payloadStart);
|
|
340
|
+
if (newline < 0) throw new Error('fusion child metadata frame is not newline-terminated');
|
|
341
|
+
const payloadBytes = stderr.subarray(payloadStart, newline);
|
|
342
|
+
const payloadText = payloadBytes.toString('utf8');
|
|
343
|
+
if (!Buffer.from(payloadText, 'utf8').equals(payloadBytes))
|
|
344
|
+
throw new Error('fusion child metadata frame is not valid UTF-8');
|
|
345
|
+
let parsed: unknown;
|
|
346
|
+
try {
|
|
347
|
+
parsed = parseJsonText(payloadText);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`fusion child metadata frame is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
records.push(parseChildResultMetadata(parsed));
|
|
354
|
+
cursor = newline + 1;
|
|
355
|
+
}
|
|
356
|
+
const events = Buffer.from(
|
|
357
|
+
records.length === 0 ? '' : `${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
|
358
|
+
'utf8',
|
|
359
|
+
);
|
|
360
|
+
return { records, events, diagnostics: Buffer.concat(diagnostics) };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function sha256Buffer(bytes: Buffer): string {
|
|
364
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function reconstructFinalText(response: Buffer, record: FusionChildResultMetadata): string {
|
|
368
|
+
const blocks: Buffer[] = [];
|
|
369
|
+
let cursor = 0;
|
|
370
|
+
for (const [index, block] of record.text_blocks.entries()) {
|
|
371
|
+
const end = cursor + block.utf8_bytes;
|
|
372
|
+
if (end > response.length)
|
|
373
|
+
throw new Error(`Pi final text block ${String(index)} is shorter than its metadata length`);
|
|
374
|
+
const bytes = response.subarray(cursor, end);
|
|
375
|
+
if (sha256Buffer(bytes) !== block.sha256)
|
|
376
|
+
throw new Error(`Pi final text block ${String(index)} hash mismatch`);
|
|
377
|
+
blocks.push(bytes);
|
|
378
|
+
if (response.at(end) !== 10)
|
|
379
|
+
throw new Error(`Pi final text block ${String(index)} lacks its print-mode newline`);
|
|
380
|
+
cursor = end + 1;
|
|
381
|
+
}
|
|
382
|
+
if (cursor !== response.length)
|
|
383
|
+
throw new Error('Pi final text stdout contains bytes outside declared text blocks');
|
|
384
|
+
const joined = Buffer.concat(blocks);
|
|
385
|
+
if (sha256Buffer(joined) !== record.text_sha256)
|
|
386
|
+
throw new Error('Pi final text aggregate hash mismatch');
|
|
387
|
+
const text = joined.toString('utf8');
|
|
388
|
+
if (!Buffer.from(text, 'utf8').equals(joined)) throw new Error('Pi final text is not valid UTF-8');
|
|
389
|
+
if (text.trim().length === 0) throw new Error('Pi assistant response is empty');
|
|
390
|
+
return text;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export class FusionPiCompactResultParser {
|
|
394
|
+
private readonly expectedProvider: string;
|
|
395
|
+
private readonly expectedModel: string;
|
|
396
|
+
|
|
397
|
+
constructor(expectedProvider: string, expectedModel: string) {
|
|
398
|
+
this.expectedProvider = expectedProvider;
|
|
399
|
+
this.expectedModel = expectedModel;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
snapshot(stderr: Buffer): ObservedChildSnapshot {
|
|
403
|
+
try {
|
|
404
|
+
const parsed = parseFusionChildStderr(stderr);
|
|
405
|
+
return this.observedFromRecords(parsed.records);
|
|
406
|
+
} catch {
|
|
407
|
+
return { usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
finish(response: Buffer, stderr: Buffer): {
|
|
412
|
+
text: string;
|
|
413
|
+
usage: FusionUsage;
|
|
414
|
+
provider: string;
|
|
415
|
+
model: string;
|
|
416
|
+
qualifiedId: string;
|
|
417
|
+
events: Buffer;
|
|
418
|
+
diagnostics: Buffer;
|
|
419
|
+
} {
|
|
420
|
+
const parsed = parseFusionChildStderr(stderr);
|
|
421
|
+
const final = parsed.records.at(-1);
|
|
422
|
+
if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
|
|
423
|
+
for (const record of parsed.records) this.assertModel(record);
|
|
424
|
+
if (final.stop_reason !== 'stop')
|
|
425
|
+
throw new Error(`Pi final stop reason is not stop: ${final.stop_reason}`);
|
|
426
|
+
const observed = this.observedFromRecords(parsed.records);
|
|
427
|
+
return {
|
|
428
|
+
text: reconstructFinalText(response, final),
|
|
429
|
+
usage: observed.usage,
|
|
430
|
+
provider: final.provider,
|
|
431
|
+
model: final.model,
|
|
432
|
+
qualifiedId: `${final.provider}/${final.model}`,
|
|
433
|
+
events: parsed.events,
|
|
434
|
+
diagnostics: parsed.diagnostics,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private assertModel(record: FusionChildResultMetadata): void {
|
|
439
|
+
if (record.provider !== this.expectedProvider || record.model !== this.expectedModel) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Pi assistant model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${record.provider}/${record.model}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private observedFromRecords(records: readonly FusionChildResultMetadata[]): ObservedChildSnapshot {
|
|
447
|
+
const usage: FusionUsage = {
|
|
448
|
+
input: 0,
|
|
449
|
+
output: 0,
|
|
450
|
+
cacheRead: 0,
|
|
451
|
+
cacheWrite: 0,
|
|
452
|
+
totalTokens: 0,
|
|
453
|
+
};
|
|
454
|
+
for (const record of records) addUsage(usage, record.usage);
|
|
455
|
+
const final = records.at(-1);
|
|
456
|
+
if (final === undefined) return { usage };
|
|
457
|
+
return {
|
|
458
|
+
usage,
|
|
459
|
+
provider: final.provider,
|
|
460
|
+
model: final.model,
|
|
461
|
+
qualifiedId: `${final.provider}/${final.model}`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function appendCapped(
|
|
467
|
+
chunks: Buffer[],
|
|
468
|
+
currentBytes: number,
|
|
469
|
+
chunk: Buffer,
|
|
470
|
+
limit: number,
|
|
471
|
+
): { bytes: number; accepted: Buffer; exceeded: boolean } {
|
|
472
|
+
if (currentBytes >= limit)
|
|
473
|
+
return { bytes: currentBytes, accepted: Buffer.alloc(0), exceeded: true };
|
|
474
|
+
const remaining = limit - currentBytes;
|
|
475
|
+
if (chunk.length <= remaining) {
|
|
476
|
+
chunks.push(chunk);
|
|
477
|
+
return { bytes: currentBytes + chunk.length, accepted: chunk, exceeded: false };
|
|
478
|
+
}
|
|
479
|
+
const accepted = chunk.subarray(0, remaining);
|
|
480
|
+
if (accepted.length > 0) chunks.push(accepted);
|
|
481
|
+
return { bytes: limit, accepted, exceeded: true };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function codeOf(error: unknown): string | undefined {
|
|
485
|
+
return isJsonObject(error) && typeof error['code'] === 'string' ? error['code'] : undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isTransientSpawnCode(code: string | undefined): boolean {
|
|
489
|
+
return code === 'EAGAIN' || code === 'EMFILE' || code === 'ENFILE';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function childError(
|
|
493
|
+
message: string,
|
|
494
|
+
code: FusionError['code'],
|
|
495
|
+
input: Pick<RunPiChildOptions, 'stage' | 'slot' | 'attempt'>,
|
|
496
|
+
transient = false,
|
|
497
|
+
childCreated = true,
|
|
498
|
+
): FusionError {
|
|
499
|
+
const details: FusionErrorDetails = {
|
|
500
|
+
code,
|
|
501
|
+
stage: input.stage,
|
|
502
|
+
attempt: input.attempt,
|
|
503
|
+
transient,
|
|
504
|
+
childCreated,
|
|
505
|
+
};
|
|
506
|
+
if (input.slot !== undefined) details.slot = input.slot;
|
|
507
|
+
return new FusionError(message, details);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function withCleanupErrors(error: FusionError, cleanupErrors: readonly string[]): FusionError {
|
|
511
|
+
if (cleanupErrors.length === 0) return error;
|
|
512
|
+
const details: FusionErrorDetails = {
|
|
513
|
+
code: error.code,
|
|
514
|
+
transient: error.transient,
|
|
515
|
+
childCreated: error.childCreated,
|
|
516
|
+
};
|
|
517
|
+
if (error.stage !== undefined) details.stage = error.stage;
|
|
518
|
+
if (error.slot !== undefined) details.slot = error.slot;
|
|
519
|
+
if (error.attempt !== undefined) details.attempt = error.attempt;
|
|
520
|
+
if (error.artifactDir !== undefined) details.artifactDir = error.artifactDir;
|
|
521
|
+
return new FusionError(
|
|
522
|
+
`${error.message}; process cleanup issues: ${cleanupErrors.join('; ')}`,
|
|
523
|
+
details,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function defaultSpawn(command: string, args: string[], options: SpawnOptions): FusionChildProcess {
|
|
528
|
+
return nodeSpawn(command, args, options);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function setUnref(timer: NodeJS.Timeout): NodeJS.Timeout {
|
|
532
|
+
timer.unref();
|
|
533
|
+
return timer;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function rememberCleanupErrors(
|
|
537
|
+
state: ProcessState,
|
|
538
|
+
signal: NodeJS.Signals,
|
|
539
|
+
errors: readonly string[],
|
|
540
|
+
): void {
|
|
541
|
+
for (const error of errors) state.cleanupErrors.push(`${signal}: ${error}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function terminateChild(
|
|
545
|
+
child: FusionChildProcess,
|
|
546
|
+
state: ProcessState,
|
|
547
|
+
platform: NodeJS.Platform,
|
|
548
|
+
killProcess: FusionKillProcess,
|
|
549
|
+
killGraceMs: number,
|
|
550
|
+
sigkillWaitMs: number,
|
|
551
|
+
settleSyntheticClose: (close: CloseRecord) => void,
|
|
552
|
+
): void {
|
|
553
|
+
if (state.settled || state.terminationStarted) return;
|
|
554
|
+
state.terminationStarted = true;
|
|
555
|
+
const termResult = sendSignal(child, platform, killProcess, 'SIGTERM');
|
|
556
|
+
rememberCleanupErrors(state, 'SIGTERM', termResult.errors);
|
|
557
|
+
if (!termResult.sent && state.primaryError === undefined) {
|
|
558
|
+
state.primaryError = new FusionError(
|
|
559
|
+
`Pi child SIGTERM failed: ${termResult.errors.join('; ')}`,
|
|
560
|
+
{
|
|
561
|
+
code: 'child_exit_failed',
|
|
562
|
+
childCreated: true,
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
state.termTimer = setUnref(
|
|
567
|
+
setTimeout(() => {
|
|
568
|
+
if (state.settled) return;
|
|
569
|
+
const killResult = sendSignal(child, platform, killProcess, 'SIGKILL');
|
|
570
|
+
rememberCleanupErrors(state, 'SIGKILL', killResult.errors);
|
|
571
|
+
if (!killResult.sent && state.primaryError === undefined) {
|
|
572
|
+
state.primaryError = new FusionError(
|
|
573
|
+
`Pi child SIGKILL failed: ${killResult.errors.join('; ')}`,
|
|
574
|
+
{
|
|
575
|
+
code: 'child_exit_failed',
|
|
576
|
+
childCreated: true,
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}, killGraceMs),
|
|
581
|
+
);
|
|
582
|
+
state.waitTimer = setUnref(
|
|
583
|
+
setTimeout(() => {
|
|
584
|
+
if (state.settled) return;
|
|
585
|
+
const message = 'Pi child did not emit close after SIGKILL wait';
|
|
586
|
+
state.cleanupErrors.push(message);
|
|
587
|
+
if (state.primaryError === undefined) {
|
|
588
|
+
state.primaryError = new FusionError(message, {
|
|
589
|
+
code: 'child_exit_failed',
|
|
590
|
+
childCreated: true,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
settleSyntheticClose({ code: null, signal: 'SIGKILL' });
|
|
594
|
+
}, killGraceMs + sigkillWaitMs),
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function sendSignal(
|
|
599
|
+
child: FusionChildProcess,
|
|
600
|
+
platform: NodeJS.Platform,
|
|
601
|
+
killProcess: FusionKillProcess,
|
|
602
|
+
signal: NodeJS.Signals,
|
|
603
|
+
): { sent: boolean; errors: readonly string[] } {
|
|
604
|
+
const errors: string[] = [];
|
|
605
|
+
const pid = child.pid;
|
|
606
|
+
if (platform !== 'win32' && pid !== undefined) {
|
|
607
|
+
try {
|
|
608
|
+
if (killProcess(-pid, signal)) return { sent: true, errors };
|
|
609
|
+
errors.push('process group kill returned false');
|
|
610
|
+
} catch (error) {
|
|
611
|
+
errors.push(
|
|
612
|
+
`process group kill failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
if (child.kill(signal)) return { sent: true, errors };
|
|
618
|
+
errors.push('child kill returned false');
|
|
619
|
+
} catch (error) {
|
|
620
|
+
errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
621
|
+
}
|
|
622
|
+
return { sent: false, errors };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function cleanupTimers(state: ProcessState): void {
|
|
626
|
+
if (state.termTimer !== undefined) clearTimeout(state.termTimer);
|
|
627
|
+
if (state.waitTimer !== undefined) clearTimeout(state.waitTimer);
|
|
628
|
+
if (state.timeoutTimer !== undefined) clearTimeout(state.timeoutTimer);
|
|
629
|
+
state.termTimer = undefined;
|
|
630
|
+
state.waitTimer = undefined;
|
|
631
|
+
state.timeoutTimer = undefined;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function writePromptToStdin(child: FusionChildProcess, prompt: string): Promise<void> {
|
|
635
|
+
const stdin = child.stdin;
|
|
636
|
+
if (stdin === undefined || stdin === null) throw new Error('Pi child stdin pipe is unavailable');
|
|
637
|
+
await new Promise<void>((resolve, reject) => {
|
|
638
|
+
let settled = false;
|
|
639
|
+
const fail = (error: Error) => {
|
|
640
|
+
if (settled) return;
|
|
641
|
+
settled = true;
|
|
642
|
+
stdin.off('error', fail);
|
|
643
|
+
reject(error);
|
|
644
|
+
};
|
|
645
|
+
const finish = () => {
|
|
646
|
+
if (settled) return;
|
|
647
|
+
settled = true;
|
|
648
|
+
stdin.off('error', fail);
|
|
649
|
+
resolve();
|
|
650
|
+
};
|
|
651
|
+
stdin.once('error', fail);
|
|
652
|
+
stdin.write(Buffer.from(prompt, 'utf8'), (error?: Error | null) => {
|
|
653
|
+
if (error !== undefined && error !== null) {
|
|
654
|
+
fail(error);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
stdin.end(finish);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function runPiChild(options: RunPiChildOptions): Promise<FusionChildRunResult> {
|
|
663
|
+
if (options.signal?.aborted) {
|
|
664
|
+
throw childError(
|
|
665
|
+
'Pi child launch cancelled before spawn',
|
|
666
|
+
'child_cancelled',
|
|
667
|
+
options,
|
|
668
|
+
false,
|
|
669
|
+
false,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
const spawnImpl = options.spawn ?? defaultSpawn;
|
|
673
|
+
const killProcess = options.killProcess ?? process.kill.bind(process);
|
|
674
|
+
const platform = options.platform ?? process.platform;
|
|
675
|
+
const env = fusionPiChildEnv(options.env ?? process.env);
|
|
676
|
+
const stdoutLimit = options.stdoutLimitBytes ?? FUSION_CHILD_STDOUT_LIMIT_BYTES;
|
|
677
|
+
const stderrLimit = options.stderrLimitBytes ?? FUSION_CHILD_STDERR_LIMIT_BYTES;
|
|
678
|
+
const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
|
|
679
|
+
const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
|
|
680
|
+
const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
|
|
681
|
+
const argv = buildFusionPiChildArgv(
|
|
682
|
+
options.model,
|
|
683
|
+
options.systemPrompt,
|
|
684
|
+
options.childExtensionPath ?? resolveFusionChildExtensionPath(),
|
|
685
|
+
);
|
|
686
|
+
const parser = new FusionPiCompactResultParser(options.model.provider, options.model.model);
|
|
687
|
+
const stdoutChunks: Buffer[] = [];
|
|
688
|
+
const stderrChunks: Buffer[] = [];
|
|
689
|
+
let stdoutBytes = 0;
|
|
690
|
+
let stderrBytes = 0;
|
|
691
|
+
const state: ProcessState = {
|
|
692
|
+
primaryError: undefined,
|
|
693
|
+
cleanupErrors: [],
|
|
694
|
+
terminationStarted: false,
|
|
695
|
+
termTimer: undefined,
|
|
696
|
+
waitTimer: undefined,
|
|
697
|
+
timeoutTimer: undefined,
|
|
698
|
+
settled: false,
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
let child: FusionChildProcess;
|
|
702
|
+
try {
|
|
703
|
+
child = spawnImpl('pi', argv, {
|
|
704
|
+
cwd: options.cwd,
|
|
705
|
+
detached: platform !== 'win32',
|
|
706
|
+
shell: false,
|
|
707
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
708
|
+
env,
|
|
709
|
+
windowsHide: true,
|
|
710
|
+
});
|
|
711
|
+
} catch (error) {
|
|
712
|
+
const code = codeOf(error);
|
|
713
|
+
throw childError(
|
|
714
|
+
`Pi child spawn failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
715
|
+
'child_spawn_failed',
|
|
716
|
+
options,
|
|
717
|
+
isTransientSpawnCode(code),
|
|
718
|
+
false,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let settleClose: (close: CloseRecord) => void = () => undefined;
|
|
723
|
+
const closePromise = new Promise<CloseRecord>((resolve) => {
|
|
724
|
+
settleClose = (close) => {
|
|
725
|
+
if (state.settled) return;
|
|
726
|
+
state.settled = true;
|
|
727
|
+
resolve(close);
|
|
728
|
+
};
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const abortListener = () => {
|
|
732
|
+
if (state.settled) return;
|
|
733
|
+
if (state.primaryError === undefined) {
|
|
734
|
+
state.primaryError = childError('Pi child cancelled', 'child_cancelled', options);
|
|
735
|
+
}
|
|
736
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
737
|
+
};
|
|
738
|
+
const stdoutListener = (data: Buffer | string) => {
|
|
739
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
740
|
+
const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
|
|
741
|
+
stdoutBytes = appended.bytes;
|
|
742
|
+
if (appended.exceeded && state.primaryError === undefined) {
|
|
743
|
+
state.primaryError = childError(
|
|
744
|
+
`Pi child final response exceeded ${String(stdoutLimit)} bytes`,
|
|
745
|
+
'child_output_cap',
|
|
746
|
+
options,
|
|
747
|
+
);
|
|
748
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
const stderrListener = (data: Buffer | string) => {
|
|
752
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
753
|
+
const appended = appendCapped(stderrChunks, stderrBytes, chunk, stderrLimit);
|
|
754
|
+
stderrBytes = appended.bytes;
|
|
755
|
+
if (appended.exceeded && state.primaryError === undefined) {
|
|
756
|
+
state.primaryError = childError(
|
|
757
|
+
`Pi child stderr exceeded ${String(stderrLimit)} bytes`,
|
|
758
|
+
'child_output_cap',
|
|
759
|
+
options,
|
|
760
|
+
);
|
|
761
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
const errorListener = (error: Error) => {
|
|
765
|
+
if (state.settled) return;
|
|
766
|
+
if (state.primaryError === undefined) {
|
|
767
|
+
const code = codeOf(error);
|
|
768
|
+
const childCreated = child.pid !== undefined;
|
|
769
|
+
state.primaryError = childError(
|
|
770
|
+
`Pi child process error: ${error.message}`,
|
|
771
|
+
'child_spawn_failed',
|
|
772
|
+
options,
|
|
773
|
+
isTransientSpawnCode(code),
|
|
774
|
+
childCreated,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (child.pid === undefined) settleClose({ code: null, signal: null });
|
|
778
|
+
};
|
|
779
|
+
const closeListener = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
780
|
+
settleClose({ code, signal });
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
child.stdout?.on('data', stdoutListener);
|
|
784
|
+
child.stderr?.on('data', stderrListener);
|
|
785
|
+
child.once('error', errorListener);
|
|
786
|
+
child.once('close', closeListener);
|
|
787
|
+
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
788
|
+
if (options.signal?.aborted) abortListener();
|
|
789
|
+
state.timeoutTimer = setUnref(
|
|
790
|
+
setTimeout(() => {
|
|
791
|
+
if (state.primaryError === undefined) {
|
|
792
|
+
state.primaryError = childError(
|
|
793
|
+
`Pi child timed out after ${String(timeoutMs)}ms`,
|
|
794
|
+
'child_timeout',
|
|
795
|
+
options,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
799
|
+
}, timeoutMs),
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
try {
|
|
804
|
+
if (state.primaryError === undefined) await writePromptToStdin(child, options.userPrompt);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
if (state.primaryError === undefined) {
|
|
807
|
+
state.primaryError = childError(
|
|
808
|
+
`Pi child stdin write failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
809
|
+
'child_stdin_failed',
|
|
810
|
+
options,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
terminateChild(child, state, platform, killProcess, killGraceMs, sigkillWaitMs, settleClose);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const close = await closePromise;
|
|
817
|
+
const response = Buffer.concat(stdoutChunks);
|
|
818
|
+
const rawStderr = Buffer.concat(stderrChunks);
|
|
819
|
+
const observed = parser.snapshot(rawStderr);
|
|
820
|
+
let compactEvents: Buffer = Buffer.alloc(0);
|
|
821
|
+
let diagnostics: Buffer = rawStderr;
|
|
822
|
+
try {
|
|
823
|
+
const decoded = parseFusionChildStderr(rawStderr);
|
|
824
|
+
compactEvents = decoded.events;
|
|
825
|
+
diagnostics = decoded.diagnostics;
|
|
826
|
+
} catch {
|
|
827
|
+
// A primary process/cap error remains authoritative; malformed metadata is
|
|
828
|
+
// surfaced below when the child otherwise exits successfully.
|
|
829
|
+
}
|
|
830
|
+
const primary = state.primaryError;
|
|
831
|
+
if (primary !== undefined)
|
|
832
|
+
throw new FusionChildRunError(
|
|
833
|
+
withCleanupErrors(primary, state.cleanupErrors),
|
|
834
|
+
compactEvents,
|
|
835
|
+
response,
|
|
836
|
+
diagnostics,
|
|
837
|
+
close,
|
|
838
|
+
observed,
|
|
839
|
+
);
|
|
840
|
+
if (close.code !== 0 || close.signal !== null) {
|
|
841
|
+
throw new FusionChildRunError(
|
|
842
|
+
withCleanupErrors(
|
|
843
|
+
childError(
|
|
844
|
+
`Pi child exited with code ${close.code === null ? 'null' : String(close.code)}${close.signal === null ? '' : ` (${close.signal})`}`,
|
|
845
|
+
'child_exit_failed',
|
|
846
|
+
options,
|
|
847
|
+
),
|
|
848
|
+
state.cleanupErrors,
|
|
849
|
+
),
|
|
850
|
+
compactEvents,
|
|
851
|
+
response,
|
|
852
|
+
diagnostics,
|
|
853
|
+
close,
|
|
854
|
+
observed,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
let parsed: ReturnType<FusionPiCompactResultParser['finish']>;
|
|
858
|
+
try {
|
|
859
|
+
parsed = parser.finish(response, rawStderr);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
throw new FusionChildRunError(
|
|
862
|
+
withCleanupErrors(
|
|
863
|
+
childError(
|
|
864
|
+
`Pi child compact result invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
865
|
+
'child_event_invalid',
|
|
866
|
+
options,
|
|
867
|
+
),
|
|
868
|
+
state.cleanupErrors,
|
|
869
|
+
),
|
|
870
|
+
compactEvents,
|
|
871
|
+
response,
|
|
872
|
+
diagnostics,
|
|
873
|
+
close,
|
|
874
|
+
observed,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
const result: FusionChildRunResult = {
|
|
878
|
+
stage: options.stage,
|
|
879
|
+
attempt: options.attempt,
|
|
880
|
+
provider: parsed.provider,
|
|
881
|
+
model: parsed.model,
|
|
882
|
+
qualifiedId: parsed.qualifiedId,
|
|
883
|
+
text: parsed.text,
|
|
884
|
+
usage: parsed.usage,
|
|
885
|
+
events: parsed.events,
|
|
886
|
+
stderr: parsed.diagnostics,
|
|
887
|
+
exitCode: close.code,
|
|
888
|
+
signal: close.signal,
|
|
889
|
+
};
|
|
890
|
+
if (options.slot !== undefined) result.slot = options.slot;
|
|
891
|
+
return result;
|
|
892
|
+
} finally {
|
|
893
|
+
cleanupTimers(state);
|
|
894
|
+
options.signal?.removeEventListener('abort', abortListener);
|
|
895
|
+
child.stdout?.off('data', stdoutListener);
|
|
896
|
+
child.stderr?.off('data', stderrListener);
|
|
897
|
+
child.off('error', errorListener);
|
|
898
|
+
child.off('close', closeListener);
|
|
899
|
+
}
|
|
900
|
+
}
|