pi-background-tasks 0.7.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 +7 -7
- package/README.md +11 -7
- package/TESTING.md +4 -4
- package/TEST_PLAN.md +7 -7
- package/extensions/fusion-child.ts +1 -0
- package/package.json +1 -1
- package/src/core/common.ts +60 -0
- package/src/core/fusion/artifacts.ts +13 -3
- package/src/core/fusion/orchestrator.ts +4 -2
- package/src/core/fusion/pi-child.ts +285 -201
- package/src/core/fusion/types.ts +2 -1
- package/src/core/registry.ts +1 -0
- package/src/extension.ts +31 -14
- package/src/fusion-child-extension.ts +100 -0
- package/src/fusion-extension.ts +29 -13
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
-
import {
|
|
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';
|
|
3
11
|
import type { ResolvedFusionModel } from './types.js';
|
|
4
12
|
import {
|
|
5
13
|
FusionError,
|
|
@@ -10,6 +18,7 @@ import {
|
|
|
10
18
|
} from './types.js';
|
|
11
19
|
import { isJsonObject, parseJsonText } from '../common.js';
|
|
12
20
|
|
|
21
|
+
// The response cap now applies to one final full answer, not cumulative Pi JSON events.
|
|
13
22
|
export const FUSION_CHILD_STDOUT_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
14
23
|
export const FUSION_CHILD_STDERR_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
15
24
|
export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
@@ -76,6 +85,7 @@ export interface RunPiChildOptions {
|
|
|
76
85
|
platform?: NodeJS.Platform | undefined;
|
|
77
86
|
env?: NodeJS.ProcessEnv | undefined;
|
|
78
87
|
stdoutLimitBytes?: number | undefined;
|
|
88
|
+
childExtensionPath?: string | undefined;
|
|
79
89
|
stderrLimitBytes?: number | undefined;
|
|
80
90
|
timeoutMs?: number | undefined;
|
|
81
91
|
killGraceMs?: number | undefined;
|
|
@@ -105,7 +115,8 @@ interface ObservedChildSnapshot {
|
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
export class FusionChildRunError extends FusionError {
|
|
108
|
-
readonly
|
|
118
|
+
readonly events: Buffer;
|
|
119
|
+
readonly response: Buffer;
|
|
109
120
|
readonly stderr: Buffer;
|
|
110
121
|
readonly exitCode: number | null;
|
|
111
122
|
readonly signalName: NodeJS.Signals | null;
|
|
@@ -116,7 +127,8 @@ export class FusionChildRunError extends FusionError {
|
|
|
116
127
|
|
|
117
128
|
constructor(
|
|
118
129
|
error: FusionError,
|
|
119
|
-
|
|
130
|
+
events: Buffer,
|
|
131
|
+
response: Buffer,
|
|
120
132
|
stderr: Buffer,
|
|
121
133
|
close: CloseRecord,
|
|
122
134
|
observed: ObservedChildSnapshot,
|
|
@@ -132,7 +144,8 @@ export class FusionChildRunError extends FusionError {
|
|
|
132
144
|
if (error.artifactDir !== undefined) details.artifactDir = error.artifactDir;
|
|
133
145
|
super(error.message, details);
|
|
134
146
|
this.name = 'FusionChildRunError';
|
|
135
|
-
this.
|
|
147
|
+
this.events = events;
|
|
148
|
+
this.response = response;
|
|
136
149
|
this.stderr = stderr;
|
|
137
150
|
this.exitCode = close.code;
|
|
138
151
|
this.signalName = close.signal;
|
|
@@ -150,10 +163,27 @@ export function fusionPiChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.P
|
|
|
150
163
|
return out;
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
export function
|
|
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[] {
|
|
154
184
|
return [
|
|
155
185
|
'--mode',
|
|
156
|
-
'
|
|
186
|
+
'text',
|
|
157
187
|
'--no-session',
|
|
158
188
|
'--no-tools',
|
|
159
189
|
'--no-extensions',
|
|
@@ -161,6 +191,8 @@ export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt:
|
|
|
161
191
|
'--no-prompt-templates',
|
|
162
192
|
'--no-themes',
|
|
163
193
|
'--no-context-files',
|
|
194
|
+
'--extension',
|
|
195
|
+
childExtensionPath,
|
|
164
196
|
'--provider',
|
|
165
197
|
model.provider,
|
|
166
198
|
'--model',
|
|
@@ -172,207 +204,262 @@ export function buildFusionPiChildArgv(model: ResolvedFusionModel, systemPrompt:
|
|
|
172
204
|
];
|
|
173
205
|
}
|
|
174
206
|
|
|
175
|
-
function
|
|
176
|
-
|
|
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;
|
|
177
248
|
}
|
|
178
249
|
|
|
179
|
-
function
|
|
250
|
+
function requireSha256(record: Record<PropertyKey, unknown>, key: string, label: string): string {
|
|
180
251
|
const value = record[key];
|
|
181
|
-
|
|
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;
|
|
182
255
|
}
|
|
183
256
|
|
|
184
|
-
function
|
|
257
|
+
function requireUsageInteger(
|
|
185
258
|
record: Record<PropertyKey, unknown>,
|
|
186
259
|
key: string,
|
|
187
|
-
|
|
260
|
+
label: string,
|
|
261
|
+
): number {
|
|
188
262
|
const value = record[key];
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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;
|
|
202
292
|
}
|
|
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
293
|
return usage;
|
|
207
294
|
}
|
|
208
295
|
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
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
|
+
};
|
|
216
325
|
}
|
|
217
326
|
|
|
218
|
-
function
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for (
|
|
223
|
-
|
|
224
|
-
if (
|
|
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;
|
|
225
355
|
}
|
|
226
|
-
|
|
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');
|
|
227
365
|
}
|
|
228
366
|
|
|
229
|
-
|
|
230
|
-
|
|
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 {
|
|
231
394
|
private readonly expectedProvider: string;
|
|
232
395
|
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
396
|
|
|
252
397
|
constructor(expectedProvider: string, expectedModel: string) {
|
|
253
398
|
this.expectedProvider = expectedProvider;
|
|
254
399
|
this.expectedModel = expectedModel;
|
|
255
400
|
}
|
|
256
401
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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}`;
|
|
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 } };
|
|
271
408
|
}
|
|
272
|
-
return observed;
|
|
273
409
|
}
|
|
274
410
|
|
|
275
|
-
finish(): {
|
|
411
|
+
finish(response: Buffer, stderr: Buffer): {
|
|
276
412
|
text: string;
|
|
277
413
|
usage: FusionUsage;
|
|
278
414
|
provider: string;
|
|
279
415
|
model: string;
|
|
280
416
|
qualifiedId: string;
|
|
417
|
+
events: Buffer;
|
|
418
|
+
diagnostics: Buffer;
|
|
281
419
|
} {
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
throw new Error(
|
|
288
|
-
|
|
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');
|
|
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);
|
|
301
427
|
return {
|
|
302
|
-
text:
|
|
303
|
-
usage:
|
|
304
|
-
provider:
|
|
305
|
-
model:
|
|
306
|
-
qualifiedId: `${
|
|
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,
|
|
307
435
|
};
|
|
308
436
|
}
|
|
309
437
|
|
|
310
|
-
private
|
|
311
|
-
|
|
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) {
|
|
438
|
+
private assertModel(record: FusionChildResultMetadata): void {
|
|
439
|
+
if (record.provider !== this.expectedProvider || record.model !== this.expectedModel) {
|
|
326
440
|
throw new Error(
|
|
327
|
-
`Pi
|
|
441
|
+
`Pi assistant model mismatch: expected ${this.expectedProvider}/${this.expectedModel}, observed ${record.provider}/${record.model}`,
|
|
328
442
|
);
|
|
329
443
|
}
|
|
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
444
|
}
|
|
339
445
|
|
|
340
|
-
private
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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']));
|
|
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
|
+
};
|
|
376
463
|
}
|
|
377
464
|
}
|
|
378
465
|
|
|
@@ -591,8 +678,12 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
591
678
|
const timeoutMs = options.timeoutMs ?? FUSION_CHILD_TIMEOUT_MS;
|
|
592
679
|
const killGraceMs = options.killGraceMs ?? FUSION_CHILD_KILL_GRACE_MS;
|
|
593
680
|
const sigkillWaitMs = options.sigkillWaitMs ?? FUSION_CHILD_SIGKILL_WAIT_MS;
|
|
594
|
-
const argv = buildFusionPiChildArgv(
|
|
595
|
-
|
|
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);
|
|
596
687
|
const stdoutChunks: Buffer[] = [];
|
|
597
688
|
const stderrChunks: Buffer[] = [];
|
|
598
689
|
let stdoutBytes = 0;
|
|
@@ -648,29 +739,9 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
648
739
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
649
740
|
const appended = appendCapped(stdoutChunks, stdoutBytes, chunk, stdoutLimit);
|
|
650
741
|
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
742
|
if (appended.exceeded && state.primaryError === undefined) {
|
|
672
743
|
state.primaryError = childError(
|
|
673
|
-
`Pi child
|
|
744
|
+
`Pi child final response exceeded ${String(stdoutLimit)} bytes`,
|
|
674
745
|
'child_output_cap',
|
|
675
746
|
options,
|
|
676
747
|
);
|
|
@@ -743,15 +814,26 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
743
814
|
}
|
|
744
815
|
|
|
745
816
|
const close = await closePromise;
|
|
746
|
-
const
|
|
747
|
-
const
|
|
748
|
-
const observed = parser.snapshot();
|
|
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
|
+
}
|
|
749
830
|
const primary = state.primaryError;
|
|
750
831
|
if (primary !== undefined)
|
|
751
832
|
throw new FusionChildRunError(
|
|
752
833
|
withCleanupErrors(primary, state.cleanupErrors),
|
|
753
|
-
|
|
754
|
-
|
|
834
|
+
compactEvents,
|
|
835
|
+
response,
|
|
836
|
+
diagnostics,
|
|
755
837
|
close,
|
|
756
838
|
observed,
|
|
757
839
|
);
|
|
@@ -765,27 +847,29 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
765
847
|
),
|
|
766
848
|
state.cleanupErrors,
|
|
767
849
|
),
|
|
768
|
-
|
|
769
|
-
|
|
850
|
+
compactEvents,
|
|
851
|
+
response,
|
|
852
|
+
diagnostics,
|
|
770
853
|
close,
|
|
771
854
|
observed,
|
|
772
855
|
);
|
|
773
856
|
}
|
|
774
|
-
let parsed: ReturnType<
|
|
857
|
+
let parsed: ReturnType<FusionPiCompactResultParser['finish']>;
|
|
775
858
|
try {
|
|
776
|
-
parsed = parser.finish();
|
|
859
|
+
parsed = parser.finish(response, rawStderr);
|
|
777
860
|
} catch (error) {
|
|
778
861
|
throw new FusionChildRunError(
|
|
779
862
|
withCleanupErrors(
|
|
780
863
|
childError(
|
|
781
|
-
`Pi child
|
|
864
|
+
`Pi child compact result invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
782
865
|
'child_event_invalid',
|
|
783
866
|
options,
|
|
784
867
|
),
|
|
785
868
|
state.cleanupErrors,
|
|
786
869
|
),
|
|
787
|
-
|
|
788
|
-
|
|
870
|
+
compactEvents,
|
|
871
|
+
response,
|
|
872
|
+
diagnostics,
|
|
789
873
|
close,
|
|
790
874
|
observed,
|
|
791
875
|
);
|
|
@@ -798,8 +882,8 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
798
882
|
qualifiedId: parsed.qualifiedId,
|
|
799
883
|
text: parsed.text,
|
|
800
884
|
usage: parsed.usage,
|
|
801
|
-
|
|
802
|
-
stderr,
|
|
885
|
+
events: parsed.events,
|
|
886
|
+
stderr: parsed.diagnostics,
|
|
803
887
|
exitCode: close.code,
|
|
804
888
|
signal: close.signal,
|
|
805
889
|
};
|
package/src/core/fusion/types.ts
CHANGED
|
@@ -232,7 +232,7 @@ export interface FusionChildRunResult {
|
|
|
232
232
|
qualifiedId: string;
|
|
233
233
|
text: string;
|
|
234
234
|
usage: FusionUsage;
|
|
235
|
-
|
|
235
|
+
events: Buffer;
|
|
236
236
|
stderr: Buffer;
|
|
237
237
|
exitCode: number;
|
|
238
238
|
signal: NodeJS.Signals | null;
|
|
@@ -247,6 +247,7 @@ export interface FusionAttemptArtifactRecord {
|
|
|
247
247
|
events_path?: string;
|
|
248
248
|
stderr_path?: string;
|
|
249
249
|
response_path?: string;
|
|
250
|
+
partial_response_path?: string;
|
|
250
251
|
provider?: string;
|
|
251
252
|
model?: string;
|
|
252
253
|
qualifiedId?: string;
|
package/src/core/registry.ts
CHANGED
|
@@ -1538,6 +1538,7 @@ export class BackgroundTaskRegistry {
|
|
|
1538
1538
|
error,
|
|
1539
1539
|
` <output-file>${escapeXml(task.outputPath)}</output-file>`,
|
|
1540
1540
|
` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
|
|
1541
|
+
' <guidance>Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.</guidance>',
|
|
1541
1542
|
'</background-task-notification>',
|
|
1542
1543
|
]
|
|
1543
1544
|
.filter(Boolean)
|