@pi-unipi/background-tasks 2.6.2 → 2.6.4
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/package.json +3 -2
- package/src/tools.ts +3 -1
- package/src/__tests__/anthropic-attribution.test.ts +0 -195
- package/src/__tests__/config.test.ts +0 -137
- package/src/__tests__/core.test.ts +0 -493
- package/src/__tests__/delegate-artifacts.test.ts +0 -528
- package/src/__tests__/delegate-budget.test.ts +0 -456
- package/src/__tests__/delegate-launch.test.ts +0 -676
- package/src/__tests__/delegate-result-package.test.ts +0 -350
- package/src/__tests__/delegate-seed.test.ts +0 -392
- package/src/__tests__/durable-fs.test.ts +0 -559
- package/src/__tests__/extension-api.test.ts +0 -579
- package/src/__tests__/fusion-artifacts.test.ts +0 -1039
- package/src/__tests__/fusion-budget.test.ts +0 -1356
- package/src/__tests__/fusion-claude-cache.test.ts +0 -320
- package/src/__tests__/fusion-config.test.ts +0 -335
- package/src/__tests__/fusion-context-prompts.test.ts +0 -670
- package/src/__tests__/fusion-evaluation.test.ts +0 -315
- package/src/__tests__/fusion-extraction-equivalence.test.ts +0 -58
- package/src/__tests__/fusion-golden-bytes.test.ts +0 -35
- package/src/__tests__/fusion-high-cardinality.test.ts +0 -192
- package/src/__tests__/fusion-model-selector.test.ts +0 -205
- package/src/__tests__/fusion-orchestrator.test.ts +0 -1194
- package/src/__tests__/fusion-rpc.test.ts +0 -369
- package/src/__tests__/fusion-sdk.test.ts +0 -1226
- package/src/__tests__/fusion-v5-core.test.ts +0 -219
- package/src/__tests__/fusion-validate-orchestrator.test.ts +0 -240
- package/src/__tests__/fusion-web-fetch.test.ts +0 -485
- package/src/__tests__/fusion-workflows.test.ts +0 -59
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +0 -109
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +0 -10
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +0 -21
- package/src/__tests__/helpers/fusion-canonical.ts +0 -140
- package/src/__tests__/helpers/fusion-fake-pi.ts +0 -279
- package/src/__tests__/helpers/fusion-golden-corpus.ts +0 -500
- package/src/__tests__/helpers/fusion-high-cardinality.ts +0 -140
- package/src/__tests__/helpers/normalize.ts +0 -22
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +0 -18
- package/src/__tests__/pi-launch.test.ts +0 -202
- package/src/__tests__/registry.test.ts +0 -1580
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +0 -130
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +0 -631
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +0 -403
- package/src/__tests__/scripted-provider/follow-up.test.ts +0 -448
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +0 -132
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +0 -310
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +0 -163
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +0 -179
- package/src/__tests__/scripted-provider/hook-probe-a.ts +0 -3
- package/src/__tests__/scripted-provider/hook-probe-b.ts +0 -3
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +0 -126
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +0 -153
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +0 -18
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +0 -477
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +0 -28
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +0 -49
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +0 -408
- package/src/__tests__/task-manager.test.ts +0 -479
- package/src/__tests__/windows-taskkill.test.ts +0 -161
|
@@ -1,1580 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { EventEmitter } from 'node:events';
|
|
4
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
7
|
-
import { basename, dirname, join } from 'node:path';
|
|
8
|
-
import { tmpdir } from 'node:os';
|
|
9
|
-
import { parseJsonText } from '../types.js';
|
|
10
|
-
import {
|
|
11
|
-
BackgroundTaskRegistry,
|
|
12
|
-
WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON,
|
|
13
|
-
commandMayLaunchPiAgent,
|
|
14
|
-
type BackgroundTaskContext,
|
|
15
|
-
type BackgroundTaskSpawn,
|
|
16
|
-
type CompletionNotificationMessage,
|
|
17
|
-
type CompletionNotificationOptions,
|
|
18
|
-
} from '../registry.js';
|
|
19
|
-
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
20
|
-
import type { BgTask, BgTaskSnapshot } from '../types.js';
|
|
21
|
-
import type { TaskkillOutcome, WindowsKillPhase } from '../windows-taskkill.js';
|
|
22
|
-
import type { BackgroundTaskChildProcess, BackgroundTaskSpawnOptions } from '../child-process.js';
|
|
23
|
-
|
|
24
|
-
type JsonObject = Record<PropertyKey, unknown>;
|
|
25
|
-
|
|
26
|
-
function isJsonObject(value: unknown): value is JsonObject {
|
|
27
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function parseJsonObject(text: string, message: string): JsonObject {
|
|
31
|
-
const parsed = parseJsonText(text);
|
|
32
|
-
assert.ok(isJsonObject(parsed), message);
|
|
33
|
-
return parsed;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function requiredJsonObject(value: unknown, message: string): JsonObject {
|
|
37
|
-
assert.ok(isJsonObject(value), message);
|
|
38
|
-
return value;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
class FakeChild extends EventEmitter {
|
|
42
|
-
stdout = new EventEmitter();
|
|
43
|
-
stderr = new EventEmitter();
|
|
44
|
-
pid: number;
|
|
45
|
-
killCalls: Array<NodeJS.Signals | undefined> = [];
|
|
46
|
-
killImpl: (signal?: NodeJS.Signals) => boolean;
|
|
47
|
-
|
|
48
|
-
constructor(pid: number, killImpl?: (signal?: NodeJS.Signals) => boolean) {
|
|
49
|
-
super();
|
|
50
|
-
this.pid = pid;
|
|
51
|
-
this.killImpl = killImpl ?? (() => true);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
kill(signal?: NodeJS.Signals): boolean {
|
|
55
|
-
this.killCalls.push(signal);
|
|
56
|
-
return this.killImpl(signal);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
writeStdout(value: string): void {
|
|
60
|
-
this.stdout.emit('data', Buffer.from(value, 'utf8'));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
writeStderr(value: string): void {
|
|
64
|
-
this.stderr.emit('data', Buffer.from(value, 'utf8'));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
close(code: number | null = 0, signal: NodeJS.Signals | null = null): void {
|
|
68
|
-
this.emit('close', code, signal);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
fail(error: Error): void {
|
|
72
|
-
this.emit('error', error);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface SpawnRecord {
|
|
77
|
-
child: FakeChild;
|
|
78
|
-
shell: string;
|
|
79
|
-
args: string[];
|
|
80
|
-
options: BackgroundTaskSpawnOptions;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface HarnessOptions {
|
|
84
|
-
platform?: NodeJS.Platform;
|
|
85
|
-
maxRecentTasks?: number;
|
|
86
|
-
maxOutputBytes?: number;
|
|
87
|
-
killGraceMs?: number;
|
|
88
|
-
stopWaitMs?: number;
|
|
89
|
-
killProcess?: (pid: number, signal?: NodeJS.Signals | number) => boolean;
|
|
90
|
-
killTree?: (
|
|
91
|
-
pid: number,
|
|
92
|
-
phase: WindowsKillPhase,
|
|
93
|
-
signal?: AbortSignal,
|
|
94
|
-
) => Promise<TaskkillOutcome>;
|
|
95
|
-
sendCompletionNotification?: (
|
|
96
|
-
message: CompletionNotificationMessage,
|
|
97
|
-
options: CompletionNotificationOptions,
|
|
98
|
-
) => void;
|
|
99
|
-
publishTerminal?: (task: BgTaskSnapshot) => void;
|
|
100
|
-
logger?: Pick<Console, 'error'>;
|
|
101
|
-
makeTaskId?: () => string;
|
|
102
|
-
now?: () => number;
|
|
103
|
-
env?: NodeJS.ProcessEnv;
|
|
104
|
-
childFactory?: (pid: number) => FakeChild;
|
|
105
|
-
modelRegistry?: BackgroundTaskContext['modelRegistry'];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function createHarness(options: HarnessOptions = {}) {
|
|
109
|
-
const root = await mkdtemp(join(tmpdir(), 'pi-bg-registry-'));
|
|
110
|
-
const cwd = join(root, 'project');
|
|
111
|
-
await mkdir(cwd, { recursive: true });
|
|
112
|
-
let pid = 4200;
|
|
113
|
-
let idSeq = 0;
|
|
114
|
-
const children: SpawnRecord[] = [];
|
|
115
|
-
const notifications: Array<{
|
|
116
|
-
message: CompletionNotificationMessage;
|
|
117
|
-
options: CompletionNotificationOptions;
|
|
118
|
-
}> = [];
|
|
119
|
-
const errors: unknown[][] = [];
|
|
120
|
-
let changes = 0;
|
|
121
|
-
const registryOptions: ConstructorParameters<typeof BackgroundTaskRegistry>[0] = {
|
|
122
|
-
logger: options.logger ?? {
|
|
123
|
-
error: (...args: unknown[]) => {
|
|
124
|
-
errors.push(args);
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
makeTaskId: options.makeTaskId ?? (() => `bunit${String(++idSeq).padStart(3, '0')}`),
|
|
128
|
-
sendCompletionNotification:
|
|
129
|
-
options.sendCompletionNotification ??
|
|
130
|
-
((message, opts) => {
|
|
131
|
-
notifications.push({ message, options: opts });
|
|
132
|
-
}),
|
|
133
|
-
onChange: () => {
|
|
134
|
-
changes++;
|
|
135
|
-
},
|
|
136
|
-
spawn: (shell, args, spawnOptions) => {
|
|
137
|
-
const child = options.childFactory?.(++pid) ?? new FakeChild(++pid);
|
|
138
|
-
children.push({ child, shell, args: [...args], options: spawnOptions ?? {} });
|
|
139
|
-
return child as unknown as BackgroundTaskChildProcess;
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
if (options.publishTerminal !== undefined)
|
|
143
|
-
registryOptions.publishTerminal = options.publishTerminal;
|
|
144
|
-
if (options.platform !== undefined) registryOptions.platform = options.platform;
|
|
145
|
-
if (options.env !== undefined) registryOptions.env = options.env;
|
|
146
|
-
if (options.maxRecentTasks !== undefined) registryOptions.maxRecentTasks = options.maxRecentTasks;
|
|
147
|
-
if (options.maxOutputBytes !== undefined) registryOptions.maxOutputBytes = options.maxOutputBytes;
|
|
148
|
-
if (options.killGraceMs !== undefined) registryOptions.killGraceMs = options.killGraceMs;
|
|
149
|
-
if (options.stopWaitMs !== undefined) registryOptions.stopWaitMs = options.stopWaitMs;
|
|
150
|
-
if (options.now !== undefined) registryOptions.now = options.now;
|
|
151
|
-
if (options.killProcess !== undefined) registryOptions.killProcess = options.killProcess;
|
|
152
|
-
if (options.killTree !== undefined) registryOptions.killTree = options.killTree;
|
|
153
|
-
const registry = new BackgroundTaskRegistry(registryOptions);
|
|
154
|
-
const ctx: BackgroundTaskContext = {
|
|
155
|
-
cwd,
|
|
156
|
-
sessionId: 'registry-test',
|
|
157
|
-
modelRegistry: options.modelRegistry ?? { getAll: () => [] },
|
|
158
|
-
model: undefined,
|
|
159
|
-
};
|
|
160
|
-
return {
|
|
161
|
-
root,
|
|
162
|
-
cwd,
|
|
163
|
-
ctx,
|
|
164
|
-
registry,
|
|
165
|
-
children,
|
|
166
|
-
notifications,
|
|
167
|
-
errors,
|
|
168
|
-
get changes() {
|
|
169
|
-
return changes;
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function git(cwd: string, args: string[]): void {
|
|
175
|
-
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
176
|
-
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function initCleanGit(cwd: string): Promise<void> {
|
|
180
|
-
git(cwd, ['init']);
|
|
181
|
-
git(cwd, ['config', 'user.email', 'pi-bg@example.invalid']);
|
|
182
|
-
git(cwd, ['config', 'user.name', 'Pi BG Tests']);
|
|
183
|
-
await writeFile(join(cwd, 'README.md'), 'clean\n', 'utf8');
|
|
184
|
-
await writeFile(join(cwd, '.gitignore'), '.pi/\nreport.md\n', 'utf8');
|
|
185
|
-
git(cwd, ['add', 'README.md', '.gitignore']);
|
|
186
|
-
git(cwd, ['commit', '-m', 'init']);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function oauthModel(provider = 'openai-codex', modelId = 'gpt-5.5'): Model<Api> {
|
|
190
|
-
return {
|
|
191
|
-
id: modelId,
|
|
192
|
-
name: modelId,
|
|
193
|
-
api: provider === 'anthropic' ? 'anthropic-messages' : 'openai-codex-responses',
|
|
194
|
-
provider,
|
|
195
|
-
baseUrl: 'https://example.invalid',
|
|
196
|
-
reasoning: false,
|
|
197
|
-
input: ['text'],
|
|
198
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
199
|
-
contextWindow: 100000,
|
|
200
|
-
maxTokens: 4096,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function oauthRegistry(model = oauthModel()): BackgroundTaskContext['modelRegistry'] {
|
|
205
|
-
return {
|
|
206
|
-
getAll: () => [model],
|
|
207
|
-
find: (provider, modelId) =>
|
|
208
|
-
provider === model.provider && modelId === model.id ? model : undefined,
|
|
209
|
-
isUsingOAuth: () => true,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function piJsonEvents(provider = 'openai-codex', model = 'gpt-5.5'): string {
|
|
214
|
-
return (
|
|
215
|
-
[
|
|
216
|
-
{
|
|
217
|
-
type: 'session',
|
|
218
|
-
version: 3,
|
|
219
|
-
id: 'pi-session-unit',
|
|
220
|
-
timestamp: '2026-01-01T00:00:00.000Z',
|
|
221
|
-
cwd: '/unit',
|
|
222
|
-
},
|
|
223
|
-
{ type: 'agent_start' },
|
|
224
|
-
{
|
|
225
|
-
type: 'message_end',
|
|
226
|
-
message: {
|
|
227
|
-
role: 'assistant',
|
|
228
|
-
provider,
|
|
229
|
-
model,
|
|
230
|
-
usage: {
|
|
231
|
-
input: 10,
|
|
232
|
-
output: 4,
|
|
233
|
-
cacheRead: 0,
|
|
234
|
-
cacheWrite: 1,
|
|
235
|
-
totalTokens: 15,
|
|
236
|
-
cost: { total: 0.12 },
|
|
237
|
-
},
|
|
238
|
-
content: [{ type: 'text', text: 'attested done' }],
|
|
239
|
-
stopReason: 'stop',
|
|
240
|
-
},
|
|
241
|
-
},
|
|
242
|
-
{ type: 'agent_end', messages: [] },
|
|
243
|
-
]
|
|
244
|
-
.map((event) => JSON.stringify(event))
|
|
245
|
-
.join('\n') + '\n'
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function cleanup(root: string) {
|
|
250
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
251
|
-
try {
|
|
252
|
-
await rm(root, { recursive: true, force: true });
|
|
253
|
-
return;
|
|
254
|
-
} catch (error) {
|
|
255
|
-
if (!(error instanceof Error) || !/ENOTEMPTY/.test(error.message) || attempt === 4)
|
|
256
|
-
throw error;
|
|
257
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
async function waitFor(
|
|
263
|
-
predicate: () => boolean,
|
|
264
|
-
message = 'condition',
|
|
265
|
-
timeoutMs = 1000,
|
|
266
|
-
): Promise<void> {
|
|
267
|
-
const start = Date.now();
|
|
268
|
-
while (Date.now() - start < timeoutMs) {
|
|
269
|
-
if (predicate()) return;
|
|
270
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
271
|
-
}
|
|
272
|
-
throw new Error(`Timed out waiting for ${message}`);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function readJsonEventually(path: string, timeoutMs = 1000): Promise<JsonObject> {
|
|
276
|
-
const start = Date.now();
|
|
277
|
-
let last = '';
|
|
278
|
-
while (Date.now() - start < timeoutMs) {
|
|
279
|
-
last = await readFile(path, 'utf8').catch(() => '');
|
|
280
|
-
try {
|
|
281
|
-
if (last.trim()) return parseJsonObject(last, 'metadata JSON must be an object');
|
|
282
|
-
} catch (error) {
|
|
283
|
-
if (!(error instanceof SyntaxError)) throw error;
|
|
284
|
-
}
|
|
285
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
286
|
-
}
|
|
287
|
-
return parseJsonObject(last, 'metadata JSON must be an object');
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function lastSpawn(h: Awaited<ReturnType<typeof createHarness>>): SpawnRecord {
|
|
291
|
-
const spawn = h.children.at(-1);
|
|
292
|
-
assert.ok(spawn, 'test harness should have recorded a child process spawn');
|
|
293
|
-
return spawn;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function taskkillOutcome(exitCode: number | null, stderr = ''): TaskkillOutcome {
|
|
297
|
-
return {
|
|
298
|
-
exitCode,
|
|
299
|
-
signal: null,
|
|
300
|
-
stdout: '',
|
|
301
|
-
stderr,
|
|
302
|
-
stdoutTruncated: false,
|
|
303
|
-
stderrTruncated: false,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
interface Deferred<T> {
|
|
308
|
-
promise: Promise<T>;
|
|
309
|
-
resolve(value: T): void;
|
|
310
|
-
reject(error: unknown): void;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function deferred<T>(): Deferred<T> {
|
|
314
|
-
let resolveFn: ((value: T) => void) | undefined;
|
|
315
|
-
let rejectFn: ((error: unknown) => void) | undefined;
|
|
316
|
-
const promise = new Promise<T>((resolve, reject) => {
|
|
317
|
-
resolveFn = resolve;
|
|
318
|
-
rejectFn = reject;
|
|
319
|
-
});
|
|
320
|
-
assert.ok(resolveFn, 'deferred resolve should initialize');
|
|
321
|
-
assert.ok(rejectFn, 'deferred reject should initialize');
|
|
322
|
-
return { promise, resolve: resolveFn, reject: rejectFn };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function isKillRequester(value: unknown): value is (task: BgTask, signal?: NodeJS.Signals) => void {
|
|
326
|
-
return typeof value === 'function';
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function requestKillForTest(
|
|
330
|
-
registry: BackgroundTaskRegistry,
|
|
331
|
-
task: BgTask,
|
|
332
|
-
signal?: NodeJS.Signals,
|
|
333
|
-
): void {
|
|
334
|
-
const method = Reflect.get(registry, 'requestKill');
|
|
335
|
-
assert.ok(isKillRequester(method), 'registry requestKill should be callable');
|
|
336
|
-
method.call(registry, task, signal);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function startFakeTask(
|
|
340
|
-
h: Awaited<ReturnType<typeof createHarness>>,
|
|
341
|
-
name = 'Registry Task',
|
|
342
|
-
): Promise<{ task: BgTask; child: FakeChild }> {
|
|
343
|
-
const task = await h.registry.startTask(h.ctx, 'node fake.js', {
|
|
344
|
-
name,
|
|
345
|
-
isAgent: false,
|
|
346
|
-
notifyOnCompletion: true,
|
|
347
|
-
triggerOnCompletion: true,
|
|
348
|
-
});
|
|
349
|
-
return { task, child: lastSpawn(h).child };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
void describe('BackgroundTaskRegistry', () => {
|
|
353
|
-
void it('preserves full shell command bytes except surrounding whitespace', async () => {
|
|
354
|
-
const h = await createHarness({ platform: 'linux' });
|
|
355
|
-
try {
|
|
356
|
-
const command = `'${process.execPath}' '${join(h.cwd, 'bin', 'autopilot-agent-run.mjs')}' --spec '${join(h.cwd, 'specs', 'unit spec.json')}'`;
|
|
357
|
-
const task = await h.registry.startTask(h.ctx, ` ${command} `, {
|
|
358
|
-
name: 'Quoted Runner',
|
|
359
|
-
isAgent: true,
|
|
360
|
-
notifyOnCompletion: false,
|
|
361
|
-
});
|
|
362
|
-
const spawn = lastSpawn(h);
|
|
363
|
-
assert.equal(task.command, command);
|
|
364
|
-
assert.equal(spawn.args.at(-1), command);
|
|
365
|
-
assert.equal(JSON.parse(readFileSync(task.metadataAbsPath, 'utf8')).command, command);
|
|
366
|
-
} finally {
|
|
367
|
-
await cleanup(h.root);
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
void it('uses explicit isAgent to decide Pi telemetry wrapping', async () => {
|
|
372
|
-
assert.equal(commandMayLaunchPiAgent('pi -p hello'), true);
|
|
373
|
-
assert.equal(
|
|
374
|
-
commandMayLaunchPiAgent('/usr/local/bin/pi -p hello'),
|
|
375
|
-
false,
|
|
376
|
-
'shell-function wrapper cannot intercept path-qualified pi commands',
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const h = await createHarness({ platform: 'linux' });
|
|
380
|
-
try {
|
|
381
|
-
const scriptLikePi = await h.registry.startTask(h.ctx, 'pi -p hello', {
|
|
382
|
-
name: 'Plain Pi Script',
|
|
383
|
-
isAgent: false,
|
|
384
|
-
notifyOnCompletion: false,
|
|
385
|
-
});
|
|
386
|
-
assert.equal(scriptLikePi.isAgent, false);
|
|
387
|
-
assert.doesNotMatch(lastSpawn(h).args.join('\n'), /pi-telemetry-wrapper/);
|
|
388
|
-
|
|
389
|
-
const agentPi = await h.registry.startTask(h.ctx, 'pi -p hello', {
|
|
390
|
-
name: 'Agent Pi',
|
|
391
|
-
isAgent: true,
|
|
392
|
-
notifyOnCompletion: false,
|
|
393
|
-
});
|
|
394
|
-
assert.equal(agentPi.isAgent, true);
|
|
395
|
-
const wrappedCommand = lastSpawn(h).args.join('\n');
|
|
396
|
-
assert.match(wrappedCommand, /pi\(\) \{ .*pi-telemetry-wrapper\.cjs/);
|
|
397
|
-
assert.ok(wrappedCommand.includes(process.execPath));
|
|
398
|
-
assert.doesNotMatch(wrappedCommand, /pi\(\) \{ node /);
|
|
399
|
-
const wrapperPath = join(
|
|
400
|
-
dirname(agentPi.outputAbsPath),
|
|
401
|
-
`${agentPi.id}.pi-telemetry-wrapper.cjs`,
|
|
402
|
-
);
|
|
403
|
-
const wrapperSource = await readFile(wrapperPath, 'utf8');
|
|
404
|
-
assert.match(wrapperSource, /const launch = /);
|
|
405
|
-
assert.match(wrapperSource, /spawn\(launch\.executable, childArgs, \{[^}]*shell: false/);
|
|
406
|
-
assert.doesNotMatch(wrapperSource, /spawn\("pi"/);
|
|
407
|
-
assert.doesNotThrow(
|
|
408
|
-
() => new Function('require', 'process', wrapperSource.replace(/^#!.*\n/, '')),
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
const pathQualifiedPi = await h.registry.startTask(h.ctx, '/usr/local/bin/pi -p hello', {
|
|
412
|
-
name: 'Path Pi',
|
|
413
|
-
isAgent: true,
|
|
414
|
-
notifyOnCompletion: false,
|
|
415
|
-
});
|
|
416
|
-
assert.equal(pathQualifiedPi.isAgent, true);
|
|
417
|
-
assert.doesNotMatch(lastSpawn(h).args.join('\n'), /pi-telemetry-wrapper/);
|
|
418
|
-
} finally {
|
|
419
|
-
await cleanup(h.root);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const disabled = await createHarness({
|
|
423
|
-
env: { ...process.env, UNIPI_BG_DISABLE_PI_TELEMETRY: '1' },
|
|
424
|
-
});
|
|
425
|
-
try {
|
|
426
|
-
await disabled.registry.startTask(disabled.ctx, 'pi -p hello', {
|
|
427
|
-
name: 'Disabled Agent',
|
|
428
|
-
isAgent: true,
|
|
429
|
-
notifyOnCompletion: false,
|
|
430
|
-
});
|
|
431
|
-
assert.doesNotMatch(lastSpawn(disabled).args.join('\n'), /pi-telemetry-wrapper/);
|
|
432
|
-
} finally {
|
|
433
|
-
await cleanup(disabled.root);
|
|
434
|
-
}
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
void it('leaves Pi agent commands unchanged under Windows cmd and records telemetry unavailability', async () => {
|
|
438
|
-
const h = await createHarness({
|
|
439
|
-
platform: 'win32',
|
|
440
|
-
env: { ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
|
|
441
|
-
});
|
|
442
|
-
try {
|
|
443
|
-
const command = 'pi --mode json "hello & echo pwned"';
|
|
444
|
-
const task = await h.registry.startTask(h.ctx, command, {
|
|
445
|
-
name: 'Cmd Pi Agent',
|
|
446
|
-
isAgent: true,
|
|
447
|
-
notifyOnCompletion: false,
|
|
448
|
-
});
|
|
449
|
-
const spawn = lastSpawn(h);
|
|
450
|
-
assert.equal(task.command, command);
|
|
451
|
-
assert.equal(spawn.shell, 'C:\\Windows\\System32\\cmd.exe');
|
|
452
|
-
assert.deepEqual(spawn.args, ['/d', '/s', '/c', `"${command}"`]);
|
|
453
|
-
assert.equal(spawn.options.shell, undefined);
|
|
454
|
-
assert.equal(spawn.options.windowsVerbatimArguments, true);
|
|
455
|
-
assert.equal(task.telemetryWrapped, undefined);
|
|
456
|
-
assert.equal(task.telemetryUnavailableReason, WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON);
|
|
457
|
-
const files = await readdir(dirname(task.outputAbsPath));
|
|
458
|
-
assert.equal(
|
|
459
|
-
files.some((file) => file.includes('pi-telemetry-wrapper')),
|
|
460
|
-
false,
|
|
461
|
-
);
|
|
462
|
-
const metadata = parseJsonObject(
|
|
463
|
-
await readFile(task.metadataAbsPath, 'utf8'),
|
|
464
|
-
'metadata must be an object',
|
|
465
|
-
);
|
|
466
|
-
assert.equal(
|
|
467
|
-
metadata['telemetryUnavailableReason'],
|
|
468
|
-
WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON,
|
|
469
|
-
);
|
|
470
|
-
spawn.child.close(0, null);
|
|
471
|
-
await waitFor(() => task.status === 'completed', 'cmd telemetry task completion');
|
|
472
|
-
assert.equal(await readFile(task.outputAbsPath, 'utf8'), '');
|
|
473
|
-
} finally {
|
|
474
|
-
await cleanup(h.root);
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
void it('rejects unresolved Windows bash before creating a task', async () => {
|
|
479
|
-
const h = await createHarness({ platform: 'win32', env: { UNIPI_BG_SHELL: 'bash', PATH: '' } });
|
|
480
|
-
try {
|
|
481
|
-
await assert.rejects(
|
|
482
|
-
h.registry.startTask(h.ctx, 'echo ok', { name: 'Bad Bash', notifyOnCompletion: false }),
|
|
483
|
-
/could not resolve bash/,
|
|
484
|
-
);
|
|
485
|
-
assert.equal(h.children.length, 0);
|
|
486
|
-
assert.equal(h.registry.allTasks().length, 0);
|
|
487
|
-
} finally {
|
|
488
|
-
await cleanup(h.root);
|
|
489
|
-
}
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
void it('uses POSIX process-group kill before child fallback', async () => {
|
|
493
|
-
let childRef: FakeChild | undefined;
|
|
494
|
-
const killCalls: Array<{ pid: number; signal?: NodeJS.Signals | number }> = [];
|
|
495
|
-
const h = await createHarness({
|
|
496
|
-
platform: 'darwin',
|
|
497
|
-
killProcess: (pid, signal) => {
|
|
498
|
-
const call: { pid: number; signal?: NodeJS.Signals | number } = { pid };
|
|
499
|
-
if (signal !== undefined) call.signal = signal;
|
|
500
|
-
killCalls.push(call);
|
|
501
|
-
queueMicrotask(() => {
|
|
502
|
-
childRef?.close(null, typeof signal === 'string' ? signal : null);
|
|
503
|
-
});
|
|
504
|
-
return true;
|
|
505
|
-
},
|
|
506
|
-
childFactory: (pid) => {
|
|
507
|
-
childRef = new FakeChild(pid);
|
|
508
|
-
return childRef;
|
|
509
|
-
},
|
|
510
|
-
});
|
|
511
|
-
try {
|
|
512
|
-
const { task, child } = await startFakeTask(h);
|
|
513
|
-
await h.registry.stopTask(task, 'user');
|
|
514
|
-
assert.deepEqual(killCalls, [{ pid: -child.pid, signal: 'SIGTERM' }]);
|
|
515
|
-
assert.deepEqual(child.killCalls, []);
|
|
516
|
-
assert.equal(task.status, 'killed');
|
|
517
|
-
} finally {
|
|
518
|
-
await cleanup(h.root);
|
|
519
|
-
}
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
void it('falls back to child.kill when process-group kill fails and reports when both fail', async () => {
|
|
523
|
-
const h = await createHarness({
|
|
524
|
-
platform: 'linux',
|
|
525
|
-
killProcess: () => {
|
|
526
|
-
throw new Error('group unavailable');
|
|
527
|
-
},
|
|
528
|
-
childFactory: (pid) =>
|
|
529
|
-
new FakeChild(pid, function (this: FakeChild, signal) {
|
|
530
|
-
queueMicrotask(() => {
|
|
531
|
-
this.close(null, signal ?? null);
|
|
532
|
-
});
|
|
533
|
-
return true;
|
|
534
|
-
}),
|
|
535
|
-
});
|
|
536
|
-
try {
|
|
537
|
-
const { task, child } = await startFakeTask(h, 'Fallback Kill');
|
|
538
|
-
await h.registry.stopTask(task, 'user');
|
|
539
|
-
assert.deepEqual(child.killCalls, ['SIGTERM']);
|
|
540
|
-
assert.equal(task.status, 'killed');
|
|
541
|
-
} finally {
|
|
542
|
-
await cleanup(h.root);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const failing = await createHarness({
|
|
546
|
-
platform: 'linux',
|
|
547
|
-
killProcess: () => {
|
|
548
|
-
throw new Error('group unavailable');
|
|
549
|
-
},
|
|
550
|
-
childFactory: (pid) =>
|
|
551
|
-
new FakeChild(pid, () => {
|
|
552
|
-
throw new Error('child unavailable');
|
|
553
|
-
}),
|
|
554
|
-
});
|
|
555
|
-
try {
|
|
556
|
-
const { task } = await startFakeTask(failing, 'Failed Kill');
|
|
557
|
-
await assert.rejects(
|
|
558
|
-
() => failing.registry.stopTask(task, 'user'),
|
|
559
|
-
/Could not kill task[\s\S]*group unavailable[\s\S]*child unavailable/,
|
|
560
|
-
);
|
|
561
|
-
assert.equal(task.status, 'running');
|
|
562
|
-
} finally {
|
|
563
|
-
await cleanup(failing.root);
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
void it('uses taskkill tree termination on Windows and never falls back to child.kill', async () => {
|
|
568
|
-
let processKillCalled = false;
|
|
569
|
-
let childRef: FakeChild | undefined;
|
|
570
|
-
const killTreeCalls: Array<{ pid: number; phase: WindowsKillPhase }> = [];
|
|
571
|
-
const h = await createHarness({
|
|
572
|
-
platform: 'win32',
|
|
573
|
-
killGraceMs: 20,
|
|
574
|
-
stopWaitMs: 500,
|
|
575
|
-
killProcess: () => {
|
|
576
|
-
processKillCalled = true;
|
|
577
|
-
return true;
|
|
578
|
-
},
|
|
579
|
-
killTree: (pid, phase) => {
|
|
580
|
-
killTreeCalls.push({ pid, phase });
|
|
581
|
-
if (phase === 'force') {
|
|
582
|
-
queueMicrotask(() => {
|
|
583
|
-
childRef?.close(null, 'SIGKILL');
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
return Promise.resolve(taskkillOutcome(0));
|
|
587
|
-
},
|
|
588
|
-
childFactory: (pid) => {
|
|
589
|
-
childRef = new FakeChild(pid, () => {
|
|
590
|
-
throw new Error('root-only kill must not run');
|
|
591
|
-
});
|
|
592
|
-
return childRef;
|
|
593
|
-
},
|
|
594
|
-
});
|
|
595
|
-
try {
|
|
596
|
-
const { task, child } = await startFakeTask(h, 'Windows Kill');
|
|
597
|
-
await h.registry.stopTask(task, 'user');
|
|
598
|
-
assert.equal(processKillCalled, false);
|
|
599
|
-
assert.deepEqual(killTreeCalls, [
|
|
600
|
-
{ pid: child.pid, phase: 'terminate' },
|
|
601
|
-
{ pid: child.pid, phase: 'force' },
|
|
602
|
-
]);
|
|
603
|
-
assert.deepEqual(child.killCalls, []);
|
|
604
|
-
const windowsSpawn = h.children[0];
|
|
605
|
-
assert.ok(windowsSpawn, 'Windows shell spawn should be recorded');
|
|
606
|
-
// ComSpec is a full path on a real Windows host, so compare the basename.
|
|
607
|
-
assert.equal(basename(windowsSpawn.shell).toLowerCase(), 'cmd.exe');
|
|
608
|
-
assert.deepEqual(windowsSpawn.args.slice(0, 3), ['/d', '/s', '/c']);
|
|
609
|
-
} finally {
|
|
610
|
-
await cleanup(h.root);
|
|
611
|
-
}
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
void it('shares duplicate Windows graceful stops and aborts soft taskkill when force starts', async () => {
|
|
615
|
-
let childRef: FakeChild | undefined;
|
|
616
|
-
let softAbortCount = 0;
|
|
617
|
-
let firstTimer: NodeJS.Timeout | undefined;
|
|
618
|
-
const phases: WindowsKillPhase[] = [];
|
|
619
|
-
const h = await createHarness({
|
|
620
|
-
platform: 'win32',
|
|
621
|
-
killGraceMs: 20,
|
|
622
|
-
stopWaitMs: 500,
|
|
623
|
-
killTree: (_pid, phase, signal) => {
|
|
624
|
-
phases.push(phase);
|
|
625
|
-
if (phase === 'terminate') {
|
|
626
|
-
if (signal !== undefined) {
|
|
627
|
-
signal.addEventListener(
|
|
628
|
-
'abort',
|
|
629
|
-
() => {
|
|
630
|
-
softAbortCount += 1;
|
|
631
|
-
},
|
|
632
|
-
{ once: true },
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
return new Promise<TaskkillOutcome>(() => undefined);
|
|
636
|
-
}
|
|
637
|
-
assert.equal(signal, undefined, 'force taskkill must not reuse the soft abort signal');
|
|
638
|
-
assert.equal(softAbortCount, 1, 'soft attempt should be aborted before force starts');
|
|
639
|
-
queueMicrotask(() => {
|
|
640
|
-
childRef?.close(null, 'SIGKILL');
|
|
641
|
-
});
|
|
642
|
-
return Promise.resolve(taskkillOutcome(0));
|
|
643
|
-
},
|
|
644
|
-
childFactory: (pid) => {
|
|
645
|
-
childRef = new FakeChild(pid);
|
|
646
|
-
return childRef;
|
|
647
|
-
},
|
|
648
|
-
});
|
|
649
|
-
try {
|
|
650
|
-
const { task } = await startFakeTask(h, 'Windows Duplicate Stop');
|
|
651
|
-
const first = h.registry.stopTask(task, 'user');
|
|
652
|
-
firstTimer = task.killEscalationTimer;
|
|
653
|
-
assert.ok(firstTimer, 'first graceful stop should arm an escalation timer');
|
|
654
|
-
const second = h.registry.stopTask(task, 'user');
|
|
655
|
-
const third = h.registry.stopTask(task, 'user');
|
|
656
|
-
assert.equal(task.killEscalationTimer, firstTimer, 'duplicate stops must share one timer');
|
|
657
|
-
await Promise.all([first, second, third]);
|
|
658
|
-
assert.deepEqual(phases, ['terminate', 'force']);
|
|
659
|
-
assert.equal(task.killEscalationTimer, undefined);
|
|
660
|
-
assert.equal(softAbortCount, 1);
|
|
661
|
-
} finally {
|
|
662
|
-
await cleanup(h.root);
|
|
663
|
-
}
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
void it('treats explicit Windows force as terminal and does not arm escalation', async () => {
|
|
667
|
-
const phases: WindowsKillPhase[] = [];
|
|
668
|
-
const h = await createHarness({
|
|
669
|
-
platform: 'win32',
|
|
670
|
-
killGraceMs: 20,
|
|
671
|
-
killTree: (_pid, phase) => {
|
|
672
|
-
phases.push(phase);
|
|
673
|
-
return Promise.resolve(taskkillOutcome(0));
|
|
674
|
-
},
|
|
675
|
-
});
|
|
676
|
-
try {
|
|
677
|
-
const { task } = await startFakeTask(h, 'Windows Explicit Force');
|
|
678
|
-
requestKillForTest(h.registry, task, 'SIGKILL');
|
|
679
|
-
assert.equal(task.killEscalationTimer, undefined);
|
|
680
|
-
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
681
|
-
assert.deepEqual(phases, ['force']);
|
|
682
|
-
assert.equal(task.killEscalationTimer, undefined);
|
|
683
|
-
} finally {
|
|
684
|
-
await cleanup(h.root);
|
|
685
|
-
}
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
void it('records Windows taskkill exit 128 as an already-exited race', async () => {
|
|
689
|
-
const h = await createHarness({
|
|
690
|
-
platform: 'win32',
|
|
691
|
-
killGraceMs: 500,
|
|
692
|
-
stopWaitMs: 1000,
|
|
693
|
-
killTree: () => Promise.resolve(taskkillOutcome(128, 'process not found')),
|
|
694
|
-
});
|
|
695
|
-
try {
|
|
696
|
-
const { task, child } = await startFakeTask(h, 'Windows Missing Process');
|
|
697
|
-
const stopped = h.registry.stopTask(task, 'user');
|
|
698
|
-
await waitFor(
|
|
699
|
-
() => readFileSync(task.outputAbsPath, 'utf8').includes('process not found'),
|
|
700
|
-
'exit 128 notice',
|
|
701
|
-
);
|
|
702
|
-
child.close(0, null);
|
|
703
|
-
await stopped;
|
|
704
|
-
assert.equal(task.status, 'killed');
|
|
705
|
-
assert.match(await readFile(task.outputAbsPath, 'utf8'), /already-exited race/);
|
|
706
|
-
} finally {
|
|
707
|
-
await cleanup(h.root);
|
|
708
|
-
}
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
void it('persists a Windows soft failure and still escalates to force after grace', async () => {
|
|
712
|
-
let childRef: FakeChild | undefined;
|
|
713
|
-
const phases: WindowsKillPhase[] = [];
|
|
714
|
-
const h = await createHarness({
|
|
715
|
-
platform: 'win32',
|
|
716
|
-
killGraceMs: 20,
|
|
717
|
-
stopWaitMs: 500,
|
|
718
|
-
killTree: (_pid, phase) => {
|
|
719
|
-
phases.push(phase);
|
|
720
|
-
if (phase === 'terminate') return Promise.resolve(taskkillOutcome(1, 'soft denied'));
|
|
721
|
-
queueMicrotask(() => {
|
|
722
|
-
childRef?.close(null, 'SIGKILL');
|
|
723
|
-
});
|
|
724
|
-
return Promise.resolve(taskkillOutcome(0));
|
|
725
|
-
},
|
|
726
|
-
childFactory: (pid) => {
|
|
727
|
-
childRef = new FakeChild(pid);
|
|
728
|
-
return childRef;
|
|
729
|
-
},
|
|
730
|
-
});
|
|
731
|
-
try {
|
|
732
|
-
const { task } = await startFakeTask(h, 'Windows Soft Failure');
|
|
733
|
-
await h.registry.stopTask(task, 'user');
|
|
734
|
-
assert.deepEqual(phases, ['terminate', 'force']);
|
|
735
|
-
assert.match(task.error ?? '', /soft denied/);
|
|
736
|
-
const metadata = parseJsonObject(await readFile(task.metadataAbsPath, 'utf8'), 'metadata');
|
|
737
|
-
assert.match(String(metadata['error']), /soft denied/);
|
|
738
|
-
} finally {
|
|
739
|
-
await cleanup(h.root);
|
|
740
|
-
}
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
void it('surfaces Windows force failure loudly without root-only fallback', async () => {
|
|
744
|
-
const h = await createHarness({
|
|
745
|
-
platform: 'win32',
|
|
746
|
-
killGraceMs: 20,
|
|
747
|
-
stopWaitMs: 500,
|
|
748
|
-
killTree: (_pid, phase) =>
|
|
749
|
-
Promise.resolve(
|
|
750
|
-
phase === 'terminate'
|
|
751
|
-
? taskkillOutcome(1, 'soft denied')
|
|
752
|
-
: taskkillOutcome(5, 'force denied'),
|
|
753
|
-
),
|
|
754
|
-
childFactory: (pid) =>
|
|
755
|
-
new FakeChild(pid, () => {
|
|
756
|
-
throw new Error('root-only kill must not run');
|
|
757
|
-
}),
|
|
758
|
-
});
|
|
759
|
-
try {
|
|
760
|
-
const { task, child } = await startFakeTask(h, 'Windows Force Failure');
|
|
761
|
-
await assert.rejects(
|
|
762
|
-
() => h.registry.stopTask(task, 'user'),
|
|
763
|
-
/Windows taskkill \/T \/F force termination failed[\s\S]*Descendant processes may have leaked/,
|
|
764
|
-
);
|
|
765
|
-
assert.equal(task.status, 'running');
|
|
766
|
-
assert.match(task.error ?? '', /force denied/);
|
|
767
|
-
assert.deepEqual(child.killCalls, []);
|
|
768
|
-
} finally {
|
|
769
|
-
await cleanup(h.root);
|
|
770
|
-
}
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
void it('keeps terminal metadata running until in-flight Windows force settles', async () => {
|
|
774
|
-
let childRef: FakeChild | undefined;
|
|
775
|
-
let forceStarted = false;
|
|
776
|
-
const force = deferred<TaskkillOutcome>();
|
|
777
|
-
const terminals: BgTaskSnapshot[] = [];
|
|
778
|
-
const h = await createHarness({
|
|
779
|
-
platform: 'win32',
|
|
780
|
-
killGraceMs: 20,
|
|
781
|
-
stopWaitMs: 1000,
|
|
782
|
-
publishTerminal: (task) => {
|
|
783
|
-
terminals.push(task);
|
|
784
|
-
},
|
|
785
|
-
killTree: (_pid, phase) => {
|
|
786
|
-
if (phase === 'terminate') return Promise.resolve(taskkillOutcome(0));
|
|
787
|
-
forceStarted = true;
|
|
788
|
-
queueMicrotask(() => {
|
|
789
|
-
childRef?.close(null, 'SIGKILL');
|
|
790
|
-
});
|
|
791
|
-
return force.promise;
|
|
792
|
-
},
|
|
793
|
-
childFactory: (pid) => {
|
|
794
|
-
childRef = new FakeChild(pid);
|
|
795
|
-
return childRef;
|
|
796
|
-
},
|
|
797
|
-
});
|
|
798
|
-
try {
|
|
799
|
-
const { task } = await startFakeTask(h, 'Windows Force Barrier');
|
|
800
|
-
const stopped = h.registry.stopTask(task, 'user');
|
|
801
|
-
await waitFor(() => forceStarted, 'force taskkill start');
|
|
802
|
-
await waitFor(() => task.finalized === true, 'child close reached finalization');
|
|
803
|
-
const runningMetadata = parseJsonObject(
|
|
804
|
-
await readFile(task.metadataAbsPath, 'utf8'),
|
|
805
|
-
'metadata before force settles',
|
|
806
|
-
);
|
|
807
|
-
assert.equal(runningMetadata['status'], 'running');
|
|
808
|
-
assert.equal(terminals.length, 0);
|
|
809
|
-
force.resolve(taskkillOutcome(0));
|
|
810
|
-
await stopped;
|
|
811
|
-
assert.equal(task.status, 'killed');
|
|
812
|
-
const terminalMetadata = parseJsonObject(
|
|
813
|
-
await readFile(task.metadataAbsPath, 'utf8'),
|
|
814
|
-
'metadata after force settles',
|
|
815
|
-
);
|
|
816
|
-
assert.equal(terminalMetadata['status'], 'killed');
|
|
817
|
-
assert.equal(terminals.length, 1);
|
|
818
|
-
} finally {
|
|
819
|
-
await cleanup(h.root);
|
|
820
|
-
}
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
void it('keeps duplicate stop requests idempotent and escalates to SIGKILL after grace', async () => {
|
|
824
|
-
let childRef: FakeChild | undefined;
|
|
825
|
-
const killCalls: Array<NodeJS.Signals | number | undefined> = [];
|
|
826
|
-
const h = await createHarness({
|
|
827
|
-
platform: 'linux',
|
|
828
|
-
killGraceMs: 20,
|
|
829
|
-
stopWaitMs: 500,
|
|
830
|
-
killProcess: (_pid, signal) => {
|
|
831
|
-
killCalls.push(signal);
|
|
832
|
-
if (signal === 'SIGKILL') {
|
|
833
|
-
queueMicrotask(() => {
|
|
834
|
-
childRef?.close(null, 'SIGKILL');
|
|
835
|
-
});
|
|
836
|
-
}
|
|
837
|
-
return true;
|
|
838
|
-
},
|
|
839
|
-
childFactory: (pid) => {
|
|
840
|
-
childRef = new FakeChild(pid);
|
|
841
|
-
return childRef;
|
|
842
|
-
},
|
|
843
|
-
});
|
|
844
|
-
try {
|
|
845
|
-
const { task } = await startFakeTask(h, 'Escalate Kill');
|
|
846
|
-
const first = h.registry.stopTask(task, 'user');
|
|
847
|
-
const second = h.registry.stopTask(task, 'user');
|
|
848
|
-
await Promise.all([first, second]);
|
|
849
|
-
assert.deepEqual(killCalls, ['SIGTERM', 'SIGKILL']);
|
|
850
|
-
assert.equal(task.status, 'killed');
|
|
851
|
-
assert.equal(task.killEscalationTimer, undefined, 'escalation timer must be cleared');
|
|
852
|
-
} finally {
|
|
853
|
-
await cleanup(h.root);
|
|
854
|
-
}
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
void it('schedules exactly one SIGKILL escalation for concurrent stop requests', async () => {
|
|
858
|
-
// Regression: SIGTERM de-duplication guarded the signal but not the timer,
|
|
859
|
-
// so each concurrent stopTask scheduled its own escalation. When the child
|
|
860
|
-
// outlived the grace window that produced duplicate SIGKILLs.
|
|
861
|
-
const killCalls: Array<NodeJS.Signals | number | undefined> = [];
|
|
862
|
-
const h = await createHarness({
|
|
863
|
-
platform: 'linux',
|
|
864
|
-
killGraceMs: 20,
|
|
865
|
-
stopWaitMs: 120,
|
|
866
|
-
// Never close the child, so every scheduled escalation timer can fire.
|
|
867
|
-
killProcess: (_pid, signal) => {
|
|
868
|
-
killCalls.push(signal);
|
|
869
|
-
return true;
|
|
870
|
-
},
|
|
871
|
-
childFactory: (pid) => new FakeChild(pid),
|
|
872
|
-
});
|
|
873
|
-
try {
|
|
874
|
-
const { task } = await startFakeTask(h, 'Escalate Once');
|
|
875
|
-
await Promise.all([
|
|
876
|
-
h.registry.stopTask(task, 'user').catch(() => undefined),
|
|
877
|
-
h.registry.stopTask(task, 'user').catch(() => undefined),
|
|
878
|
-
h.registry.stopTask(task, 'user').catch(() => undefined),
|
|
879
|
-
]);
|
|
880
|
-
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
881
|
-
assert.deepEqual(
|
|
882
|
-
killCalls,
|
|
883
|
-
['SIGTERM', 'SIGKILL'],
|
|
884
|
-
'concurrent stop requests must escalate to SIGKILL exactly once',
|
|
885
|
-
);
|
|
886
|
-
} finally {
|
|
887
|
-
await cleanup(h.root);
|
|
888
|
-
}
|
|
889
|
-
});
|
|
890
|
-
|
|
891
|
-
void it('finalizes and notifies once under error/close and output-cap races', async () => {
|
|
892
|
-
const h = await createHarness({
|
|
893
|
-
maxOutputBytes: 8,
|
|
894
|
-
killProcess: () => true,
|
|
895
|
-
});
|
|
896
|
-
try {
|
|
897
|
-
const { task, child } = await startFakeTask(h, 'Race Failure');
|
|
898
|
-
child.fail(new Error('spawn exploded'));
|
|
899
|
-
child.close(0, null);
|
|
900
|
-
await waitFor(() => task.status !== 'running', 'spawn race finalization');
|
|
901
|
-
await waitFor(() => h.notifications.length === 1, 'single spawn-race notification');
|
|
902
|
-
assert.equal(task.status, 'failed');
|
|
903
|
-
assert.match(task.error ?? '', /spawn exploded/);
|
|
904
|
-
assert.equal(h.notifications.length, 1);
|
|
905
|
-
// BUG-181: the terminal event itself is authoritative; agents must not poll to reconfirm it.
|
|
906
|
-
const notification = h.notifications[0];
|
|
907
|
-
assert.ok(notification, 'terminal notification should be captured');
|
|
908
|
-
assert.match(
|
|
909
|
-
notification.message.content,
|
|
910
|
-
/<guidance>Terminal state and output metadata are durable\. Do not call bg_status to reconfirm; use bg_logs only if output is needed\.<\/guidance>/,
|
|
911
|
-
);
|
|
912
|
-
assert.deepEqual(notification.options, { deliverAs: 'followUp', triggerTurn: true });
|
|
913
|
-
|
|
914
|
-
const capped = await h.registry.startTask(h.ctx, 'node noisy.js', {
|
|
915
|
-
name: 'Output Race',
|
|
916
|
-
notifyOnCompletion: true,
|
|
917
|
-
triggerOnCompletion: true,
|
|
918
|
-
});
|
|
919
|
-
const cappedChild = lastSpawn(h).child;
|
|
920
|
-
cappedChild.writeStdout('0123456789abcdef');
|
|
921
|
-
cappedChild.close(1, null);
|
|
922
|
-
cappedChild.close(0, null);
|
|
923
|
-
await waitFor(() => capped.status !== 'running', 'output-cap finalization');
|
|
924
|
-
await waitFor(() => h.notifications.length === 2, 'single output-cap notification');
|
|
925
|
-
assert.equal(capped.status, 'failed');
|
|
926
|
-
assert.match(capped.error ?? '', /Output exceeded cap/);
|
|
927
|
-
assert.equal(h.notifications.length, 2);
|
|
928
|
-
} finally {
|
|
929
|
-
await cleanup(h.root);
|
|
930
|
-
}
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
void it('publishes terminal snapshots exactly once after durable metadata', async () => {
|
|
934
|
-
const terminals: BgTaskSnapshot[] = [];
|
|
935
|
-
const metadataStatuses: unknown[] = [];
|
|
936
|
-
let metadataPath = '';
|
|
937
|
-
const h = await createHarness({
|
|
938
|
-
publishTerminal: (task) => {
|
|
939
|
-
terminals.push(task);
|
|
940
|
-
metadataStatuses.push(
|
|
941
|
-
parseJsonObject(readFileSync(metadataPath, 'utf8'), 'terminal metadata must be written')[
|
|
942
|
-
'status'
|
|
943
|
-
],
|
|
944
|
-
);
|
|
945
|
-
},
|
|
946
|
-
});
|
|
947
|
-
try {
|
|
948
|
-
const { task, child } = await startFakeTask(h, 'Terminal Once');
|
|
949
|
-
metadataPath = task.metadataAbsPath;
|
|
950
|
-
child.close(0, null);
|
|
951
|
-
child.close(1, null);
|
|
952
|
-
await waitFor(() => task.status !== 'running', 'terminal status');
|
|
953
|
-
await waitFor(() => terminals.length === 1, 'single terminal publication');
|
|
954
|
-
const terminal = terminals[0];
|
|
955
|
-
assert.ok(terminal, 'terminal snapshot should be present');
|
|
956
|
-
assert.equal(terminal.id, task.id);
|
|
957
|
-
assert.equal(terminal.status, 'completed');
|
|
958
|
-
assert.deepEqual(metadataStatuses, ['completed']);
|
|
959
|
-
} finally {
|
|
960
|
-
await cleanup(h.root);
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
|
|
964
|
-
void it('keeps failed terminal EventBus delivery loud and retriable', async () => {
|
|
965
|
-
const terminals: BgTaskSnapshot[] = [];
|
|
966
|
-
let attempts = 0;
|
|
967
|
-
const h = await createHarness({
|
|
968
|
-
publishTerminal: (task) => {
|
|
969
|
-
attempts += 1;
|
|
970
|
-
if (attempts === 1) throw new Error('terminal bus unavailable');
|
|
971
|
-
terminals.push(task);
|
|
972
|
-
},
|
|
973
|
-
});
|
|
974
|
-
try {
|
|
975
|
-
const { task, child } = await startFakeTask(h, 'Terminal Retry');
|
|
976
|
-
child.close(0, null);
|
|
977
|
-
await waitFor(() => task.status === 'completed', 'terminal retry completion');
|
|
978
|
-
await waitFor(() => terminals.length === 1, 'terminal retry publication');
|
|
979
|
-
assert.equal(attempts, 2);
|
|
980
|
-
assert.equal(task.terminalPublished, true);
|
|
981
|
-
assert.equal(terminals[0]?.id, task.id);
|
|
982
|
-
assert.match(
|
|
983
|
-
h.errors.flat().join(' '),
|
|
984
|
-
/terminal publication failed|terminal bus unavailable/,
|
|
985
|
-
);
|
|
986
|
-
} finally {
|
|
987
|
-
await cleanup(h.root);
|
|
988
|
-
}
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
void it('resets notified when completion notification delivery fails and records loud metadata errors', async () => {
|
|
992
|
-
const failingNotify = await createHarness({
|
|
993
|
-
sendCompletionNotification: () => {
|
|
994
|
-
throw new Error('send failed');
|
|
995
|
-
},
|
|
996
|
-
});
|
|
997
|
-
try {
|
|
998
|
-
const { task, child } = await startFakeTask(failingNotify, 'Notify Failure');
|
|
999
|
-
child.close(0, null);
|
|
1000
|
-
await waitFor(() => task.status === 'completed', 'notification failure task completion');
|
|
1001
|
-
await waitFor(() => failingNotify.errors.length > 0, 'notification failure log');
|
|
1002
|
-
assert.equal(task.notified, false);
|
|
1003
|
-
const metadata = parseJsonObject(
|
|
1004
|
-
await readFile(task.metadataAbsPath, 'utf8'),
|
|
1005
|
-
'notification metadata must be an object',
|
|
1006
|
-
);
|
|
1007
|
-
assert.equal(metadata['notified'], false);
|
|
1008
|
-
assert.match(failingNotify.errors.flat().join(' '), /notification failed|send failed/);
|
|
1009
|
-
} finally {
|
|
1010
|
-
await cleanup(failingNotify.root);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const metadataFailure = await createHarness();
|
|
1014
|
-
try {
|
|
1015
|
-
const { task, child } = await startFakeTask(metadataFailure, 'Metadata Failure');
|
|
1016
|
-
// OUR runtime dir lives under UNIPI_BG_TMP_DIR (not .pi/tasks); delete it to force
|
|
1017
|
-
// the terminal-metadata write failure this test exercises.
|
|
1018
|
-
await rm(task.outputAbsPath, { recursive: true, force: true });
|
|
1019
|
-
await rm(join(task.outputAbsPath, '..'), { recursive: true, force: true });
|
|
1020
|
-
child.close(0, null);
|
|
1021
|
-
await waitFor(() => task.status === 'failed', 'metadata failure task completion');
|
|
1022
|
-
await waitFor(
|
|
1023
|
-
() => metadataFailure.notifications.length === 1,
|
|
1024
|
-
'notification despite metadata failure',
|
|
1025
|
-
);
|
|
1026
|
-
await waitFor(() => metadataFailure.errors.length > 0, 'metadata failure log');
|
|
1027
|
-
assert.equal(task.notified, true);
|
|
1028
|
-
assert.match(task.error ?? '', /Terminal metadata write failed/);
|
|
1029
|
-
assert.match(
|
|
1030
|
-
metadataFailure.errors.flat().join(' '),
|
|
1031
|
-
/failed to (write failed terminal|write|update )?metadata|ENOENT/,
|
|
1032
|
-
);
|
|
1033
|
-
} finally {
|
|
1034
|
-
await cleanup(metadataFailure.root);
|
|
1035
|
-
}
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
void it('ingests split, malformed, and large telemetry records without losing task state', async () => {
|
|
1039
|
-
const h = await createHarness();
|
|
1040
|
-
try {
|
|
1041
|
-
const { task, child } = await startFakeTask(h, 'Telemetry Chunks');
|
|
1042
|
-
child.writeStdout('not-json-but-user-output\n');
|
|
1043
|
-
child.writeStdout('{"type":"background-task-telemetry",');
|
|
1044
|
-
assert.equal(task.contextUsage, undefined);
|
|
1045
|
-
|
|
1046
|
-
const byName = Object.fromEntries(
|
|
1047
|
-
Array.from({ length: 2500 }, (_, index) => [`tool-${String(index)}`, 1]),
|
|
1048
|
-
);
|
|
1049
|
-
const telemetry = JSON.stringify({
|
|
1050
|
-
type: 'background-task-telemetry',
|
|
1051
|
-
contextUsage: { tokens: 12_345, contextWindow: 200_000, percent: 6.1725 },
|
|
1052
|
-
tokenUsage: {
|
|
1053
|
-
input: 10_000,
|
|
1054
|
-
output: 2000,
|
|
1055
|
-
cacheRead: 300,
|
|
1056
|
-
cacheWrite: 45,
|
|
1057
|
-
totalTokens: 12_345,
|
|
1058
|
-
},
|
|
1059
|
-
toolUsage: { total: 2500, failed: 3, byName },
|
|
1060
|
-
model: 'openai-codex/gpt-5.5',
|
|
1061
|
-
});
|
|
1062
|
-
assert.ok(telemetry.length > 16 * 1024, 'fixture must exceed the old 16KiB telemetry buffer');
|
|
1063
|
-
const telemetryPrefix = '{"type":"background-task-telemetry",';
|
|
1064
|
-
assert.ok(telemetry.startsWith(telemetryPrefix));
|
|
1065
|
-
const continuation = telemetry.slice(telemetryPrefix.length);
|
|
1066
|
-
for (const chunk of [
|
|
1067
|
-
continuation.slice(0, 257),
|
|
1068
|
-
...(continuation.slice(257).match(/.{1,113}/gs) ?? []),
|
|
1069
|
-
'\n',
|
|
1070
|
-
]) {
|
|
1071
|
-
child.writeStdout(chunk);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
assert.deepEqual(task.contextUsage, {
|
|
1075
|
-
tokens: 12_345,
|
|
1076
|
-
contextWindow: 200_000,
|
|
1077
|
-
percent: 6.1725,
|
|
1078
|
-
});
|
|
1079
|
-
assert.deepEqual(task.tokenUsage, {
|
|
1080
|
-
input: 10_000,
|
|
1081
|
-
output: 2000,
|
|
1082
|
-
cacheRead: 300,
|
|
1083
|
-
cacheWrite: 45,
|
|
1084
|
-
totalTokens: 12_345,
|
|
1085
|
-
});
|
|
1086
|
-
const toolUsage = task.toolUsage;
|
|
1087
|
-
assert.ok(toolUsage, 'valid telemetry should populate tool usage');
|
|
1088
|
-
assert.equal(toolUsage.total, 2500);
|
|
1089
|
-
assert.equal(toolUsage.failed, 3);
|
|
1090
|
-
assert.equal(toolUsage.byName['tool-2499'], 1);
|
|
1091
|
-
assert.equal(task.model, 'openai-codex/gpt-5.5');
|
|
1092
|
-
|
|
1093
|
-
child.writeStdout('{"type":"background-task-telemetry",bad}\n');
|
|
1094
|
-
const retainedToolUsage = task.toolUsage;
|
|
1095
|
-
assert.ok(retainedToolUsage, 'malformed telemetry must not clear previous tool usage');
|
|
1096
|
-
assert.equal(retainedToolUsage.total, 2500);
|
|
1097
|
-
assert.equal(task.model, 'openai-codex/gpt-5.5');
|
|
1098
|
-
child.close(0, null);
|
|
1099
|
-
await waitFor(() => task.status === 'completed', 'telemetry task completion');
|
|
1100
|
-
let metadata = await readJsonEventually(task.metadataAbsPath);
|
|
1101
|
-
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1102
|
-
metadata = await readJsonEventually(task.metadataAbsPath);
|
|
1103
|
-
if (JSON.stringify(metadata['tokenUsage']) === JSON.stringify(task.tokenUsage)) break;
|
|
1104
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1105
|
-
}
|
|
1106
|
-
assert.deepEqual(metadata['tokenUsage'], task.tokenUsage);
|
|
1107
|
-
const metadataToolUsage = requiredJsonObject(
|
|
1108
|
-
metadata['toolUsage'],
|
|
1109
|
-
'metadata tool usage must be an object',
|
|
1110
|
-
);
|
|
1111
|
-
const metadataToolCounts = requiredJsonObject(
|
|
1112
|
-
metadataToolUsage['byName'],
|
|
1113
|
-
'metadata tool counts must be an object',
|
|
1114
|
-
);
|
|
1115
|
-
assert.equal(metadataToolCounts['tool-2499'], 1);
|
|
1116
|
-
assert.equal(metadata['model'], 'openai-codex/gpt-5.5');
|
|
1117
|
-
} finally {
|
|
1118
|
-
await cleanup(h.root);
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
void it('renders wrapped Pi-agent activity transcripts and keeps telemetry out of the output file', async () => {
|
|
1123
|
-
const h = await createHarness({ platform: 'linux' });
|
|
1124
|
-
try {
|
|
1125
|
-
const task = await h.registry.startTask(h.ctx, 'pi -p hello', {
|
|
1126
|
-
name: 'Wrapped Agent',
|
|
1127
|
-
isAgent: true,
|
|
1128
|
-
notifyOnCompletion: false,
|
|
1129
|
-
});
|
|
1130
|
-
assert.equal(task.telemetryWrapped, true);
|
|
1131
|
-
const child = lastSpawn(h).child;
|
|
1132
|
-
|
|
1133
|
-
child.writeStdout(
|
|
1134
|
-
'{"type":"background-task-activity","kind":"tool_start","tool":"read","argsSummary":"README.md"}\n',
|
|
1135
|
-
);
|
|
1136
|
-
// Telemetry split across two stdout chunks must reassemble before parsing.
|
|
1137
|
-
child.writeStdout(
|
|
1138
|
-
'{"type":"background-task-telemetry","tokenUsage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"totalTokens":15},',
|
|
1139
|
-
);
|
|
1140
|
-
child.writeStdout(
|
|
1141
|
-
'"toolUsage":{"total":1,"failed":1,"byName":{"read":1}},"model":"prov/model","contextUsage":{"tokens":15,"contextWindow":1000,"percent":1.5}}\n',
|
|
1142
|
-
);
|
|
1143
|
-
child.writeStdout(
|
|
1144
|
-
'{"type":"background-task-activity","kind":"tool_end","tool":"read","isError":true,"error":"boom"}\n',
|
|
1145
|
-
);
|
|
1146
|
-
child.writeStdout(
|
|
1147
|
-
'{"type":"background-task-activity","kind":"assistant_text","text":"final answer"}\n',
|
|
1148
|
-
);
|
|
1149
|
-
child.writeStderr('child stderr diagnostic\n');
|
|
1150
|
-
// Trailing partial line (no newline) must be flushed verbatim on finalize.
|
|
1151
|
-
child.writeStdout('trailing fragment without newline');
|
|
1152
|
-
|
|
1153
|
-
assert.deepEqual(task.tokenUsage, {
|
|
1154
|
-
input: 10,
|
|
1155
|
-
output: 5,
|
|
1156
|
-
cacheRead: 0,
|
|
1157
|
-
cacheWrite: 0,
|
|
1158
|
-
totalTokens: 15,
|
|
1159
|
-
});
|
|
1160
|
-
assert.deepEqual(task.toolUsage, { total: 1, failed: 1, byName: { read: 1 } });
|
|
1161
|
-
assert.equal(task.model, 'prov/model');
|
|
1162
|
-
assert.deepEqual(task.contextUsage, { tokens: 15, contextWindow: 1000, percent: 1.5 });
|
|
1163
|
-
|
|
1164
|
-
child.close(0, null);
|
|
1165
|
-
await waitFor(() => task.status === 'completed', 'wrapped-agent completion');
|
|
1166
|
-
|
|
1167
|
-
let output = '';
|
|
1168
|
-
await waitFor(() => {
|
|
1169
|
-
try {
|
|
1170
|
-
output = readFileSync(task.outputAbsPath, 'utf8');
|
|
1171
|
-
} catch {
|
|
1172
|
-
output = '';
|
|
1173
|
-
}
|
|
1174
|
-
return output.includes('trailing fragment without newline');
|
|
1175
|
-
}, 'wrapped-agent transcript flushed');
|
|
1176
|
-
|
|
1177
|
-
assert.match(output, /\u2192 read README\.md/);
|
|
1178
|
-
assert.match(output, /\u2717 read failed: boom/);
|
|
1179
|
-
assert.match(output, /^final answer$/m);
|
|
1180
|
-
assert.match(output, /child stderr diagnostic/);
|
|
1181
|
-
assert.doesNotMatch(output, /background-task-telemetry/);
|
|
1182
|
-
assert.doesNotMatch(output, /background-task-activity/);
|
|
1183
|
-
assert.doesNotMatch(output, /"kind"/);
|
|
1184
|
-
} finally {
|
|
1185
|
-
await cleanup(h.root);
|
|
1186
|
-
}
|
|
1187
|
-
});
|
|
1188
|
-
|
|
1189
|
-
void it('preserves split multiline XML context telemetry across newline boundaries', async () => {
|
|
1190
|
-
const h = await createHarness();
|
|
1191
|
-
try {
|
|
1192
|
-
const { task, child } = await startFakeTask(h, 'XML Telemetry');
|
|
1193
|
-
child.writeStdout('prefix\n<background-task-context-usage>\n <tokens>321</tokens>\n');
|
|
1194
|
-
assert.equal(task.contextUsage, undefined);
|
|
1195
|
-
child.writeStdout(
|
|
1196
|
-
' <context-window>1000</context-window>\n <percent>32.1</percent>\n</background-task-context-usage>\n',
|
|
1197
|
-
);
|
|
1198
|
-
assert.deepEqual(task.contextUsage, { tokens: 321, contextWindow: 1000, percent: 32.1 });
|
|
1199
|
-
child.close(0, null);
|
|
1200
|
-
await waitFor(() => task.status === 'completed', 'xml telemetry task completion');
|
|
1201
|
-
} finally {
|
|
1202
|
-
await cleanup(h.root);
|
|
1203
|
-
}
|
|
1204
|
-
});
|
|
1205
|
-
|
|
1206
|
-
void it('produces a direct-spawn attested Pi sidecar with raw events, stderr, hashes, and exact argv', async () => {
|
|
1207
|
-
const h = await createHarness({ modelRegistry: oauthRegistry() });
|
|
1208
|
-
try {
|
|
1209
|
-
await initCleanGit(h.cwd);
|
|
1210
|
-
const task = await h.registry.startAttestedPiTask(h.ctx, {
|
|
1211
|
-
name: 'Unit Attested',
|
|
1212
|
-
provider: 'openai-codex',
|
|
1213
|
-
model: 'gpt-5.5',
|
|
1214
|
-
prompt: 'write report.md',
|
|
1215
|
-
reportPath: 'report.md',
|
|
1216
|
-
extraPiArgs: ['--no-extensions'],
|
|
1217
|
-
});
|
|
1218
|
-
await writeFile(join(h.cwd, 'report.md'), 'unit report\n', 'utf8');
|
|
1219
|
-
assert.match(task.id, /^b[0-9a-f]{32}$/);
|
|
1220
|
-
const spawn = lastSpawn(h);
|
|
1221
|
-
// This harness inherits the host platform, so the launch shape is
|
|
1222
|
-
// asserted per platform. On POSIX the resolved executable is the `pi`
|
|
1223
|
-
// entry on PATH. On Windows npm installs `pi` as a `pi.cmd` shim that a
|
|
1224
|
-
// shell-less spawn cannot resolve, so the Pi package bin is launched
|
|
1225
|
-
// through the current Node executable instead. Both are correct
|
|
1226
|
-
// production behaviour for their platform.
|
|
1227
|
-
const piArgs = process.platform === 'win32' ? spawn.args.slice(1) : [...spawn.args];
|
|
1228
|
-
if (process.platform === 'win32') {
|
|
1229
|
-
assert.equal(spawn.shell, process.execPath);
|
|
1230
|
-
assert.ok(
|
|
1231
|
-
spawn.args[0]?.endsWith('cli.js'),
|
|
1232
|
-
'Windows launches the resolved Pi bin as the first argument',
|
|
1233
|
-
);
|
|
1234
|
-
} else {
|
|
1235
|
-
assert.equal(spawn.shell, 'pi');
|
|
1236
|
-
}
|
|
1237
|
-
assert.equal(spawn.options.env?.['OPENAI_API_KEY'], undefined);
|
|
1238
|
-
assert.equal(spawn.options.env?.['OPENAI_BASE_URL'], undefined);
|
|
1239
|
-
assert.equal(spawn.options.env?.['ANTHROPIC_API_KEY'], undefined);
|
|
1240
|
-
assert.equal(spawn.options.env?.['OPENROUTER_API_KEY'], undefined);
|
|
1241
|
-
assert.deepEqual(piArgs, [
|
|
1242
|
-
'--mode',
|
|
1243
|
-
'json',
|
|
1244
|
-
'--provider',
|
|
1245
|
-
'openai-codex',
|
|
1246
|
-
'--model',
|
|
1247
|
-
'gpt-5.5',
|
|
1248
|
-
'--no-extensions',
|
|
1249
|
-
'write report.md',
|
|
1250
|
-
]);
|
|
1251
|
-
spawn.child.writeStdout(piJsonEvents());
|
|
1252
|
-
spawn.child.writeStderr('diagnostic\n');
|
|
1253
|
-
spawn.child.close(0, null);
|
|
1254
|
-
await waitFor(() => task.status === 'completed', 'attested sidecar completion');
|
|
1255
|
-
assert.ok(task.attestationAbsPath, 'attestation path should be recorded on task');
|
|
1256
|
-
assert.equal(
|
|
1257
|
-
existsSync(task.attestationAbsPath ?? ''),
|
|
1258
|
-
true,
|
|
1259
|
-
'completed must not become externally visible before the attestation is durable',
|
|
1260
|
-
);
|
|
1261
|
-
const attestation = parseJsonObject(
|
|
1262
|
-
await readFile(task.attestationAbsPath, 'utf8'),
|
|
1263
|
-
'attestation sidecar must be an object',
|
|
1264
|
-
);
|
|
1265
|
-
assert.equal(attestation['schema_version'], 'phase2.pi_task_attestation.v1');
|
|
1266
|
-
assert.equal(
|
|
1267
|
-
requiredJsonObject(attestation['lifecycle'], 'lifecycle')['status'],
|
|
1268
|
-
'completed',
|
|
1269
|
-
);
|
|
1270
|
-
const invocation = requiredJsonObject(attestation['invocation'], 'invocation');
|
|
1271
|
-
assert.equal(invocation['pi_session_id'], 'pi-session-unit');
|
|
1272
|
-
assert.equal(invocation['provider'], 'openai-codex');
|
|
1273
|
-
assert.equal(invocation['model_id'], 'gpt-5.5');
|
|
1274
|
-
assert.equal(invocation['credential_kind'], 'oauth');
|
|
1275
|
-
assert.equal(invocation['direct_api_key'], false);
|
|
1276
|
-
// The recorded evidence argv is the logical Pi invocation on every
|
|
1277
|
-
// platform. It deliberately stays ['pi', ...] rather than echoing the
|
|
1278
|
-
// Windows Node-plus-cli.js launch form, so attestation evidence keeps one
|
|
1279
|
-
// stable meaning across platforms.
|
|
1280
|
-
assert.deepEqual(invocation['argv'], ['pi', ...piArgs]);
|
|
1281
|
-
const sourceHashes = requiredJsonObject(attestation['source_hashes'], 'source hashes');
|
|
1282
|
-
const artifacts = requiredJsonObject(attestation['artifacts'], 'artifacts');
|
|
1283
|
-
assert.equal(
|
|
1284
|
-
requiredJsonObject(artifacts['task_output'], 'task output artifact')['sha256'],
|
|
1285
|
-
sourceHashes['output_sha256'],
|
|
1286
|
-
);
|
|
1287
|
-
assert.equal(
|
|
1288
|
-
requiredJsonObject(artifacts['stderr'], 'stderr artifact')['sha256'],
|
|
1289
|
-
sourceHashes['stderr_sha256'],
|
|
1290
|
-
);
|
|
1291
|
-
assert.equal(
|
|
1292
|
-
requiredJsonObject(artifacts['transcript'], 'transcript artifact')['sha256'],
|
|
1293
|
-
sourceHashes['events_sha256'],
|
|
1294
|
-
);
|
|
1295
|
-
assert.match(await readFile(task.outputAbsPath, 'utf8'), /attested done/);
|
|
1296
|
-
assert.match(await readFile(task.eventsAbsPath ?? '', 'utf8'), /pi-session-unit/);
|
|
1297
|
-
assert.match(await readFile(task.stderrAbsPath ?? '', 'utf8'), /diagnostic/);
|
|
1298
|
-
const metadata = parseJsonObject(
|
|
1299
|
-
await readFile(task.metadataAbsPath, 'utf8'),
|
|
1300
|
-
'metadata must remain parseable after attestation',
|
|
1301
|
-
);
|
|
1302
|
-
assert.equal(metadata['bytesWritten'], readFileSync(task.outputAbsPath).length);
|
|
1303
|
-
} finally {
|
|
1304
|
-
await cleanup(h.root);
|
|
1305
|
-
}
|
|
1306
|
-
});
|
|
1307
|
-
|
|
1308
|
-
void it('rejects duplicate thinking in attested Pi extra args before spawn', async () => {
|
|
1309
|
-
const h = await createHarness({ modelRegistry: oauthRegistry() });
|
|
1310
|
-
try {
|
|
1311
|
-
await initCleanGit(h.cwd);
|
|
1312
|
-
await assert.rejects(
|
|
1313
|
-
h.registry.startAttestedPiTask(h.ctx, {
|
|
1314
|
-
name: 'Duplicate Thinking',
|
|
1315
|
-
provider: 'openai-codex',
|
|
1316
|
-
model: 'gpt-5.5',
|
|
1317
|
-
thinking: 'high',
|
|
1318
|
-
prompt: 'write report.md',
|
|
1319
|
-
reportPath: 'report.md',
|
|
1320
|
-
extraPiArgs: ['--thinking', 'low'],
|
|
1321
|
-
}),
|
|
1322
|
-
/structured thinking field|duplicate Pi args/,
|
|
1323
|
-
);
|
|
1324
|
-
assert.equal(h.children.length, 0, 'duplicate thinking must fail before spawning pi');
|
|
1325
|
-
} finally {
|
|
1326
|
-
await cleanup(h.root);
|
|
1327
|
-
}
|
|
1328
|
-
});
|
|
1329
|
-
|
|
1330
|
-
void it('launches attested Pi on Windows through Node while preserving logical argv', async () => {
|
|
1331
|
-
const h = await createHarness({ platform: 'win32', modelRegistry: oauthRegistry() });
|
|
1332
|
-
try {
|
|
1333
|
-
await initCleanGit(h.cwd);
|
|
1334
|
-
const prompt = 'write report.md & echo pwned "%VAR%" C:\\tmp\\space path\\';
|
|
1335
|
-
const task = await h.registry.startAttestedPiTask(h.ctx, {
|
|
1336
|
-
name: 'Win Attested',
|
|
1337
|
-
provider: 'openai-codex',
|
|
1338
|
-
model: 'gpt-5.5',
|
|
1339
|
-
prompt,
|
|
1340
|
-
reportPath: 'report.md',
|
|
1341
|
-
extraPiArgs: ['--no-extensions', 'quoted "value"'],
|
|
1342
|
-
});
|
|
1343
|
-
await writeFile(join(h.cwd, 'report.md'), 'unit report\n', 'utf8');
|
|
1344
|
-
const spawn = lastSpawn(h);
|
|
1345
|
-
assert.equal(spawn.shell, process.execPath);
|
|
1346
|
-
assert.equal(spawn.options.shell, false);
|
|
1347
|
-
assert.equal(spawn.options.detached, false);
|
|
1348
|
-
assert.equal(spawn.args.at(-1), prompt);
|
|
1349
|
-
assert.ok(spawn.args[0]?.endsWith('cli.js'));
|
|
1350
|
-
assert.deepEqual(spawn.args.slice(1), [
|
|
1351
|
-
'--mode',
|
|
1352
|
-
'json',
|
|
1353
|
-
'--provider',
|
|
1354
|
-
'openai-codex',
|
|
1355
|
-
'--model',
|
|
1356
|
-
'gpt-5.5',
|
|
1357
|
-
'--no-extensions',
|
|
1358
|
-
'quoted "value"',
|
|
1359
|
-
prompt,
|
|
1360
|
-
]);
|
|
1361
|
-
spawn.child.writeStdout(piJsonEvents());
|
|
1362
|
-
spawn.child.close(0, null);
|
|
1363
|
-
await waitFor(() => task.status === 'completed', 'Windows attested completion');
|
|
1364
|
-
assert.ok(task.attestationAbsPath);
|
|
1365
|
-
const attestation = parseJsonObject(
|
|
1366
|
-
await readFile(task.attestationAbsPath, 'utf8'),
|
|
1367
|
-
'attestation sidecar must be an object',
|
|
1368
|
-
);
|
|
1369
|
-
const invocation = requiredJsonObject(attestation['invocation'], 'invocation');
|
|
1370
|
-
assert.deepEqual(invocation['argv'], [
|
|
1371
|
-
'pi',
|
|
1372
|
-
'--mode',
|
|
1373
|
-
'json',
|
|
1374
|
-
'--provider',
|
|
1375
|
-
'openai-codex',
|
|
1376
|
-
'--model',
|
|
1377
|
-
'gpt-5.5',
|
|
1378
|
-
'--no-extensions',
|
|
1379
|
-
'quoted "value"',
|
|
1380
|
-
prompt,
|
|
1381
|
-
]);
|
|
1382
|
-
} finally {
|
|
1383
|
-
await cleanup(h.root);
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
|
-
|
|
1387
|
-
void it('strips metered API environment from attested Pi child process', async () => {
|
|
1388
|
-
const h = await createHarness({
|
|
1389
|
-
modelRegistry: oauthRegistry(),
|
|
1390
|
-
env: {
|
|
1391
|
-
...process.env,
|
|
1392
|
-
OPENAI_API_KEY: 'metered-openai',
|
|
1393
|
-
OPENAI_BASE_URL: 'https://api.openai.invalid',
|
|
1394
|
-
ANTHROPIC_API_KEY: 'metered-anthropic',
|
|
1395
|
-
ANTHROPIC_BASE_URL: 'https://api.anthropic.invalid',
|
|
1396
|
-
OPENROUTER_API_KEY: 'metered-openrouter',
|
|
1397
|
-
OPENROUTER_BASE_URL: 'https://openrouter.invalid',
|
|
1398
|
-
PI_API_KEY: 'metered-pi',
|
|
1399
|
-
PI_AUTH_FILE: '/tmp/forbidden-auth.json',
|
|
1400
|
-
},
|
|
1401
|
-
});
|
|
1402
|
-
try {
|
|
1403
|
-
await initCleanGit(h.cwd);
|
|
1404
|
-
const task = await h.registry.startAttestedPiTask(h.ctx, {
|
|
1405
|
-
name: 'Env Strip',
|
|
1406
|
-
provider: 'openai-codex',
|
|
1407
|
-
model: 'gpt-5.5',
|
|
1408
|
-
prompt: 'write report.md',
|
|
1409
|
-
reportPath: 'report.md',
|
|
1410
|
-
});
|
|
1411
|
-
await writeFile(join(h.cwd, 'report.md'), 'unit report\n', 'utf8');
|
|
1412
|
-
const spawn = lastSpawn(h);
|
|
1413
|
-
for (const key of [
|
|
1414
|
-
'OPENAI_API_KEY',
|
|
1415
|
-
'OPENAI_BASE_URL',
|
|
1416
|
-
'ANTHROPIC_API_KEY',
|
|
1417
|
-
'ANTHROPIC_BASE_URL',
|
|
1418
|
-
'OPENROUTER_API_KEY',
|
|
1419
|
-
'OPENROUTER_BASE_URL',
|
|
1420
|
-
'PI_API_KEY',
|
|
1421
|
-
'PI_AUTH_FILE',
|
|
1422
|
-
]) {
|
|
1423
|
-
assert.equal(spawn.options.env?.[key], undefined, `${key} must be stripped`);
|
|
1424
|
-
}
|
|
1425
|
-
spawn.child.writeStdout(piJsonEvents());
|
|
1426
|
-
spawn.child.close(0, null);
|
|
1427
|
-
await waitFor(() => task.status === 'completed', 'attested env-strip completion');
|
|
1428
|
-
} finally {
|
|
1429
|
-
await cleanup(h.root);
|
|
1430
|
-
}
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
void it('rejects malformed attested Pi events and does not emit a sidecar', async () => {
|
|
1434
|
-
const h = await createHarness({ modelRegistry: oauthRegistry() });
|
|
1435
|
-
try {
|
|
1436
|
-
await initCleanGit(h.cwd);
|
|
1437
|
-
const task = await h.registry.startAttestedPiTask(h.ctx, {
|
|
1438
|
-
name: 'Bad Attested',
|
|
1439
|
-
provider: 'openai-codex',
|
|
1440
|
-
model: 'gpt-5.5',
|
|
1441
|
-
prompt: 'write report.md',
|
|
1442
|
-
reportPath: 'report.md',
|
|
1443
|
-
});
|
|
1444
|
-
await writeFile(join(h.cwd, 'report.md'), 'unit report\n', 'utf8');
|
|
1445
|
-
lastSpawn(h).child.writeStdout('{"type":"session","id":"s","cwd":"/tmp"}\n');
|
|
1446
|
-
lastSpawn(h).child.close(0, null);
|
|
1447
|
-
await waitFor(() => task.status === 'failed', 'malformed attested failure');
|
|
1448
|
-
assert.match(task.error ?? '', /agent_start|assistant|agent_end|session/i);
|
|
1449
|
-
assert.equal(existsSync(task.attestationAbsPath ?? ''), false);
|
|
1450
|
-
} finally {
|
|
1451
|
-
await cleanup(h.root);
|
|
1452
|
-
}
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
void it('keeps ordinary bg_run tasks free of attestation sidecars', async () => {
|
|
1456
|
-
const h = await createHarness();
|
|
1457
|
-
try {
|
|
1458
|
-
const { task, child } = await startFakeTask(h, 'Ordinary No Sidecar');
|
|
1459
|
-
child.writeStdout('ordinary\n');
|
|
1460
|
-
child.close(0, null);
|
|
1461
|
-
await waitFor(() => task.status === 'completed', 'ordinary completion');
|
|
1462
|
-
assert.equal(task.attestationPath, undefined);
|
|
1463
|
-
assert.equal(existsSync(task.outputAbsPath.replace(/\.output$/, '.attestation.json')), false);
|
|
1464
|
-
} finally {
|
|
1465
|
-
await cleanup(h.root);
|
|
1466
|
-
}
|
|
1467
|
-
});
|
|
1468
|
-
|
|
1469
|
-
void it('tracks managed Fusion completion, durable progress, once-only usage, and cancellation', async () => {
|
|
1470
|
-
const h = await createHarness({ stopWaitMs: 100 });
|
|
1471
|
-
try {
|
|
1472
|
-
let complete: (() => void) | undefined;
|
|
1473
|
-
const completion = new Promise<void>((resolve) => {
|
|
1474
|
-
complete = resolve;
|
|
1475
|
-
});
|
|
1476
|
-
let releaseTerminal: (() => void) | undefined;
|
|
1477
|
-
const terminalPublicationGate = new Promise<void>((resolve) => {
|
|
1478
|
-
releaseTerminal = resolve;
|
|
1479
|
-
});
|
|
1480
|
-
const facts = {
|
|
1481
|
-
runId: 'reason-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
1482
|
-
workflow: 'reason' as const,
|
|
1483
|
-
artifactDir: '.pi/fusion/test/reason-a',
|
|
1484
|
-
artifactDirAbs: join(h.cwd, '.pi', 'fusion', 'test', 'reason-a'),
|
|
1485
|
-
state: 'initializing',
|
|
1486
|
-
usageDelivered: false,
|
|
1487
|
-
};
|
|
1488
|
-
const task = await h.registry.startManagedTask(h.ctx, {
|
|
1489
|
-
id: facts.runId,
|
|
1490
|
-
name: 'fusion reason',
|
|
1491
|
-
command: 'fusion_reason',
|
|
1492
|
-
isAgent: true,
|
|
1493
|
-
completion,
|
|
1494
|
-
cancel: () => undefined,
|
|
1495
|
-
notifyOnCompletion: true,
|
|
1496
|
-
triggerOnCompletion: true,
|
|
1497
|
-
fusion: facts,
|
|
1498
|
-
terminalPublicationGate,
|
|
1499
|
-
});
|
|
1500
|
-
assert.equal(h.children.length, 0, 'managed task must not create a registry child process');
|
|
1501
|
-
await h.registry.updateManagedTask(task, 'candidates_running', 'candidate wave started');
|
|
1502
|
-
assert.equal(task.fusion?.state, 'candidates_running');
|
|
1503
|
-
assert.match(await readFile(task.outputAbsPath, 'utf8'), /candidate wave started/);
|
|
1504
|
-
assert.equal(await h.registry.claimFusionUsage(task), true);
|
|
1505
|
-
assert.equal(await h.registry.claimFusionUsage(task), false);
|
|
1506
|
-
assert.equal(task.fusion?.usageDelivered, true);
|
|
1507
|
-
complete?.();
|
|
1508
|
-
await waitFor(() => task.status === 'completed', 'managed Fusion completion');
|
|
1509
|
-
assert.equal(
|
|
1510
|
-
h.notifications.length,
|
|
1511
|
-
0,
|
|
1512
|
-
'completion must wait behind the launch publication gate',
|
|
1513
|
-
);
|
|
1514
|
-
releaseTerminal?.();
|
|
1515
|
-
await waitFor(() => h.notifications.length === 1, 'gated managed Fusion notification');
|
|
1516
|
-
assert.match(h.notifications[0]?.message.content ?? '', /Call bg_result/);
|
|
1517
|
-
|
|
1518
|
-
let rejectCancelled: ((error: Error) => void) | undefined;
|
|
1519
|
-
const cancelled = new Promise<void>((_resolve, reject) => {
|
|
1520
|
-
rejectCancelled = reject;
|
|
1521
|
-
});
|
|
1522
|
-
const cancelledFacts = {
|
|
1523
|
-
...facts,
|
|
1524
|
-
runId: 'reason-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
1525
|
-
usageDelivered: false,
|
|
1526
|
-
};
|
|
1527
|
-
const cancelledTask = await h.registry.startManagedTask(h.ctx, {
|
|
1528
|
-
id: cancelledFacts.runId,
|
|
1529
|
-
name: 'fusion reason',
|
|
1530
|
-
command: 'fusion_reason',
|
|
1531
|
-
isAgent: true,
|
|
1532
|
-
completion: cancelled,
|
|
1533
|
-
cancel: () => rejectCancelled?.(new Error('fusion cancelled')),
|
|
1534
|
-
notifyOnCompletion: false,
|
|
1535
|
-
triggerOnCompletion: false,
|
|
1536
|
-
fusion: cancelledFacts,
|
|
1537
|
-
stopWaitMs: 100,
|
|
1538
|
-
});
|
|
1539
|
-
await h.registry.stopTask(cancelledTask, 'user');
|
|
1540
|
-
assert.equal(cancelledTask.status, 'killed');
|
|
1541
|
-
assert.equal(cancelledTask.managedCancelRequested, true);
|
|
1542
|
-
} finally {
|
|
1543
|
-
await cleanup(h.root);
|
|
1544
|
-
}
|
|
1545
|
-
});
|
|
1546
|
-
|
|
1547
|
-
void it('prunes oldest finished tasks while preserving running tasks', async () => {
|
|
1548
|
-
let clock = 1_000;
|
|
1549
|
-
const h = await createHarness({
|
|
1550
|
-
maxRecentTasks: 3,
|
|
1551
|
-
now: () => clock++,
|
|
1552
|
-
});
|
|
1553
|
-
try {
|
|
1554
|
-
const running = await h.registry.startTask(h.ctx, 'sleep forever', {
|
|
1555
|
-
name: 'Still Running',
|
|
1556
|
-
notifyOnCompletion: false,
|
|
1557
|
-
});
|
|
1558
|
-
assert.equal(running.status, 'running');
|
|
1559
|
-
|
|
1560
|
-
for (let i = 1; i <= 4; i++) {
|
|
1561
|
-
const suffix = String(i);
|
|
1562
|
-
const task = await h.registry.startTask(h.ctx, `printf ${suffix}`, {
|
|
1563
|
-
name: `Finished ${suffix}`,
|
|
1564
|
-
notifyOnCompletion: false,
|
|
1565
|
-
});
|
|
1566
|
-
lastSpawn(h).child.close(0, null);
|
|
1567
|
-
await waitFor(() => task.status === 'completed', `finished ${suffix}`);
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
await waitFor(() => h.registry.allTasks().length <= 3, 'old finished tasks pruned');
|
|
1571
|
-
const names = h.registry
|
|
1572
|
-
.allTasks()
|
|
1573
|
-
.map((task) => task.name)
|
|
1574
|
-
.sort();
|
|
1575
|
-
assert.deepEqual(names, ['Finished 3', 'Finished 4', 'Still Running'].sort());
|
|
1576
|
-
} finally {
|
|
1577
|
-
await cleanup(h.root);
|
|
1578
|
-
}
|
|
1579
|
-
});
|
|
1580
|
-
});
|