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