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
package/src/core/registry.ts
CHANGED
|
@@ -1,144 +1,203 @@
|
|
|
1
|
-
import { spawn as nodeSpawn, type SpawnOptions } from
|
|
2
|
-
import { randomBytes } from
|
|
3
|
-
import { createWriteStream, existsSync } from
|
|
4
|
-
import { mkdir, writeFile } from
|
|
5
|
-
import { join } from
|
|
6
|
-
import type {
|
|
7
|
-
import {
|
|
1
|
+
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createWriteStream, existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
7
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import { formatSize } from '@earendil-works/pi-coding-agent';
|
|
8
9
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
10
|
+
boundedRead,
|
|
11
|
+
deriveTaskNameFromCommand,
|
|
12
|
+
escapeXml,
|
|
13
|
+
formatAgentActivityLine,
|
|
14
|
+
formatDuration,
|
|
15
|
+
isJsonObject,
|
|
16
|
+
normalizeTaskName,
|
|
17
|
+
parseAgentActivity,
|
|
18
|
+
parseJsonText,
|
|
19
|
+
sanitizePathSegment,
|
|
20
|
+
shellInvocation,
|
|
21
|
+
shellQuote,
|
|
22
|
+
snapshot,
|
|
23
|
+
taskDisplayName,
|
|
24
|
+
type BgLogsDetails,
|
|
25
|
+
type BgTask,
|
|
26
|
+
type BgTaskSnapshot,
|
|
27
|
+
type JsonObject,
|
|
28
|
+
type KillKind,
|
|
29
|
+
type StartAttestedPiTaskOptions,
|
|
30
|
+
type StartTaskOptions,
|
|
31
|
+
type TaskContextUsage,
|
|
32
|
+
type TaskStatus,
|
|
33
|
+
type TaskTokenUsage,
|
|
34
|
+
type TaskToolUsage,
|
|
35
|
+
} from './common.js';
|
|
36
|
+
import {
|
|
37
|
+
ATTESTED_TASK_ID_PATTERN,
|
|
38
|
+
attestedPiChildEnv,
|
|
39
|
+
buildAttestedPiArgv,
|
|
40
|
+
buildPiTaskAttestation,
|
|
41
|
+
closeAndFsyncOutputStream,
|
|
42
|
+
gitAuthoritySnapshot,
|
|
43
|
+
gitRepoRoot,
|
|
44
|
+
makeAttestedTaskId,
|
|
45
|
+
makeAttestedTaskPaths,
|
|
46
|
+
observePiOAuth,
|
|
47
|
+
parsePiJsonEvents,
|
|
48
|
+
resolveReportPath,
|
|
49
|
+
spawnAndCapturePi,
|
|
50
|
+
writeFileFsynced,
|
|
51
|
+
writeJsonAtomic,
|
|
52
|
+
} from './attested-pi-run.js';
|
|
53
|
+
|
|
54
|
+
export const MAX_OUTPUT_BYTES = Number(process.env['PI_BG_MAX_OUTPUT_BYTES'] ?? 20 * 1024 * 1024);
|
|
32
55
|
export const KILL_GRACE_MS = 3000;
|
|
33
56
|
export const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
|
|
34
57
|
export const MAX_RECENT_TASKS = 100;
|
|
35
58
|
const TELEMETRY_BUFFER_CHARS = 512 * 1024;
|
|
36
59
|
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export
|
|
60
|
+
export interface BackgroundTaskModelRegistry
|
|
61
|
+
extends Pick<ExtensionContext['modelRegistry'], 'getAll'> {
|
|
62
|
+
find?: (provider: string, modelId: string) => Model<Api> | undefined;
|
|
63
|
+
isUsingOAuth?: (model: Model<Api>) => boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BackgroundTaskContext {
|
|
67
|
+
cwd: string;
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
modelRegistry: BackgroundTaskModelRegistry;
|
|
70
|
+
model?: ExtensionContext['model'] | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface OutputEventSource {
|
|
74
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BackgroundTaskChildProcess {
|
|
78
|
+
pid?: number | undefined;
|
|
79
|
+
stdout?: OutputEventSource | null | undefined;
|
|
80
|
+
stderr?: OutputEventSource | null | undefined;
|
|
81
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
82
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
83
|
+
on(
|
|
84
|
+
event: 'close',
|
|
85
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
86
|
+
): unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type BackgroundTaskSpawn = (
|
|
90
|
+
command: string,
|
|
91
|
+
args: string[],
|
|
92
|
+
options: SpawnOptions,
|
|
93
|
+
) => BackgroundTaskChildProcess;
|
|
94
|
+
|
|
55
95
|
type KillProcessFn = (pid: number, signal?: NodeJS.Signals | number) => boolean;
|
|
56
96
|
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
97
|
+
export interface CompletionNotificationMessage {
|
|
98
|
+
customType: 'background-task-notification';
|
|
99
|
+
content: string;
|
|
100
|
+
display: true;
|
|
101
|
+
details: BgTaskSnapshot;
|
|
102
|
+
}
|
|
63
103
|
|
|
64
|
-
export
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
104
|
+
export interface CompletionNotificationOptions {
|
|
105
|
+
deliverAs: 'followUp';
|
|
106
|
+
triggerTurn: boolean;
|
|
107
|
+
}
|
|
68
108
|
|
|
69
109
|
export type CompletionNotificationSender = (
|
|
70
|
-
|
|
71
|
-
|
|
110
|
+
message: CompletionNotificationMessage,
|
|
111
|
+
options: CompletionNotificationOptions,
|
|
72
112
|
) => void;
|
|
73
113
|
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
export interface BackgroundTaskRegistryOptions {
|
|
115
|
+
onChange?: () => void;
|
|
116
|
+
sendCompletionNotification: CompletionNotificationSender;
|
|
117
|
+
publishTerminal?: (task: BgTaskSnapshot) => void;
|
|
118
|
+
spawn?: BackgroundTaskSpawn;
|
|
119
|
+
killProcess?: KillProcessFn;
|
|
120
|
+
platform?: NodeJS.Platform;
|
|
121
|
+
env?: NodeJS.ProcessEnv;
|
|
122
|
+
makeTaskId?: () => string;
|
|
123
|
+
now?: () => number;
|
|
124
|
+
maxOutputBytes?: number;
|
|
125
|
+
maxRecentTasks?: number;
|
|
126
|
+
killGraceMs?: number;
|
|
127
|
+
stopWaitMs?: number;
|
|
128
|
+
logger?: Pick<Console, 'error'>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface RuntimeDir {
|
|
132
|
+
abs: string;
|
|
133
|
+
display: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ModelWindowIndex {
|
|
137
|
+
byQualifiedId: Record<string, number>;
|
|
138
|
+
byId: Record<string, number>;
|
|
139
|
+
defaultModel?: string | undefined;
|
|
140
|
+
defaultProvider?: string | undefined;
|
|
141
|
+
defaultContextWindow?: number | undefined;
|
|
142
|
+
}
|
|
99
143
|
|
|
100
144
|
function defaultTaskId(): string {
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
145
|
+
return `b${randomBytes(4).toString('hex')}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function dirNameFromDisplay(path: string): string {
|
|
149
|
+
const parts = path.split(/[\\/]/);
|
|
150
|
+
return parts.length >= 2 ? (parts.at(-2) ?? '') : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function commandMayLaunchPiAgent(
|
|
154
|
+
command: string,
|
|
155
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
156
|
+
): boolean {
|
|
157
|
+
if (env['PI_BG_DISABLE_PI_TELEMETRY'] === '1') return false;
|
|
158
|
+
return /(^|[\s;&|()])pi(?=\s)(?=[^\n;&|]*(?:\s-p(?:\s|$)|\s--print(?:\s|$)|\s--mode(?:=|\s+)json\b))/m.test(
|
|
159
|
+
command,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function buildModelWindowIndex(
|
|
164
|
+
ctx: Pick<BackgroundTaskContext, 'modelRegistry' | 'model'>,
|
|
165
|
+
): ModelWindowIndex {
|
|
166
|
+
const byQualifiedId: Record<string, number> = {};
|
|
167
|
+
const candidatesById = new Map<string, Set<number>>();
|
|
168
|
+
for (const model of ctx.modelRegistry.getAll()) {
|
|
169
|
+
const contextWindow =
|
|
170
|
+
typeof model.contextWindow === 'number' &&
|
|
171
|
+
Number.isFinite(model.contextWindow) &&
|
|
172
|
+
model.contextWindow > 0
|
|
173
|
+
? Math.floor(model.contextWindow)
|
|
174
|
+
: undefined;
|
|
175
|
+
if (!contextWindow) continue;
|
|
176
|
+
byQualifiedId[`${model.provider}/${model.id}`] = contextWindow;
|
|
177
|
+
let candidates = candidatesById.get(model.id);
|
|
178
|
+
if (!candidates) {
|
|
179
|
+
candidates = new Set<number>();
|
|
180
|
+
candidatesById.set(model.id, candidates);
|
|
181
|
+
}
|
|
182
|
+
candidates.add(contextWindow);
|
|
183
|
+
}
|
|
184
|
+
const byId: Record<string, number> = {};
|
|
185
|
+
for (const [id, windows] of candidatesById) {
|
|
186
|
+
const onlyWindow = windows.values().next();
|
|
187
|
+
if (windows.size === 1 && !onlyWindow.done) byId[id] = onlyWindow.value;
|
|
188
|
+
}
|
|
189
|
+
const current = ctx.model;
|
|
190
|
+
return {
|
|
191
|
+
byQualifiedId,
|
|
192
|
+
byId,
|
|
193
|
+
defaultModel: current?.id,
|
|
194
|
+
defaultProvider: current?.provider,
|
|
195
|
+
defaultContextWindow: current?.contextWindow,
|
|
196
|
+
};
|
|
138
197
|
}
|
|
139
198
|
|
|
140
199
|
export function createPiTelemetryWrapperSource(index: ModelWindowIndex): string {
|
|
141
|
-
|
|
200
|
+
return `#!/usr/bin/env node
|
|
142
201
|
const { spawn } = require("node:child_process");
|
|
143
202
|
const index = ${JSON.stringify(index)};
|
|
144
203
|
|
|
@@ -221,6 +280,36 @@ function emitUnifiedTelemetry(payload) {
|
|
|
221
280
|
process.stdout.write(JSON.stringify(out) + "\\n");
|
|
222
281
|
}
|
|
223
282
|
|
|
283
|
+
function emitActivity(activity) {
|
|
284
|
+
process.stdout.write(JSON.stringify({ type: "background-task-activity", ...activity }) + "\\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function summarizeArgs(args) {
|
|
288
|
+
if (!args || typeof args !== "object") return "";
|
|
289
|
+
const pick = (value) => {
|
|
290
|
+
if (typeof value === "string" && value.trim()) return value.trim().slice(0, 200);
|
|
291
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
292
|
+
return undefined;
|
|
293
|
+
};
|
|
294
|
+
const preferred = ["path", "file_path", "file", "filename", "command", "cmd", "pattern", "query", "url", "name", "value", "text", "message"];
|
|
295
|
+
for (const key of preferred) { const summary = pick(args[key]); if (summary) return summary; }
|
|
296
|
+
for (const key of Object.keys(args)) { const summary = pick(args[key]); if (summary) return summary; }
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function emitAssistantActivity(message) {
|
|
301
|
+
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
302
|
+
for (const part of content) {
|
|
303
|
+
if (!part || typeof part !== "object") continue;
|
|
304
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
305
|
+
emitActivity({ kind: "assistant_text", text: part.text });
|
|
306
|
+
} else if (part.type === "thinking" || part.type === "reasoning") {
|
|
307
|
+
const text = typeof part.text === "string" ? part.text : (typeof part.thinking === "string" ? part.thinking : "");
|
|
308
|
+
if (text.trim()) emitActivity({ kind: "reasoning", text: text });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
224
313
|
function resolveModelName(fromMessage, fromArgs, providerFromArgs) {
|
|
225
314
|
const message = fromMessage ? String(fromMessage) : "";
|
|
226
315
|
const args = fromArgs ? String(fromArgs) : "";
|
|
@@ -320,7 +409,6 @@ function emitToolTelemetry() {
|
|
|
320
409
|
const parsed = parseInvocation(process.argv.slice(2));
|
|
321
410
|
const child = spawn("pi", parsed.args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
322
411
|
let buffer = "";
|
|
323
|
-
let finalText = "";
|
|
324
412
|
|
|
325
413
|
if (!parsed.parseJson) {
|
|
326
414
|
child.stdout.pipe(process.stdout);
|
|
@@ -338,9 +426,14 @@ child.on("error", (error) => {
|
|
|
338
426
|
});
|
|
339
427
|
child.on("close", (code, signal) => {
|
|
340
428
|
if (parsed.parseJson && buffer.trim()) processLine(buffer);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
429
|
+
// Never call process.exit() here: the final message telemetry may still be
|
|
430
|
+
// buffered on wrapper stdout, and forced exit can publish a stale context
|
|
431
|
+
// snapshot from the preceding assistant turn. exitCode lets Node drain the
|
|
432
|
+
// pipe; signal termination is deferred through the same stdout barrier.
|
|
433
|
+
process.stdout.write("", () => {
|
|
434
|
+
if (signal) process.kill(process.pid, signal);
|
|
435
|
+
else process.exitCode = code ?? 0;
|
|
436
|
+
});
|
|
344
437
|
});
|
|
345
438
|
|
|
346
439
|
function processLine(line) {
|
|
@@ -353,606 +446,1211 @@ function processLine(line) {
|
|
|
353
446
|
return;
|
|
354
447
|
}
|
|
355
448
|
if (event.type === "tool_execution_start") {
|
|
356
|
-
|
|
449
|
+
const toolName = event.toolName || event.tool_name || "tool";
|
|
450
|
+
markToolStarted(event.toolCallId || event.tool_call_id, toolName);
|
|
451
|
+
emitActivity({ kind: "tool_start", tool: String(toolName), argsSummary: summarizeArgs(event.args || event.arguments || event.input || event.parameters) });
|
|
357
452
|
emitToolTelemetry();
|
|
358
453
|
return;
|
|
359
454
|
}
|
|
360
455
|
if (event.type === "tool_execution_end") {
|
|
456
|
+
const toolName = event.toolName || event.tool_name || "tool";
|
|
361
457
|
if (event.isError) markToolFailed(event.toolCallId || event.tool_call_id);
|
|
458
|
+
emitActivity({ kind: "tool_end", tool: String(toolName), isError: !!event.isError, error: typeof event.error === "string" ? event.error : undefined });
|
|
362
459
|
emitToolTelemetry();
|
|
363
460
|
return;
|
|
364
461
|
}
|
|
365
462
|
if (event.type === "message_end" && event.message && event.message.role === "assistant") {
|
|
463
|
+
emitAssistantActivity(event.message);
|
|
366
464
|
countToolCallsFromMessage(event.message);
|
|
367
465
|
emitMessageTelemetry(event.message, parsed.model, parsed.provider);
|
|
368
|
-
finalText = (event.message.content || [])
|
|
369
|
-
.filter((part) => part && part.type === "text" && part.text)
|
|
370
|
-
.map((part) => part.text)
|
|
371
|
-
.join("\\n");
|
|
372
466
|
}
|
|
373
467
|
}
|
|
374
468
|
`;
|
|
375
469
|
}
|
|
376
470
|
|
|
471
|
+
interface ContextUsagePayload extends JsonObject {
|
|
472
|
+
readonly contextWindow?: unknown;
|
|
473
|
+
readonly tokens?: unknown;
|
|
474
|
+
readonly percent?: unknown;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface TokenUsagePayload extends JsonObject {
|
|
478
|
+
readonly input?: unknown;
|
|
479
|
+
readonly output?: unknown;
|
|
480
|
+
readonly cacheRead?: unknown;
|
|
481
|
+
readonly cacheWrite?: unknown;
|
|
482
|
+
readonly totalTokens?: unknown;
|
|
483
|
+
readonly costTotal?: unknown;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
interface ToolUsagePayload extends JsonObject {
|
|
487
|
+
readonly byName?: unknown;
|
|
488
|
+
readonly failed?: unknown;
|
|
489
|
+
readonly total?: unknown;
|
|
490
|
+
}
|
|
491
|
+
|
|
377
492
|
function normalizeContextUsage(value: unknown): TaskContextUsage | undefined {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
493
|
+
if (!isJsonObject(value)) return undefined;
|
|
494
|
+
const input: ContextUsagePayload = value;
|
|
495
|
+
const rawContextWindow = input.contextWindow;
|
|
496
|
+
const contextWindow =
|
|
497
|
+
typeof rawContextWindow === 'number' &&
|
|
498
|
+
Number.isFinite(rawContextWindow) &&
|
|
499
|
+
rawContextWindow > 0
|
|
500
|
+
? Math.floor(rawContextWindow)
|
|
501
|
+
: undefined;
|
|
502
|
+
if (!contextWindow) return undefined;
|
|
503
|
+
const rawTokens = input.tokens;
|
|
504
|
+
const tokens =
|
|
505
|
+
rawTokens === null
|
|
506
|
+
? null
|
|
507
|
+
: typeof rawTokens === 'number' && Number.isFinite(rawTokens) && rawTokens >= 0
|
|
508
|
+
? Math.floor(rawTokens)
|
|
509
|
+
: null;
|
|
510
|
+
const rawPercent = input.percent;
|
|
511
|
+
const percent =
|
|
512
|
+
rawPercent === null
|
|
513
|
+
? null
|
|
514
|
+
: typeof rawPercent === 'number' && Number.isFinite(rawPercent) && rawPercent >= 0
|
|
515
|
+
? rawPercent
|
|
516
|
+
: tokens === null
|
|
517
|
+
? null
|
|
518
|
+
: (tokens / contextWindow) * 100;
|
|
519
|
+
return { tokens, contextWindow, percent };
|
|
400
520
|
}
|
|
401
521
|
|
|
402
522
|
function parseContextUsageXml(xml: string): TaskContextUsage | undefined {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
523
|
+
const readNumber = (tag: string): number | null | undefined => {
|
|
524
|
+
const match = new RegExp(`<${tag}>(.*?)</${tag}>`, 'i').exec(xml);
|
|
525
|
+
if (!match) return undefined;
|
|
526
|
+
const raw = match[1]?.trim();
|
|
527
|
+
if (raw === 'null' || raw === '?') return null;
|
|
528
|
+
const parsed = Number(raw);
|
|
529
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
530
|
+
};
|
|
531
|
+
const tokens = readNumber('tokens');
|
|
532
|
+
const contextWindow = readNumber('context-window') ?? readNumber('contextWindow');
|
|
533
|
+
const percent = readNumber('percent');
|
|
534
|
+
return normalizeContextUsage({ tokens, contextWindow, percent });
|
|
415
535
|
}
|
|
416
536
|
|
|
417
537
|
function nonNegativeInteger(value: unknown): number {
|
|
418
|
-
|
|
538
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
|
|
419
539
|
}
|
|
420
540
|
|
|
421
541
|
function normalizeModel(value: unknown): string | undefined {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
542
|
+
if (typeof value !== 'string') return undefined;
|
|
543
|
+
const trimmed = value.trim();
|
|
544
|
+
if (!trimmed) return undefined;
|
|
545
|
+
return trimmed.length > 120 ? trimmed.slice(0, 120) : trimmed;
|
|
426
546
|
}
|
|
427
547
|
|
|
428
548
|
function normalizeTokenUsage(value: unknown): TaskTokenUsage | undefined {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
549
|
+
if (!isJsonObject(value)) return undefined;
|
|
550
|
+
const input: TokenUsagePayload = value;
|
|
551
|
+
const usage: TaskTokenUsage = {
|
|
552
|
+
input: nonNegativeInteger(input.input),
|
|
553
|
+
output: nonNegativeInteger(input.output),
|
|
554
|
+
cacheRead: nonNegativeInteger(input.cacheRead),
|
|
555
|
+
cacheWrite: nonNegativeInteger(input.cacheWrite),
|
|
556
|
+
totalTokens: nonNegativeInteger(input.totalTokens),
|
|
557
|
+
};
|
|
558
|
+
if (usage.totalTokens <= 0)
|
|
559
|
+
usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
|
560
|
+
const rawCostTotal = input.costTotal;
|
|
561
|
+
if (typeof rawCostTotal === 'number' && Number.isFinite(rawCostTotal) && rawCostTotal >= 0)
|
|
562
|
+
usage.costTotal = rawCostTotal;
|
|
563
|
+
return usage.totalTokens > 0 ? usage : undefined;
|
|
442
564
|
}
|
|
443
565
|
|
|
444
566
|
function normalizeToolUsage(value: unknown): TaskToolUsage | undefined {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
567
|
+
if (!isJsonObject(value)) return undefined;
|
|
568
|
+
const input: ToolUsagePayload = value;
|
|
569
|
+
const byName: Record<string, number> = {};
|
|
570
|
+
const rawByName = input.byName;
|
|
571
|
+
if (isJsonObject(rawByName)) {
|
|
572
|
+
for (const [name, count] of Object.entries(rawByName)) {
|
|
573
|
+
const normalized = nonNegativeInteger(count);
|
|
574
|
+
if (normalized > 0) byName[name] = normalized;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const byNameTotal = Object.values(byName).reduce((sum, count) => sum + count, 0);
|
|
578
|
+
const failed = nonNegativeInteger(input.failed);
|
|
579
|
+
const total = Math.max(nonNegativeInteger(input.total), byNameTotal, failed);
|
|
580
|
+
return total > 0 || failed > 0 ? { total, failed, byName } : undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
interface TelemetryControlPayload extends JsonObject {
|
|
584
|
+
readonly type?: unknown;
|
|
585
|
+
readonly contextUsage?: unknown;
|
|
586
|
+
readonly tokenUsage?: unknown;
|
|
587
|
+
readonly toolUsage?: unknown;
|
|
588
|
+
readonly model?: unknown;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
interface TelemetryDelta {
|
|
592
|
+
context?: TaskContextUsage | undefined;
|
|
593
|
+
tokens?: TaskTokenUsage | undefined;
|
|
594
|
+
tools?: TaskToolUsage | undefined;
|
|
595
|
+
model?: string | undefined;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function noopOnChange(): void {
|
|
599
|
+
return undefined;
|
|
459
600
|
}
|
|
460
601
|
|
|
461
602
|
export class BackgroundTaskRegistry {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
603
|
+
private readonly tasks = new Map<string, BgTask>();
|
|
604
|
+
private runtimeDir: RuntimeDir | undefined;
|
|
605
|
+
private shuttingDown = false;
|
|
606
|
+
private readonly spawn: BackgroundTaskSpawn;
|
|
607
|
+
private readonly killProcess: KillProcessFn;
|
|
608
|
+
private readonly platform: NodeJS.Platform;
|
|
609
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
610
|
+
private readonly makeTaskIdFn: () => string;
|
|
611
|
+
private readonly now: () => number;
|
|
612
|
+
private readonly maxOutputBytes: number;
|
|
613
|
+
private readonly maxRecentTasks: number;
|
|
614
|
+
private readonly killGraceMs: number;
|
|
615
|
+
private readonly stopWaitMs: number;
|
|
616
|
+
private readonly logger: Pick<Console, 'error'>;
|
|
617
|
+
private readonly onChange: () => void;
|
|
618
|
+
private readonly sendCompletionNotification: CompletionNotificationSender;
|
|
619
|
+
private readonly publishTerminalSnapshot: (task: BgTaskSnapshot) => void;
|
|
620
|
+
|
|
621
|
+
constructor(options: BackgroundTaskRegistryOptions) {
|
|
622
|
+
this.spawn =
|
|
623
|
+
options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions));
|
|
624
|
+
this.killProcess = options.killProcess ?? process.kill.bind(process);
|
|
625
|
+
this.platform = options.platform ?? process.platform;
|
|
626
|
+
this.env = options.env ?? process.env;
|
|
627
|
+
this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
|
|
628
|
+
this.now = options.now ?? Date.now;
|
|
629
|
+
this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
|
|
630
|
+
this.maxRecentTasks = options.maxRecentTasks ?? MAX_RECENT_TASKS;
|
|
631
|
+
this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
|
|
632
|
+
this.stopWaitMs = options.stopWaitMs ?? STOP_WAIT_MS;
|
|
633
|
+
this.logger = options.logger ?? console;
|
|
634
|
+
this.onChange = options.onChange ?? noopOnChange;
|
|
635
|
+
this.sendCompletionNotification = options.sendCompletionNotification;
|
|
636
|
+
this.publishTerminalSnapshot = options.publishTerminal ?? noopOnChange;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
isShuttingDown(): boolean {
|
|
640
|
+
return this.shuttingDown;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
setShuttingDown(value: boolean): void {
|
|
644
|
+
this.shuttingDown = value;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
allTasks(): BgTask[] {
|
|
648
|
+
return [...this.tasks.values()];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
snapshot(task: BgTask): BgTaskSnapshot {
|
|
652
|
+
return snapshot(task);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async ensureRuntimeDir(ctx: BackgroundTaskContext): Promise<RuntimeDir> {
|
|
656
|
+
if (this.runtimeDir) return this.runtimeDir;
|
|
657
|
+
const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
|
|
658
|
+
const runId = `${sessionId}-${String(process.pid)}`;
|
|
659
|
+
const runtimeDirAbs = join(ctx.cwd, '.pi', 'tasks', runId);
|
|
660
|
+
const runtimeDirDisplay = join('.pi', 'tasks', runId);
|
|
661
|
+
await mkdir(runtimeDirAbs, { recursive: true });
|
|
662
|
+
this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
|
|
663
|
+
return this.runtimeDir;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async startTask(
|
|
667
|
+
ctx: BackgroundTaskContext,
|
|
668
|
+
command: string,
|
|
669
|
+
options: StartTaskOptions = {},
|
|
670
|
+
): Promise<BgTask> {
|
|
671
|
+
const normalizedCommand = command.trim();
|
|
672
|
+
if (!normalizedCommand) throw new Error('Background command is empty');
|
|
673
|
+
if (this.shuttingDown)
|
|
674
|
+
throw new Error('Cannot start a background task while Pi is shutting down');
|
|
675
|
+
|
|
676
|
+
const dir = await this.ensureRuntimeDir(ctx);
|
|
677
|
+
const id = this.makeTaskIdFn();
|
|
678
|
+
const outputAbsPath = join(dir.abs, `${id}.output`);
|
|
679
|
+
const metadataAbsPath = join(dir.abs, `${id}.json`);
|
|
680
|
+
const outputPath = join(dir.display, `${id}.output`);
|
|
681
|
+
const timeoutSeconds =
|
|
682
|
+
typeof options.timeoutSeconds === 'number' &&
|
|
683
|
+
Number.isFinite(options.timeoutSeconds) &&
|
|
684
|
+
options.timeoutSeconds > 0
|
|
685
|
+
? Math.floor(options.timeoutSeconds)
|
|
686
|
+
: undefined;
|
|
687
|
+
const taskName =
|
|
688
|
+
normalizeTaskName(options.name) ??
|
|
689
|
+
normalizeTaskName(options.description) ??
|
|
690
|
+
deriveTaskNameFromCommand(normalizedCommand);
|
|
691
|
+
const isAgent = options.isAgent ?? false;
|
|
692
|
+
const trimmedDescription = options.description?.trim();
|
|
693
|
+
const description =
|
|
694
|
+
trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : undefined;
|
|
695
|
+
|
|
696
|
+
const task: BgTask = {
|
|
697
|
+
id,
|
|
698
|
+
name: taskName,
|
|
699
|
+
command: normalizedCommand,
|
|
700
|
+
description,
|
|
701
|
+
status: 'running',
|
|
702
|
+
outputPath,
|
|
703
|
+
outputAbsPath,
|
|
704
|
+
metadataAbsPath,
|
|
705
|
+
cwd: ctx.cwd,
|
|
706
|
+
startTime: this.now(),
|
|
707
|
+
exitCode: undefined,
|
|
708
|
+
pid: undefined,
|
|
709
|
+
bytesWritten: 0,
|
|
710
|
+
isAgent,
|
|
711
|
+
notified: false,
|
|
712
|
+
notifyOnCompletion: options.notifyOnCompletion ?? true,
|
|
713
|
+
triggerOnCompletion: options.triggerOnCompletion ?? false,
|
|
714
|
+
timeoutSeconds,
|
|
715
|
+
terminalPublicationGate: options.terminalPublicationGate,
|
|
716
|
+
waiters: [],
|
|
717
|
+
};
|
|
718
|
+
this.tasks.set(id, task);
|
|
719
|
+
|
|
720
|
+
const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
|
|
721
|
+
task.stream = stream;
|
|
722
|
+
stream.on('error', (error) => {
|
|
723
|
+
task.error = `Output file write failed: ${error.message}`;
|
|
724
|
+
if (task.status === 'running') {
|
|
725
|
+
task.killKind = 'output_cap';
|
|
726
|
+
try {
|
|
727
|
+
this.requestKill(task, 'SIGTERM');
|
|
728
|
+
} catch (killError) {
|
|
729
|
+
void this.finalizeTask(
|
|
730
|
+
task,
|
|
731
|
+
'failed',
|
|
732
|
+
null,
|
|
733
|
+
undefined,
|
|
734
|
+
`${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
try {
|
|
741
|
+
let commandToSpawn = normalizedCommand;
|
|
742
|
+
if (isAgent && commandMayLaunchPiAgent(normalizedCommand, this.env)) {
|
|
743
|
+
const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
|
|
744
|
+
await writeFile(
|
|
745
|
+
wrapperAbsPath,
|
|
746
|
+
createPiTelemetryWrapperSource(buildModelWindowIndex(ctx)),
|
|
747
|
+
'utf8',
|
|
748
|
+
);
|
|
749
|
+
commandToSpawn = `pi() { node ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
|
|
750
|
+
task.telemetryWrapped = true;
|
|
751
|
+
}
|
|
752
|
+
const invocation = shellInvocation(commandToSpawn, this.platform, this.env);
|
|
753
|
+
const child = this.spawn(invocation.shell, invocation.args, {
|
|
754
|
+
cwd: ctx.cwd,
|
|
755
|
+
detached: this.platform !== 'win32',
|
|
756
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
757
|
+
env: this.env,
|
|
758
|
+
windowsHide: true,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
task.child = child;
|
|
762
|
+
task.pid = child.pid;
|
|
763
|
+
|
|
764
|
+
child.stdout?.on('data', (data) => {
|
|
765
|
+
this.appendChildOutput(task, data, 'stdout');
|
|
766
|
+
});
|
|
767
|
+
child.stderr?.on('data', (data) => {
|
|
768
|
+
this.appendChildOutput(task, data, 'stderr');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
child.on('error', (error) => {
|
|
772
|
+
this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
|
|
773
|
+
void this.finalizeTask(task, 'failed', null, undefined, error.message);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
child.on('close', (code, signalName) => {
|
|
777
|
+
let status: TaskStatus;
|
|
778
|
+
let error: string | undefined;
|
|
779
|
+
if (task.killKind === 'user' || task.killKind === 'shutdown') {
|
|
780
|
+
status = 'killed';
|
|
781
|
+
} else if (task.killKind === 'timeout') {
|
|
782
|
+
status = 'failed';
|
|
783
|
+
error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
|
|
784
|
+
} else if (task.killKind === 'output_cap') {
|
|
785
|
+
status = 'failed';
|
|
786
|
+
error = task.error ?? `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
|
|
787
|
+
} else if ((code ?? 0) === 0) {
|
|
788
|
+
status = 'completed';
|
|
789
|
+
} else {
|
|
790
|
+
status = 'failed';
|
|
791
|
+
const exitCode = code === null ? 'null' : String(code);
|
|
792
|
+
error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
|
|
793
|
+
}
|
|
794
|
+
void this.finalizeTask(task, status, code, signalName, error);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
if (timeoutSeconds !== undefined) {
|
|
798
|
+
task.timeoutHandle = setTimeout(() => {
|
|
799
|
+
if (task.status !== 'running') return;
|
|
800
|
+
task.killKind = 'timeout';
|
|
801
|
+
task.error = `Timed out after ${String(timeoutSeconds)}s`;
|
|
802
|
+
this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
|
|
803
|
+
try {
|
|
804
|
+
this.requestKill(task, 'SIGTERM');
|
|
805
|
+
} catch (error) {
|
|
806
|
+
void this.finalizeTask(
|
|
807
|
+
task,
|
|
808
|
+
'failed',
|
|
809
|
+
null,
|
|
810
|
+
undefined,
|
|
811
|
+
`${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}, timeoutSeconds * 1000);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
await this.writeMetadata(task);
|
|
818
|
+
this.onChange();
|
|
819
|
+
return task;
|
|
820
|
+
} catch (error) {
|
|
821
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
822
|
+
this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
|
|
823
|
+
await this.finalizeTask(task, 'failed', null, undefined, message);
|
|
824
|
+
throw new Error(`Failed to start background task: ${message}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async startAttestedPiTask(
|
|
829
|
+
ctx: BackgroundTaskContext,
|
|
830
|
+
request: StartAttestedPiTaskOptions,
|
|
831
|
+
): Promise<BgTask> {
|
|
832
|
+
if (this.shuttingDown)
|
|
833
|
+
throw new Error('Cannot start an attested Pi task while Pi is shutting down');
|
|
834
|
+
|
|
835
|
+
const dir = await this.ensureRuntimeDir(ctx);
|
|
836
|
+
const id = makeAttestedTaskId();
|
|
837
|
+
if (!ATTESTED_TASK_ID_PATTERN.test(id))
|
|
838
|
+
throw new Error('Generated attested task id is invalid');
|
|
839
|
+
const paths = makeAttestedTaskPaths(dir.abs, dir.display, id);
|
|
840
|
+
const argv = buildAttestedPiArgv(request);
|
|
841
|
+
const promptBytes = Buffer.from(request.prompt, 'utf8');
|
|
842
|
+
const reportAbsPath = await resolveReportPath(ctx.cwd, request.reportPath);
|
|
843
|
+
const auth = observePiOAuth(ctx, request.provider, request.model);
|
|
844
|
+
const repoRootRealpath = await gitRepoRoot(ctx.cwd);
|
|
845
|
+
const cwdRealpath = await realpath(ctx.cwd);
|
|
846
|
+
const startAuthority = await gitAuthoritySnapshot(ctx.cwd);
|
|
847
|
+
if (!startAuthority.clean)
|
|
848
|
+
throw new Error('Attested Pi task requires a clean worktree at start');
|
|
849
|
+
const timeoutSeconds =
|
|
850
|
+
typeof request.timeoutSeconds === 'number' &&
|
|
851
|
+
Number.isFinite(request.timeoutSeconds) &&
|
|
852
|
+
request.timeoutSeconds > 0
|
|
853
|
+
? Math.floor(request.timeoutSeconds)
|
|
854
|
+
: undefined;
|
|
855
|
+
|
|
856
|
+
const task: BgTask = {
|
|
857
|
+
id,
|
|
858
|
+
name: normalizeTaskName(request.name) ?? 'Attested Pi task',
|
|
859
|
+
command: argv.map(shellQuote).join(' '),
|
|
860
|
+
status: 'running',
|
|
861
|
+
outputPath: paths.outputPath,
|
|
862
|
+
outputAbsPath: paths.outputAbsPath,
|
|
863
|
+
metadataAbsPath: paths.metadataAbsPath,
|
|
864
|
+
eventsAbsPath: paths.eventsAbsPath,
|
|
865
|
+
stderrAbsPath: paths.stderrAbsPath,
|
|
866
|
+
wrapperAbsPath: paths.wrapperAbsPath,
|
|
867
|
+
attestationAbsPath: paths.attestationAbsPath,
|
|
868
|
+
cwd: ctx.cwd,
|
|
869
|
+
startTime: this.now(),
|
|
870
|
+
exitCode: undefined,
|
|
871
|
+
pid: undefined,
|
|
872
|
+
bytesWritten: 0,
|
|
873
|
+
isAgent: true,
|
|
874
|
+
notified: false,
|
|
875
|
+
notifyOnCompletion: false,
|
|
876
|
+
triggerOnCompletion: false,
|
|
877
|
+
timeoutSeconds,
|
|
878
|
+
attestationPath: paths.attestationPath,
|
|
879
|
+
attestedPi: {
|
|
880
|
+
eventsPath: paths.eventsPath,
|
|
881
|
+
stderrPath: paths.stderrPath,
|
|
882
|
+
wrapperPath: paths.wrapperPath,
|
|
883
|
+
attestationPath: paths.attestationPath,
|
|
884
|
+
},
|
|
885
|
+
waiters: [],
|
|
886
|
+
};
|
|
887
|
+
this.tasks.set(id, task);
|
|
888
|
+
|
|
889
|
+
await writeFileFsynced(paths.outputAbsPath, '');
|
|
890
|
+
await writeFileFsynced(paths.eventsAbsPath, '');
|
|
891
|
+
await writeFileFsynced(paths.stderrAbsPath, '');
|
|
892
|
+
await writeFileFsynced(
|
|
893
|
+
paths.wrapperAbsPath,
|
|
894
|
+
'direct-spawn attested Pi task; no shell telemetry wrapper is used\n',
|
|
895
|
+
);
|
|
896
|
+
await this.writeMetadata(task);
|
|
897
|
+
|
|
898
|
+
const captured = spawnAndCapturePi(this.spawn, argv, {
|
|
899
|
+
cwd: ctx.cwd,
|
|
900
|
+
detached: this.platform !== 'win32',
|
|
901
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
902
|
+
env: attestedPiChildEnv(this.env),
|
|
903
|
+
windowsHide: true,
|
|
904
|
+
});
|
|
905
|
+
task.child = captured.child;
|
|
906
|
+
task.pid = captured.child.pid;
|
|
907
|
+
await this.writeMetadata(task);
|
|
908
|
+
this.onChange();
|
|
909
|
+
|
|
910
|
+
captured.child.on('error', (error) => {
|
|
911
|
+
void this.finalizeAttestedPiTask(
|
|
912
|
+
task,
|
|
913
|
+
paths,
|
|
914
|
+
argv,
|
|
915
|
+
cwdRealpath,
|
|
916
|
+
repoRootRealpath,
|
|
917
|
+
startAuthority,
|
|
918
|
+
auth,
|
|
919
|
+
promptBytes,
|
|
920
|
+
reportAbsPath,
|
|
921
|
+
captured.stdoutChunks,
|
|
922
|
+
captured.stderrChunks,
|
|
923
|
+
'failed',
|
|
924
|
+
null,
|
|
925
|
+
null,
|
|
926
|
+
error.message,
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
captured.child.on('close', (code, signalName) => {
|
|
931
|
+
let status: TaskStatus = (code ?? 0) === 0 && signalName === null ? 'completed' : 'failed';
|
|
932
|
+
let error: string | undefined;
|
|
933
|
+
if (task.killKind === 'timeout') {
|
|
934
|
+
status = 'failed';
|
|
935
|
+
error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
|
|
936
|
+
} else if (task.killKind === 'user' || task.killKind === 'shutdown') {
|
|
937
|
+
status = 'killed';
|
|
938
|
+
error = task.error;
|
|
939
|
+
} else if (status === 'failed') {
|
|
940
|
+
const exitCode = code === null ? 'null' : String(code);
|
|
941
|
+
error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
|
|
942
|
+
}
|
|
943
|
+
void this.finalizeAttestedPiTask(
|
|
944
|
+
task,
|
|
945
|
+
paths,
|
|
946
|
+
argv,
|
|
947
|
+
cwdRealpath,
|
|
948
|
+
repoRootRealpath,
|
|
949
|
+
startAuthority,
|
|
950
|
+
auth,
|
|
951
|
+
promptBytes,
|
|
952
|
+
reportAbsPath,
|
|
953
|
+
captured.stdoutChunks,
|
|
954
|
+
captured.stderrChunks,
|
|
955
|
+
status,
|
|
956
|
+
code,
|
|
957
|
+
signalName,
|
|
958
|
+
error,
|
|
959
|
+
);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
if (timeoutSeconds !== undefined) {
|
|
963
|
+
task.timeoutHandle = setTimeout(() => {
|
|
964
|
+
if (task.status !== 'running') return;
|
|
965
|
+
task.killKind = 'timeout';
|
|
966
|
+
task.error = `Timed out after ${String(timeoutSeconds)}s`;
|
|
967
|
+
try {
|
|
968
|
+
this.requestKill(task, 'SIGTERM');
|
|
969
|
+
} catch (error) {
|
|
970
|
+
void this.finalizeAttestedPiTask(
|
|
971
|
+
task,
|
|
972
|
+
paths,
|
|
973
|
+
argv,
|
|
974
|
+
cwdRealpath,
|
|
975
|
+
repoRootRealpath,
|
|
976
|
+
startAuthority,
|
|
977
|
+
auth,
|
|
978
|
+
promptBytes,
|
|
979
|
+
reportAbsPath,
|
|
980
|
+
captured.stdoutChunks,
|
|
981
|
+
captured.stderrChunks,
|
|
982
|
+
'failed',
|
|
983
|
+
null,
|
|
984
|
+
null,
|
|
985
|
+
error instanceof Error ? error.message : String(error),
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
}, timeoutSeconds * 1000);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return task;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private async finalizeAttestedPiTask(
|
|
995
|
+
task: BgTask,
|
|
996
|
+
paths: ReturnType<typeof makeAttestedTaskPaths>,
|
|
997
|
+
argv: string[],
|
|
998
|
+
cwdRealpath: string,
|
|
999
|
+
repoRootRealpath: string,
|
|
1000
|
+
startAuthority: Awaited<ReturnType<typeof gitAuthoritySnapshot>>,
|
|
1001
|
+
auth: ReturnType<typeof observePiOAuth>,
|
|
1002
|
+
promptBytes: Buffer,
|
|
1003
|
+
reportAbsPath: string,
|
|
1004
|
+
stdoutChunks: Buffer[],
|
|
1005
|
+
stderrChunks: Buffer[],
|
|
1006
|
+
status: TaskStatus,
|
|
1007
|
+
exitCode: number | null,
|
|
1008
|
+
signal: NodeJS.Signals | null,
|
|
1009
|
+
error?: string,
|
|
1010
|
+
): Promise<void> {
|
|
1011
|
+
if (task.finalized) return;
|
|
1012
|
+
task.finalized = true;
|
|
1013
|
+
if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
|
|
1014
|
+
let finalStatus = status;
|
|
1015
|
+
task.exitCode = exitCode;
|
|
1016
|
+
task.signal = signal;
|
|
1017
|
+
task.endTime = this.now();
|
|
1018
|
+
if (error) task.error = error;
|
|
1019
|
+
|
|
1020
|
+
const rawEvents = Buffer.concat(stdoutChunks);
|
|
1021
|
+
const rawStderr = Buffer.concat(stderrChunks);
|
|
1022
|
+
await writeFileFsynced(paths.eventsAbsPath, rawEvents);
|
|
1023
|
+
await writeFileFsynced(paths.stderrAbsPath, rawStderr);
|
|
1024
|
+
|
|
1025
|
+
let parsed: ReturnType<typeof parsePiJsonEvents> | undefined;
|
|
1026
|
+
if (finalStatus === 'completed') {
|
|
1027
|
+
try {
|
|
1028
|
+
parsed = parsePiJsonEvents(rawEvents);
|
|
1029
|
+
task.model = parsed.providerScopedModelId;
|
|
1030
|
+
task.tokenUsage = {
|
|
1031
|
+
input: parsed.tokenUsage.input,
|
|
1032
|
+
output: parsed.tokenUsage.output,
|
|
1033
|
+
cacheRead: parsed.tokenUsage.cacheRead,
|
|
1034
|
+
cacheWrite: parsed.tokenUsage.cacheWrite,
|
|
1035
|
+
totalTokens: parsed.tokenUsage.totalTokens,
|
|
1036
|
+
};
|
|
1037
|
+
if (parsed.tokenUsage.costTotal !== undefined)
|
|
1038
|
+
task.tokenUsage.costTotal = parsed.tokenUsage.costTotal;
|
|
1039
|
+
task.toolUsage = parsed.toolUsage;
|
|
1040
|
+
const outputBytes = Buffer.from(parsed.humanTranscript, 'utf8');
|
|
1041
|
+
task.bytesWritten = outputBytes.length;
|
|
1042
|
+
await writeFileFsynced(paths.outputAbsPath, outputBytes);
|
|
1043
|
+
} catch (parseError) {
|
|
1044
|
+
finalStatus = 'failed';
|
|
1045
|
+
task.error = parseError instanceof Error ? parseError.message : String(parseError);
|
|
1046
|
+
const outputBytes = Buffer.from(`[attested Pi task error: ${task.error}]\n`, 'utf8');
|
|
1047
|
+
task.bytesWritten = outputBytes.length;
|
|
1048
|
+
await writeFileFsynced(paths.outputAbsPath, outputBytes);
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
const outputBytes = Buffer.from(rawStderr.toString('utf8'), 'utf8');
|
|
1052
|
+
task.bytesWritten = outputBytes.length;
|
|
1053
|
+
await writeFileFsynced(paths.outputAbsPath, outputBytes);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
if (finalStatus === 'completed' && parsed) {
|
|
1058
|
+
const finishAuthority = await gitAuthoritySnapshot(task.cwd);
|
|
1059
|
+
const completedSnapshot: BgTaskSnapshot = { ...snapshot(task), status: 'completed' };
|
|
1060
|
+
await this.writeMetadataSnapshot(task, completedSnapshot);
|
|
1061
|
+
const attestation = await buildPiTaskAttestation({
|
|
1062
|
+
task: completedSnapshot,
|
|
1063
|
+
paths,
|
|
1064
|
+
sessionDir: dirNameFromDisplay(paths.outputPath),
|
|
1065
|
+
argv,
|
|
1066
|
+
cwdRealpath,
|
|
1067
|
+
repoRootRealpath,
|
|
1068
|
+
startAuthority,
|
|
1069
|
+
finishAuthority,
|
|
1070
|
+
parsedEvents: parsed,
|
|
1071
|
+
auth,
|
|
1072
|
+
prompt: promptBytes,
|
|
1073
|
+
reportAbsPath,
|
|
1074
|
+
});
|
|
1075
|
+
await writeJsonAtomic(paths.attestationAbsPath, attestation);
|
|
1076
|
+
} else {
|
|
1077
|
+
await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
|
|
1078
|
+
}
|
|
1079
|
+
} catch (attestationError) {
|
|
1080
|
+
finalStatus = 'failed';
|
|
1081
|
+
task.error =
|
|
1082
|
+
attestationError instanceof Error ? attestationError.message : String(attestationError);
|
|
1083
|
+
await this.writeMetadataSnapshot(task, { ...snapshot(task), status: 'failed' }).catch(
|
|
1084
|
+
(metadataError: unknown) => {
|
|
1085
|
+
this.logger.error(
|
|
1086
|
+
`[background-tasks] failed to write failed attested metadata for ${task.id}:`,
|
|
1087
|
+
metadataError,
|
|
1088
|
+
);
|
|
1089
|
+
},
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
task.status = finalStatus;
|
|
1094
|
+
for (const waiter of task.waiters.splice(0)) waiter();
|
|
1095
|
+
this.onChange();
|
|
1096
|
+
this.publishTerminal(task);
|
|
1097
|
+
this.pruneOldTasks();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
resolveTask(idOrPrefix: string): BgTask {
|
|
1101
|
+
const id = idOrPrefix.trim();
|
|
1102
|
+
if (!id) throw new Error('Task ID is required');
|
|
1103
|
+
const exact = this.tasks.get(id);
|
|
1104
|
+
if (exact) return exact;
|
|
1105
|
+
const matches = [...this.tasks.values()].filter((task) => task.id.startsWith(id));
|
|
1106
|
+
const onlyMatch = matches[0];
|
|
1107
|
+
if (matches.length === 1 && onlyMatch) return onlyMatch;
|
|
1108
|
+
if (matches.length > 1)
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
`Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(', ')}`,
|
|
1111
|
+
);
|
|
1112
|
+
throw new Error(`Unknown background task ID: ${id}`);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async stopTask(task: BgTask, kind: KillKind, reason?: string): Promise<BgTask> {
|
|
1116
|
+
if (task.status !== 'running') {
|
|
1117
|
+
throw new Error(`Task ${task.id} is ${task.status}, not running`);
|
|
1118
|
+
}
|
|
1119
|
+
task.killKind = kind;
|
|
1120
|
+
if (reason) task.error = reason;
|
|
1121
|
+
this.requestKill(task, 'SIGTERM');
|
|
1122
|
+
const stopped = await this.waitForEnd(task, this.stopWaitMs);
|
|
1123
|
+
if (!stopped) {
|
|
1124
|
+
throw new Error(
|
|
1125
|
+
`Task ${task.id} did not exit within ${formatDuration(this.stopWaitMs)} after SIGTERM/SIGKILL`,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
return task;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async stopAllRunning(
|
|
1132
|
+
kind: KillKind,
|
|
1133
|
+
reason?: string,
|
|
1134
|
+
): Promise<{ stopped: number; failures: string[] }> {
|
|
1135
|
+
const running = this.allTasks().filter((task) => task.status === 'running');
|
|
1136
|
+
const failures: string[] = [];
|
|
1137
|
+
let stopped = 0;
|
|
1138
|
+
await Promise.all(
|
|
1139
|
+
running.map(async (task) => {
|
|
1140
|
+
try {
|
|
1141
|
+
await this.stopTask(task, kind, reason);
|
|
1142
|
+
stopped++;
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
failures.push(
|
|
1145
|
+
`${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
}),
|
|
1149
|
+
);
|
|
1150
|
+
return { stopped, failures };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async getTaskLogs(
|
|
1154
|
+
task: BgTask,
|
|
1155
|
+
maxBytes: number,
|
|
1156
|
+
tail: boolean,
|
|
1157
|
+
): Promise<{ text: string; details: BgLogsDetails }> {
|
|
1158
|
+
if (!existsSync(task.outputAbsPath)) {
|
|
1159
|
+
throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
|
|
1160
|
+
}
|
|
1161
|
+
const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
|
|
1162
|
+
const direction = tail ? 'tail' : 'head';
|
|
1163
|
+
let text = read.content.length > 0 ? read.content : '(no output yet)';
|
|
1164
|
+
if (read.truncated) {
|
|
1165
|
+
const omitted = read.totalBytes - read.bytesRead;
|
|
1166
|
+
const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
|
|
1167
|
+
text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
|
|
1168
|
+
} else {
|
|
1169
|
+
text += `\n\n[Full output: ${task.outputPath}]`;
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
text,
|
|
1173
|
+
details: {
|
|
1174
|
+
task: snapshot(task),
|
|
1175
|
+
path: task.outputPath,
|
|
1176
|
+
bytesRead: read.bytesRead,
|
|
1177
|
+
truncated: read.truncated,
|
|
1178
|
+
tail,
|
|
1179
|
+
},
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private async writeMetadata(task: BgTask): Promise<void> {
|
|
1184
|
+
await this.writeMetadataSnapshot(task, snapshot(task));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private async writeMetadataSnapshot(task: BgTask, value: BgTaskSnapshot): Promise<void> {
|
|
1188
|
+
const write = async () => {
|
|
1189
|
+
await writeJsonAtomic(task.metadataAbsPath, value);
|
|
1190
|
+
};
|
|
1191
|
+
const previous = task.metadataWriteChain ?? Promise.resolve();
|
|
1192
|
+
const next = previous.then(write, write);
|
|
1193
|
+
task.metadataWriteChain = next.catch(() => undefined);
|
|
1194
|
+
await next;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private ingestTelemetry(task: BgTask, text: string): void {
|
|
1198
|
+
if (!text) return;
|
|
1199
|
+
const telemetryText = `${task.contextUsageBuffer ?? ''}${text}`;
|
|
1200
|
+
let latestContext = task.contextUsage;
|
|
1201
|
+
let latestTokens = task.tokenUsage;
|
|
1202
|
+
let latestTools = task.toolUsage;
|
|
1203
|
+
let latestModel = task.model;
|
|
1204
|
+
for (const line of telemetryText.split(/\r?\n/)) {
|
|
1205
|
+
if (!line.includes('background-task-')) continue;
|
|
1206
|
+
const trimmed = line.trim();
|
|
1207
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
1208
|
+
try {
|
|
1209
|
+
const parsed = parseJsonText(trimmed);
|
|
1210
|
+
if (!isJsonObject(parsed)) continue;
|
|
1211
|
+
const payload: TelemetryControlPayload = parsed;
|
|
1212
|
+
if (payload.type === 'background-task-context-usage') {
|
|
1213
|
+
latestContext = normalizeContextUsage(payload) ?? latestContext;
|
|
1214
|
+
} else if (payload.type === 'background-task-telemetry') {
|
|
1215
|
+
latestContext = normalizeContextUsage(payload.contextUsage) ?? latestContext;
|
|
1216
|
+
latestTokens = normalizeTokenUsage(payload.tokenUsage) ?? latestTokens;
|
|
1217
|
+
latestTools = normalizeToolUsage(payload.toolUsage) ?? latestTools;
|
|
1218
|
+
latestModel = normalizeModel(payload.model) ?? latestModel;
|
|
1219
|
+
}
|
|
1220
|
+
} catch {
|
|
1221
|
+
// Ignore malformed optional telemetry; task output remains authoritative for debugging.
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const xmlMatches = telemetryText.matchAll(
|
|
1226
|
+
/<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi,
|
|
1227
|
+
);
|
|
1228
|
+
for (const match of xmlMatches) latestContext = parseContextUsageXml(match[0]) ?? latestContext;
|
|
1229
|
+
|
|
1230
|
+
const lastNewline = Math.max(telemetryText.lastIndexOf('\n'), telemetryText.lastIndexOf('\r'));
|
|
1231
|
+
let retained = lastNewline >= 0 ? telemetryText.slice(lastNewline + 1) : telemetryText;
|
|
1232
|
+
const lastXmlOpen = telemetryText.toLowerCase().lastIndexOf('<background-task-context-usage');
|
|
1233
|
+
const lastXmlClose = telemetryText
|
|
1234
|
+
.toLowerCase()
|
|
1235
|
+
.lastIndexOf('</background-task-context-usage>');
|
|
1236
|
+
if (lastXmlOpen > lastXmlClose) retained = telemetryText.slice(lastXmlOpen);
|
|
1237
|
+
task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
|
|
1238
|
+
|
|
1239
|
+
this.commitTelemetry(task, {
|
|
1240
|
+
context: latestContext,
|
|
1241
|
+
tokens: latestTokens,
|
|
1242
|
+
tools: latestTools,
|
|
1243
|
+
model: latestModel,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Apply the latest parsed telemetry to a task, persisting metadata and notifying the UI only on change. */
|
|
1248
|
+
private commitTelemetry(task: BgTask, next: TelemetryDelta): void {
|
|
1249
|
+
const before = JSON.stringify({
|
|
1250
|
+
contextUsage: task.contextUsage,
|
|
1251
|
+
tokenUsage: task.tokenUsage,
|
|
1252
|
+
toolUsage: task.toolUsage,
|
|
1253
|
+
model: task.model,
|
|
1254
|
+
});
|
|
1255
|
+
if (next.context !== undefined) task.contextUsage = next.context;
|
|
1256
|
+
if (next.tokens !== undefined) task.tokenUsage = next.tokens;
|
|
1257
|
+
if (next.tools !== undefined) task.toolUsage = next.tools;
|
|
1258
|
+
if (next.model !== undefined) task.model = next.model;
|
|
1259
|
+
const after = JSON.stringify({
|
|
1260
|
+
contextUsage: task.contextUsage,
|
|
1261
|
+
tokenUsage: task.tokenUsage,
|
|
1262
|
+
toolUsage: task.toolUsage,
|
|
1263
|
+
model: task.model,
|
|
1264
|
+
});
|
|
1265
|
+
if (before !== after) {
|
|
1266
|
+
this.onChange();
|
|
1267
|
+
void this.writeMetadata(task).catch((error: unknown) => {
|
|
1268
|
+
this.logger.error(
|
|
1269
|
+
`[background-tasks] failed to write telemetry metadata for ${task.id}:`,
|
|
1270
|
+
error,
|
|
1271
|
+
);
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Cap-enforcing sink for all persisted task output; terminates the task once the byte cap is exceeded. */
|
|
1277
|
+
private writeToStream(task: BgTask, buffer: Buffer): void {
|
|
1278
|
+
if (!task.stream || task.stream.destroyed) return;
|
|
1279
|
+
if (buffer.length === 0) return;
|
|
1280
|
+
|
|
1281
|
+
const nextBytes = task.bytesWritten + buffer.length;
|
|
1282
|
+
if (nextBytes <= this.maxOutputBytes) {
|
|
1283
|
+
task.stream.write(buffer);
|
|
1284
|
+
task.bytesWritten = nextBytes;
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const remaining = Math.max(0, this.maxOutputBytes - task.bytesWritten);
|
|
1289
|
+
if (remaining > 0) {
|
|
1290
|
+
task.stream.write(buffer.subarray(0, remaining));
|
|
1291
|
+
task.bytesWritten += remaining;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (!task.capExceeded) {
|
|
1295
|
+
task.capExceeded = true;
|
|
1296
|
+
task.error = `Output exceeded cap of ${formatSize(this.maxOutputBytes)}; terminating task`;
|
|
1297
|
+
const notice = `\n\n[background task error: ${task.error}]\n`;
|
|
1298
|
+
task.stream.write(notice);
|
|
1299
|
+
task.bytesWritten += Buffer.byteLength(notice, 'utf8');
|
|
1300
|
+
task.killKind = 'output_cap';
|
|
1301
|
+
try {
|
|
1302
|
+
this.requestKill(task, 'SIGTERM');
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1305
|
+
void this.finalizeTask(task, 'failed', null, undefined, task.error);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Persist an internally generated notice (spawn/timeout/cap diagnostics) verbatim. */
|
|
1311
|
+
private writeNotice(task: BgTask, text: string): void {
|
|
1312
|
+
if (!text) return;
|
|
1313
|
+
this.writeToStream(task, Buffer.from(text, 'utf8'));
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private appendChildOutput(
|
|
1317
|
+
task: BgTask,
|
|
1318
|
+
data: Buffer | string,
|
|
1319
|
+
source: 'stdout' | 'stderr',
|
|
1320
|
+
): void {
|
|
1321
|
+
if (!task.stream || task.stream.destroyed) return;
|
|
1322
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
1323
|
+
if (buffer.length === 0) return;
|
|
1324
|
+
if (task.telemetryWrapped) {
|
|
1325
|
+
// Wrapped Pi agents stream control lines on stdout (telemetry + activity); child
|
|
1326
|
+
// stderr is raw diagnostics and is always passed through to the transcript verbatim.
|
|
1327
|
+
if (source === 'stdout') this.processAgentStdout(task, buffer.toString('utf8'));
|
|
1328
|
+
else this.writeToStream(task, buffer);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
this.ingestTelemetry(task, buffer.toString('utf8'));
|
|
1332
|
+
this.writeToStream(task, buffer);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Reconstruct wrapped-agent stdout into whole control lines, routing telemetry to metrics and activity to the transcript. */
|
|
1336
|
+
private processAgentStdout(task: BgTask, text: string): void {
|
|
1337
|
+
const buffered = `${task.agentStdoutBuffer ?? ''}${text}`;
|
|
1338
|
+
const lastNewline = buffered.lastIndexOf('\n');
|
|
1339
|
+
task.agentStdoutBuffer = lastNewline >= 0 ? buffered.slice(lastNewline + 1) : buffered;
|
|
1340
|
+
if (lastNewline < 0) return;
|
|
1341
|
+
const latest: TelemetryDelta = {};
|
|
1342
|
+
for (const line of buffered.slice(0, lastNewline).split('\n'))
|
|
1343
|
+
this.consumeAgentLine(task, line, latest);
|
|
1344
|
+
this.commitTelemetry(task, latest);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Flush a trailing partial wrapped-agent line on finalize so the last transcript fragment is never lost. */
|
|
1348
|
+
private flushAgentStdout(task: BgTask): void {
|
|
1349
|
+
const remainder = task.agentStdoutBuffer;
|
|
1350
|
+
if (!remainder) return;
|
|
1351
|
+
task.agentStdoutBuffer = '';
|
|
1352
|
+
const latest: TelemetryDelta = {};
|
|
1353
|
+
this.consumeAgentLine(task, remainder, latest);
|
|
1354
|
+
this.commitTelemetry(task, latest);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private consumeAgentLine(task: BgTask, rawLine: string, latest: TelemetryDelta): void {
|
|
1358
|
+
const line = rawLine.replace(/\r$/, '');
|
|
1359
|
+
const trimmed = line.trim();
|
|
1360
|
+
if (!trimmed) return;
|
|
1361
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
|
1362
|
+
this.writeNotice(task, `${line}\n`);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
let parsed: unknown;
|
|
1366
|
+
try {
|
|
1367
|
+
parsed = parseJsonText(trimmed);
|
|
1368
|
+
} catch {
|
|
1369
|
+
this.writeNotice(task, `${line}\n`);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
if (!isJsonObject(parsed)) {
|
|
1373
|
+
this.writeNotice(task, `${line}\n`);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const record: TelemetryControlPayload = parsed;
|
|
1377
|
+
const type = record.type;
|
|
1378
|
+
if (type === 'background-task-context-usage') {
|
|
1379
|
+
const context = normalizeContextUsage(record);
|
|
1380
|
+
if (context) latest.context = context;
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (type === 'background-task-telemetry') {
|
|
1384
|
+
const context = normalizeContextUsage(record.contextUsage);
|
|
1385
|
+
if (context) latest.context = context;
|
|
1386
|
+
const tokens = normalizeTokenUsage(record.tokenUsage);
|
|
1387
|
+
if (tokens) latest.tokens = tokens;
|
|
1388
|
+
const tools = normalizeToolUsage(record.toolUsage);
|
|
1389
|
+
if (tools) latest.tools = tools;
|
|
1390
|
+
const model = normalizeModel(record.model);
|
|
1391
|
+
if (model) latest.model = model;
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const activity = parseAgentActivity(parsed);
|
|
1395
|
+
if (activity) {
|
|
1396
|
+
const formatted = formatAgentActivityLine(activity);
|
|
1397
|
+
if (formatted) this.writeNotice(task, `${formatted}\n`);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
// Unknown JSON object: pass through to the transcript rather than silently dropping it.
|
|
1401
|
+
this.writeNotice(task, `${line}\n`);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private requestKill(task: BgTask, signal: NodeJS.Signals = 'SIGTERM'): void {
|
|
1405
|
+
if (task.status !== 'running') {
|
|
1406
|
+
throw new Error(`Task ${task.id} is ${task.status}, not running`);
|
|
1407
|
+
}
|
|
1408
|
+
if (!task.child) {
|
|
1409
|
+
throw new Error(`Task ${task.id} has no child process handle`);
|
|
1410
|
+
}
|
|
1411
|
+
if (!task.pid) {
|
|
1412
|
+
throw new Error(`Task ${task.id} has no process id`);
|
|
1413
|
+
}
|
|
1414
|
+
if (task.killSignalSent && signal === 'SIGTERM') return;
|
|
1415
|
+
|
|
1416
|
+
const errors: string[] = [];
|
|
1417
|
+
let killed = false;
|
|
1418
|
+
|
|
1419
|
+
if (this.platform !== 'win32') {
|
|
1420
|
+
try {
|
|
1421
|
+
this.killProcess(-task.pid, signal);
|
|
1422
|
+
killed = true;
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
errors.push(
|
|
1425
|
+
`process group kill failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
if (!killed) {
|
|
1431
|
+
try {
|
|
1432
|
+
task.child.kill(signal);
|
|
1433
|
+
killed = true;
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
errors.push(`child kill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (!killed) {
|
|
1440
|
+
throw new Error(`Could not kill task ${task.id}: ${errors.join('; ')}`);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
task.killSignalSent = true;
|
|
1444
|
+
setTimeout(() => {
|
|
1445
|
+
if (task.status !== 'running') return;
|
|
1446
|
+
try {
|
|
1447
|
+
this.requestKill(task, 'SIGKILL');
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
task.error = `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1450
|
+
void this.writeMetadata(task).catch((metadataError: unknown) => {
|
|
1451
|
+
this.logger.error(
|
|
1452
|
+
`[background-tasks] failed to write metadata for ${task.id}:`,
|
|
1453
|
+
metadataError,
|
|
1454
|
+
);
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
}, this.killGraceMs).unref();
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
private waitForEnd(task: BgTask, timeoutMs: number): Promise<boolean> {
|
|
1461
|
+
if (task.status !== 'running') return Promise.resolve(true);
|
|
1462
|
+
return new Promise((resolve) => {
|
|
1463
|
+
const timeout = setTimeout(() => {
|
|
1464
|
+
const idx = task.waiters.indexOf(done);
|
|
1465
|
+
if (idx >= 0) task.waiters.splice(idx, 1);
|
|
1466
|
+
resolve(false);
|
|
1467
|
+
}, timeoutMs);
|
|
1468
|
+
const done = () => {
|
|
1469
|
+
clearTimeout(timeout);
|
|
1470
|
+
resolve(true);
|
|
1471
|
+
};
|
|
1472
|
+
task.waiters.push(done);
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
private publishTerminal(task: BgTask): void {
|
|
1477
|
+
if (task.terminalPublished || task.terminalPublishInFlight) return;
|
|
1478
|
+
task.terminalPublishInFlight = true;
|
|
1479
|
+
if (task.terminalPublicationGate === undefined) {
|
|
1480
|
+
this.tryPublishTerminalNow(task);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
void this.publishTerminalWhenReady(task);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private async publishTerminalWhenReady(task: BgTask): Promise<void> {
|
|
1487
|
+
try {
|
|
1488
|
+
await task.terminalPublicationGate;
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
this.handleTerminalPublishFailure(task, error);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
this.tryPublishTerminalNow(task);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
private tryPublishTerminalNow(task: BgTask): void {
|
|
1497
|
+
try {
|
|
1498
|
+
if (task.terminalPublished) return;
|
|
1499
|
+
this.publishTerminalSnapshot(snapshot(task));
|
|
1500
|
+
task.terminalPublished = true;
|
|
1501
|
+
if (task.terminalPublishRetryHandle) {
|
|
1502
|
+
clearTimeout(task.terminalPublishRetryHandle);
|
|
1503
|
+
task.terminalPublishRetryHandle = undefined;
|
|
1504
|
+
}
|
|
1505
|
+
} catch (error) {
|
|
1506
|
+
this.handleTerminalPublishFailure(task, error);
|
|
1507
|
+
return;
|
|
1508
|
+
} finally {
|
|
1509
|
+
task.terminalPublishInFlight = false;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
private handleTerminalPublishFailure(task: BgTask, error: unknown): void {
|
|
1514
|
+
this.logger.error(`[background-tasks] terminal publication failed for ${task.id}:`, error);
|
|
1515
|
+
task.terminalPublishInFlight = false;
|
|
1516
|
+
if (!task.terminalPublished && task.terminalPublishRetryHandle === undefined) {
|
|
1517
|
+
task.terminalPublishRetryHandle = setTimeout(() => {
|
|
1518
|
+
task.terminalPublishRetryHandle = undefined;
|
|
1519
|
+
this.publishTerminal(task);
|
|
1520
|
+
}, 100);
|
|
1521
|
+
task.terminalPublishRetryHandle.unref();
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
private notifyCompletion(task: BgTask): void {
|
|
1526
|
+
if (!task.notifyOnCompletion || task.notified || this.shuttingDown) return;
|
|
1527
|
+
task.notified = true;
|
|
1528
|
+
const exit =
|
|
1529
|
+
task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
|
|
1530
|
+
const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
|
|
1531
|
+
const taskName = taskDisplayName(task);
|
|
1532
|
+
const content = [
|
|
1533
|
+
'<background-task-notification>',
|
|
1534
|
+
` <task-id>${task.id}</task-id>`,
|
|
1535
|
+
` <task-name>${escapeXml(taskName)}</task-name>`,
|
|
1536
|
+
` <status>${task.status}</status>`,
|
|
1537
|
+
exit,
|
|
1538
|
+
error,
|
|
1539
|
+
` <output-file>${escapeXml(task.outputPath)}</output-file>`,
|
|
1540
|
+
` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
|
|
1541
|
+
'</background-task-notification>',
|
|
1542
|
+
]
|
|
1543
|
+
.filter(Boolean)
|
|
1544
|
+
.join('\n');
|
|
1545
|
+
|
|
1546
|
+
try {
|
|
1547
|
+
this.sendCompletionNotification(
|
|
1548
|
+
{
|
|
1549
|
+
customType: 'background-task-notification',
|
|
1550
|
+
content,
|
|
1551
|
+
display: true,
|
|
1552
|
+
details: snapshot(task),
|
|
1553
|
+
},
|
|
1554
|
+
{ deliverAs: 'followUp', triggerTurn: task.triggerOnCompletion },
|
|
1555
|
+
);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
task.notified = false;
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
`Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
private async finalizeTask(
|
|
1565
|
+
task: BgTask,
|
|
1566
|
+
status: TaskStatus,
|
|
1567
|
+
exitCode: number | null,
|
|
1568
|
+
signal?: string | null,
|
|
1569
|
+
error?: string,
|
|
1570
|
+
): Promise<void> {
|
|
1571
|
+
if (task.finalized) return;
|
|
1572
|
+
task.finalized = true;
|
|
1573
|
+
if (task.timeoutHandle) clearTimeout(task.timeoutHandle);
|
|
1574
|
+
let finalStatus = status;
|
|
1575
|
+
let finalError = error;
|
|
1576
|
+
task.exitCode = exitCode;
|
|
1577
|
+
task.signal = signal ?? null;
|
|
1578
|
+
|
|
1579
|
+
// Keep status="running" until the final wrapped-agent fragment has been
|
|
1580
|
+
// consumed and the output plus terminal metadata are durable. Publishing a
|
|
1581
|
+
// terminal state earlier lets bg_status observe the previous assistant
|
|
1582
|
+
// turn's context snapshot and recreates the same false-completion race the
|
|
1583
|
+
// attested producer is required to prevent.
|
|
1584
|
+
try {
|
|
1585
|
+
if (task.telemetryWrapped) {
|
|
1586
|
+
// Child-process close can be observed before the wrapper stdout listener has
|
|
1587
|
+
// committed its last parsed telemetry batch. Wait for a short quiet window,
|
|
1588
|
+
// then flush the trailing partial line, so completed status never races
|
|
1589
|
+
// ahead of the final assistant-turn context/token/tool snapshot.
|
|
1590
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
1591
|
+
this.flushAgentStdout(task);
|
|
1592
|
+
}
|
|
1593
|
+
if (task.stream && !task.stream.destroyed) await closeAndFsyncOutputStream(task.stream);
|
|
1594
|
+
} catch (finalizeError) {
|
|
1595
|
+
finalStatus = 'failed';
|
|
1596
|
+
const message =
|
|
1597
|
+
finalizeError instanceof Error ? finalizeError.message : String(finalizeError);
|
|
1598
|
+
finalError = finalError
|
|
1599
|
+
? `${finalError}; final output durability failed: ${message}`
|
|
1600
|
+
: `Final output durability failed: ${message}`;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
task.endTime = this.now();
|
|
1604
|
+
if (finalError) task.error = finalError;
|
|
1605
|
+
try {
|
|
1606
|
+
await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
|
|
1607
|
+
task.status = finalStatus;
|
|
1608
|
+
} catch (metadataError) {
|
|
1609
|
+
finalStatus = 'failed';
|
|
1610
|
+
task.status = 'failed';
|
|
1611
|
+
task.error = `Terminal metadata write failed: ${metadataError instanceof Error ? metadataError.message : String(metadataError)}`;
|
|
1612
|
+
this.logger.error(
|
|
1613
|
+
`[background-tasks] failed to write metadata for ${task.id}:`,
|
|
1614
|
+
metadataError,
|
|
1615
|
+
);
|
|
1616
|
+
await this.writeMetadata(task).catch((retryError: unknown) => {
|
|
1617
|
+
this.logger.error(
|
|
1618
|
+
`[background-tasks] failed to write failed terminal metadata for ${task.id}:`,
|
|
1619
|
+
retryError,
|
|
1620
|
+
);
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
for (const waiter of task.waiters.splice(0)) waiter();
|
|
1625
|
+
this.onChange();
|
|
1626
|
+
this.publishTerminal(task);
|
|
1627
|
+
try {
|
|
1628
|
+
this.notifyCompletion(task);
|
|
1629
|
+
} catch (notificationError) {
|
|
1630
|
+
this.logger.error(
|
|
1631
|
+
`[background-tasks] notification failed for ${task.id}:`,
|
|
1632
|
+
notificationError,
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
try {
|
|
1636
|
+
await this.writeMetadata(task);
|
|
1637
|
+
} catch (metadataError) {
|
|
1638
|
+
this.logger.error(
|
|
1639
|
+
`[background-tasks] failed to update notification metadata for ${task.id}:`,
|
|
1640
|
+
metadataError,
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
this.pruneOldTasks();
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private pruneOldTasks(): void {
|
|
1647
|
+
if (this.tasks.size <= this.maxRecentTasks) return;
|
|
1648
|
+
const removable = [...this.tasks.values()]
|
|
1649
|
+
.filter((task) => task.status !== 'running')
|
|
1650
|
+
.sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
|
|
1651
|
+
while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
|
|
1652
|
+
const task = removable.shift();
|
|
1653
|
+
if (task) this.tasks.delete(task.id);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
958
1656
|
}
|