@pi-unipi/background-tasks 2.6.1
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/README.md +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import type { EventBus } from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import {
|
|
9
|
+
BG_EXTENSION_CAPABILITIES,
|
|
10
|
+
BG_REQUEST_CHANNEL,
|
|
11
|
+
BG_REQUEST_SCHEMA,
|
|
12
|
+
BG_RESPONSE_CHANNEL,
|
|
13
|
+
BG_RESPONSE_SCHEMA,
|
|
14
|
+
BG_TERMINAL_CHANNEL,
|
|
15
|
+
BG_TERMINAL_SCHEMA,
|
|
16
|
+
installBackgroundTaskExtensionApi,
|
|
17
|
+
type BackgroundTaskExtensionResponse,
|
|
18
|
+
type BackgroundTaskExtensionService,
|
|
19
|
+
type BackgroundTaskExtensionTerminal,
|
|
20
|
+
} from '../extension-api.js';
|
|
21
|
+
import {
|
|
22
|
+
BackgroundTaskRegistry,
|
|
23
|
+
type BackgroundTaskContext,
|
|
24
|
+
type BackgroundTaskSpawn,
|
|
25
|
+
} from '../registry.js';
|
|
26
|
+
import type { BackgroundTaskChildProcess } from '../child-process.js';
|
|
27
|
+
import type { TaskkillOutcome, WindowsKillPhase } from '../windows-taskkill.js';
|
|
28
|
+
import type { BgTaskSnapshot } from '../types.js';
|
|
29
|
+
|
|
30
|
+
class MemoryEventBus implements EventBus {
|
|
31
|
+
private readonly listeners = new Map<string, Set<(data: unknown) => void>>();
|
|
32
|
+
|
|
33
|
+
emit(channel: string, data: unknown): void {
|
|
34
|
+
for (const listener of [...(this.listeners.get(channel) ?? [])]) listener(data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
38
|
+
let set = this.listeners.get(channel);
|
|
39
|
+
if (!set) {
|
|
40
|
+
set = new Set();
|
|
41
|
+
this.listeners.set(channel, set);
|
|
42
|
+
}
|
|
43
|
+
set.add(handler);
|
|
44
|
+
return () => {
|
|
45
|
+
set?.delete(handler);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class FakeChild extends EventEmitter {
|
|
51
|
+
stdout = new EventEmitter();
|
|
52
|
+
stderr = new EventEmitter();
|
|
53
|
+
pid: number;
|
|
54
|
+
killCalls: Array<NodeJS.Signals | undefined> = [];
|
|
55
|
+
closeOnKill = true;
|
|
56
|
+
|
|
57
|
+
constructor(pid: number) {
|
|
58
|
+
super();
|
|
59
|
+
this.pid = pid;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
kill(signal?: NodeJS.Signals): boolean {
|
|
63
|
+
this.killCalls.push(signal);
|
|
64
|
+
if (this.closeOnKill) queueMicrotask(() => this.close(null, signal ?? 'SIGTERM'));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
close(code: number | null = 0, signal: NodeJS.Signals | null = null): void {
|
|
69
|
+
this.emit('close', code, signal);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Harness {
|
|
74
|
+
root: string;
|
|
75
|
+
ctx: BackgroundTaskContext;
|
|
76
|
+
bus: MemoryEventBus;
|
|
77
|
+
registry: BackgroundTaskRegistry;
|
|
78
|
+
setCtx(value: BackgroundTaskContext | undefined): void;
|
|
79
|
+
setShutdown(value: boolean): void;
|
|
80
|
+
close(): void;
|
|
81
|
+
spawnCount(): number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function createHarness(): Promise<Harness> {
|
|
85
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-bg-api-'));
|
|
86
|
+
const cwd = join(root, 'project');
|
|
87
|
+
await mkdir(cwd, { recursive: true });
|
|
88
|
+
const bus = new MemoryEventBus();
|
|
89
|
+
let currentCtx: BackgroundTaskContext | undefined;
|
|
90
|
+
let shuttingDown = false;
|
|
91
|
+
let spawns = 0;
|
|
92
|
+
const registry = new BackgroundTaskRegistry({
|
|
93
|
+
sendCompletionNotification: () => {},
|
|
94
|
+
spawn: () => {
|
|
95
|
+
spawns += 1;
|
|
96
|
+
throw new Error('spawn should not be reached by this protocol test');
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const ctx: BackgroundTaskContext = {
|
|
100
|
+
cwd,
|
|
101
|
+
sessionId: 'extension-api-unit',
|
|
102
|
+
modelRegistry: { getAll: () => [] },
|
|
103
|
+
model: undefined,
|
|
104
|
+
};
|
|
105
|
+
currentCtx = ctx;
|
|
106
|
+
const service = installBackgroundTaskExtensionApi({
|
|
107
|
+
events: bus,
|
|
108
|
+
registry,
|
|
109
|
+
getContext: () => currentCtx,
|
|
110
|
+
isShuttingDown: () => shuttingDown,
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
root,
|
|
114
|
+
ctx,
|
|
115
|
+
bus,
|
|
116
|
+
registry,
|
|
117
|
+
setCtx(value) {
|
|
118
|
+
currentCtx = value;
|
|
119
|
+
},
|
|
120
|
+
setShutdown(value) {
|
|
121
|
+
shuttingDown = value;
|
|
122
|
+
},
|
|
123
|
+
close() {
|
|
124
|
+
service.close();
|
|
125
|
+
},
|
|
126
|
+
spawnCount() {
|
|
127
|
+
return spawns;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ProtocolHarness {
|
|
133
|
+
root: string;
|
|
134
|
+
ctx: BackgroundTaskContext;
|
|
135
|
+
bus: MemoryEventBus;
|
|
136
|
+
children: FakeChild[];
|
|
137
|
+
close(): void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function createProtocolHarness(
|
|
141
|
+
options: {
|
|
142
|
+
onSpawn?: ((child: FakeChild) => void) | undefined;
|
|
143
|
+
} = {},
|
|
144
|
+
): Promise<ProtocolHarness> {
|
|
145
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-bg-api-protocol-'));
|
|
146
|
+
const cwd = join(root, 'project');
|
|
147
|
+
await mkdir(cwd, { recursive: true });
|
|
148
|
+
const bus = new MemoryEventBus();
|
|
149
|
+
const children: FakeChild[] = [];
|
|
150
|
+
let pid = 5100;
|
|
151
|
+
let idSeq = 0;
|
|
152
|
+
let service: BackgroundTaskExtensionService | undefined;
|
|
153
|
+
const spawn: BackgroundTaskSpawn = () => {
|
|
154
|
+
const child = new FakeChild(++pid);
|
|
155
|
+
children.push(child);
|
|
156
|
+
options.onSpawn?.(child);
|
|
157
|
+
return child as unknown as BackgroundTaskChildProcess;
|
|
158
|
+
};
|
|
159
|
+
// This harness spawns a FakeChild rather than a real process, so its pid is
|
|
160
|
+
// fabricated. Both termination seams are therefore injected and routed back
|
|
161
|
+
// to the fake child.
|
|
162
|
+
//
|
|
163
|
+
// Without them the registry would resolve the live host platform: on Windows
|
|
164
|
+
// it took the taskkill branch and issued a real process-tree kill against a
|
|
165
|
+
// pid that never existed, so FakeChild.kill was never called, no 'close' was
|
|
166
|
+
// ever emitted, and the task never reached a terminal state.
|
|
167
|
+
//
|
|
168
|
+
// Pinning the platform keeps this protocol test deterministic on every host.
|
|
169
|
+
// Real Windows termination behaviour is proven on a real Windows host by
|
|
170
|
+
// tests/windows/windows-integration.test.ts (grandchild tree teardown) and
|
|
171
|
+
// by the injected killTree cases in tests/unit/registry.test.ts.
|
|
172
|
+
const killProcess = (pid: number, signal?: NodeJS.Signals | number): boolean => {
|
|
173
|
+
const target = children.find((child) => child.pid === Math.abs(pid));
|
|
174
|
+
if (!target) throw new Error(`no fake child for pid ${String(pid)}`);
|
|
175
|
+
target.kill(typeof signal === 'string' ? signal : 'SIGTERM');
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
const killTree = (pid: number, phase: WindowsKillPhase): Promise<TaskkillOutcome> => {
|
|
179
|
+
killProcess(pid, phase === 'force' ? 'SIGKILL' : 'SIGTERM');
|
|
180
|
+
return Promise.resolve({
|
|
181
|
+
exitCode: 0,
|
|
182
|
+
signal: null,
|
|
183
|
+
stdout: '',
|
|
184
|
+
stderr: '',
|
|
185
|
+
stdoutTruncated: false,
|
|
186
|
+
stderrTruncated: false,
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
const registry = new BackgroundTaskRegistry({
|
|
190
|
+
makeTaskId: () => `bproto${String(++idSeq).padStart(3, '0')}`,
|
|
191
|
+
sendCompletionNotification: () => {},
|
|
192
|
+
killGraceMs: 20,
|
|
193
|
+
stopWaitMs: 500,
|
|
194
|
+
platform: 'linux',
|
|
195
|
+
killProcess,
|
|
196
|
+
killTree,
|
|
197
|
+
spawn,
|
|
198
|
+
publishTerminal: (task) => {
|
|
199
|
+
if (!service) throw new Error('test EventBus service is not installed');
|
|
200
|
+
service.publishTerminal(task);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
const ctx: BackgroundTaskContext = {
|
|
204
|
+
cwd,
|
|
205
|
+
sessionId: 'extension-api-protocol-unit',
|
|
206
|
+
modelRegistry: { getAll: () => [] },
|
|
207
|
+
model: undefined,
|
|
208
|
+
};
|
|
209
|
+
service = installBackgroundTaskExtensionApi({
|
|
210
|
+
events: bus,
|
|
211
|
+
registry,
|
|
212
|
+
getContext: () => ctx,
|
|
213
|
+
isShuttingDown: () => false,
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
root,
|
|
217
|
+
ctx,
|
|
218
|
+
bus,
|
|
219
|
+
children,
|
|
220
|
+
close() {
|
|
221
|
+
service?.close();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
227
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function requireResponse(value: unknown): BackgroundTaskExtensionResponse {
|
|
231
|
+
assert.ok(isRecord(value), 'response must be an object');
|
|
232
|
+
assert.equal(value['schema_version'], BG_RESPONSE_SCHEMA);
|
|
233
|
+
assert.equal(typeof value['request_id'], 'string');
|
|
234
|
+
assert.equal(typeof value['operation'], 'string');
|
|
235
|
+
assert.equal(typeof value['ok'], 'boolean');
|
|
236
|
+
const hasResult = Object.prototype.hasOwnProperty.call(value, 'result');
|
|
237
|
+
const hasError = Object.prototype.hasOwnProperty.call(value, 'error');
|
|
238
|
+
assert.notEqual(hasResult, hasError, 'response must contain exactly one of result/error');
|
|
239
|
+
return value as BackgroundTaskExtensionResponse;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function requireTask(value: unknown, label: string): BgTaskSnapshot {
|
|
243
|
+
assert.ok(isRecord(value), `${label} must be an object`);
|
|
244
|
+
const id = value['id'];
|
|
245
|
+
const command = value['command'];
|
|
246
|
+
const status = value['status'];
|
|
247
|
+
const outputPath = value['outputPath'];
|
|
248
|
+
if (typeof id !== 'string') assert.fail(`${label}.id`);
|
|
249
|
+
if (typeof command !== 'string') assert.fail(`${label}.command`);
|
|
250
|
+
if (
|
|
251
|
+
status !== 'running' &&
|
|
252
|
+
status !== 'completed' &&
|
|
253
|
+
status !== 'failed' &&
|
|
254
|
+
status !== 'killed'
|
|
255
|
+
) {
|
|
256
|
+
assert.fail(`${label}.status`);
|
|
257
|
+
}
|
|
258
|
+
if (typeof outputPath !== 'string') assert.fail(`${label}.outputPath`);
|
|
259
|
+
const partial = value as Partial<BgTaskSnapshot>;
|
|
260
|
+
return {
|
|
261
|
+
...partial,
|
|
262
|
+
id,
|
|
263
|
+
command,
|
|
264
|
+
status,
|
|
265
|
+
outputPath,
|
|
266
|
+
cwd: partial.cwd ?? '',
|
|
267
|
+
startTime: partial.startTime ?? 0,
|
|
268
|
+
bytesWritten: partial.bytesWritten ?? 0,
|
|
269
|
+
isAgent: partial.isAgent ?? false,
|
|
270
|
+
notified: partial.notified ?? false,
|
|
271
|
+
notifyOnCompletion: partial.notifyOnCompletion ?? false,
|
|
272
|
+
triggerOnCompletion: partial.triggerOnCompletion ?? false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function requireTerminal(value: unknown): BackgroundTaskExtensionTerminal {
|
|
277
|
+
assert.ok(isRecord(value), 'terminal must be an object');
|
|
278
|
+
assert.deepEqual(Object.keys(value).sort(), ['schema_version', 'task']);
|
|
279
|
+
assert.equal(value['schema_version'], BG_TERMINAL_SCHEMA);
|
|
280
|
+
return { schema_version: BG_TERMINAL_SCHEMA, task: requireTask(value['task'], 'terminal.task') };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function waitForResponse(
|
|
284
|
+
bus: EventBus,
|
|
285
|
+
requestId: string,
|
|
286
|
+
): Promise<BackgroundTaskExtensionResponse> {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const timer = setTimeout(() => {
|
|
289
|
+
unsubscribe();
|
|
290
|
+
reject(new Error(`timed out waiting for ${requestId}`));
|
|
291
|
+
}, 500);
|
|
292
|
+
const unsubscribe = bus.on(BG_RESPONSE_CHANNEL, (data) => {
|
|
293
|
+
const response = requireResponse(data);
|
|
294
|
+
if (response.request_id !== requestId) return;
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
unsubscribe();
|
|
297
|
+
resolve(response);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function emitRequest(
|
|
303
|
+
bus: EventBus,
|
|
304
|
+
request: unknown,
|
|
305
|
+
): Promise<BackgroundTaskExtensionResponse> {
|
|
306
|
+
const requestId =
|
|
307
|
+
isRecord(request) && typeof request['request_id'] === 'string'
|
|
308
|
+
? request['request_id']
|
|
309
|
+
: 'malformed';
|
|
310
|
+
const pending = waitForResponse(bus, requestId);
|
|
311
|
+
bus.emit(BG_REQUEST_CHANNEL, request);
|
|
312
|
+
return pending;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// This suite drives the EventBus terminal-publication protocol against an
|
|
316
|
+
// injected fake child, so no real process is ever created and the budget does
|
|
317
|
+
// not depend on host process-creation cost. A genuine hang must still fail
|
|
318
|
+
// fast on every platform.
|
|
319
|
+
const TERMINAL_WAIT_TIMEOUT_MS = 1500;
|
|
320
|
+
|
|
321
|
+
async function waitForTerminal(
|
|
322
|
+
terminals: readonly BackgroundTaskExtensionTerminal[],
|
|
323
|
+
taskId: string,
|
|
324
|
+
timeoutMs = TERMINAL_WAIT_TIMEOUT_MS,
|
|
325
|
+
): Promise<BackgroundTaskExtensionTerminal> {
|
|
326
|
+
const start = Date.now();
|
|
327
|
+
while (Date.now() - start < timeoutMs) {
|
|
328
|
+
const terminal = terminals.find((entry) => entry.task.id === taskId);
|
|
329
|
+
if (terminal) return terminal;
|
|
330
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
331
|
+
}
|
|
332
|
+
throw new Error(`timed out waiting for terminal ${taskId}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
void describe('background EventBus protocol', () => {
|
|
336
|
+
void it('handshakes capabilities and rejects malformed, duplicate, and unavailable requests', async () => {
|
|
337
|
+
const h = await createHarness();
|
|
338
|
+
try {
|
|
339
|
+
const ok = await emitRequest(h.bus, {
|
|
340
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
341
|
+
request_id: 'cap-1',
|
|
342
|
+
operation: 'capabilities',
|
|
343
|
+
payload: {},
|
|
344
|
+
});
|
|
345
|
+
assert.equal(ok.ok, true);
|
|
346
|
+
assert.deepEqual(ok.ok ? ok.result : undefined, BG_EXTENSION_CAPABILITIES);
|
|
347
|
+
|
|
348
|
+
const duplicate = await emitRequest(h.bus, {
|
|
349
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
350
|
+
request_id: 'cap-1',
|
|
351
|
+
operation: 'capabilities',
|
|
352
|
+
payload: {},
|
|
353
|
+
});
|
|
354
|
+
assert.equal(duplicate.ok, false);
|
|
355
|
+
assert.match(duplicate.ok ? '' : duplicate.error, /duplicate request_id/u);
|
|
356
|
+
|
|
357
|
+
const unknown = await emitRequest(h.bus, {
|
|
358
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
359
|
+
request_id: 'unknown-op',
|
|
360
|
+
operation: 'bogus',
|
|
361
|
+
payload: {},
|
|
362
|
+
});
|
|
363
|
+
assert.equal(unknown.ok, false);
|
|
364
|
+
assert.equal(unknown.operation, 'bogus');
|
|
365
|
+
assert.match(unknown.ok ? '' : unknown.error, /capabilities, run, status, logs, kill/u);
|
|
366
|
+
|
|
367
|
+
const extra = await emitRequest(h.bus, {
|
|
368
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
369
|
+
request_id: 'extra-key',
|
|
370
|
+
operation: 'capabilities',
|
|
371
|
+
payload: {},
|
|
372
|
+
extra: true,
|
|
373
|
+
});
|
|
374
|
+
assert.equal(extra.ok, false);
|
|
375
|
+
assert.match(extra.ok ? '' : extra.error, /unknown key extra/u);
|
|
376
|
+
|
|
377
|
+
const notObject = await emitRequest(h.bus, []);
|
|
378
|
+
assert.equal(notObject.ok, false);
|
|
379
|
+
assert.equal(notObject.request_id, 'malformed');
|
|
380
|
+
assert.match(notObject.ok ? '' : notObject.error, /request frame must be an object/u);
|
|
381
|
+
|
|
382
|
+
const missingPayload = await emitRequest(h.bus, {
|
|
383
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
384
|
+
request_id: 'missing-payload',
|
|
385
|
+
operation: 'status',
|
|
386
|
+
});
|
|
387
|
+
assert.equal(missingPayload.ok, false);
|
|
388
|
+
assert.match(missingPayload.ok ? '' : missingPayload.error, /payload is required/u);
|
|
389
|
+
|
|
390
|
+
const unknownPayloadKey = await emitRequest(h.bus, {
|
|
391
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
392
|
+
request_id: 'unknown-payload-key',
|
|
393
|
+
operation: 'status',
|
|
394
|
+
payload: { taskId: 'b123', extra: true },
|
|
395
|
+
});
|
|
396
|
+
assert.equal(unknownPayloadKey.ok, false);
|
|
397
|
+
assert.match(unknownPayloadKey.ok ? '' : unknownPayloadKey.error, /unknown key extra/u);
|
|
398
|
+
|
|
399
|
+
const malformedPayload = await emitRequest(h.bus, {
|
|
400
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
401
|
+
request_id: 'bad-run',
|
|
402
|
+
operation: 'run',
|
|
403
|
+
payload: {
|
|
404
|
+
name: 'Bad Run',
|
|
405
|
+
command: 'printf nope',
|
|
406
|
+
isAgent: false,
|
|
407
|
+
timeoutSeconds: null,
|
|
408
|
+
notifyOnCompletion: true,
|
|
409
|
+
triggerOnCompletion: true,
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
assert.equal(malformedPayload.ok, false);
|
|
413
|
+
assert.match(malformedPayload.ok ? '' : malformedPayload.error, /positive integer/u);
|
|
414
|
+
assert.equal(h.spawnCount(), 0);
|
|
415
|
+
|
|
416
|
+
h.setCtx(undefined);
|
|
417
|
+
const missingCtx = await emitRequest(h.bus, {
|
|
418
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
419
|
+
request_id: 'missing-ctx',
|
|
420
|
+
operation: 'capabilities',
|
|
421
|
+
payload: {},
|
|
422
|
+
});
|
|
423
|
+
assert.equal(missingCtx.ok, false);
|
|
424
|
+
assert.match(missingCtx.ok ? '' : missingCtx.error, /before session_start/u);
|
|
425
|
+
|
|
426
|
+
h.setCtx(h.ctx);
|
|
427
|
+
h.setShutdown(true);
|
|
428
|
+
h.registry.setShuttingDown(true);
|
|
429
|
+
const shutdown = await emitRequest(h.bus, {
|
|
430
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
431
|
+
request_id: 'shutdown',
|
|
432
|
+
operation: 'status',
|
|
433
|
+
payload: {},
|
|
434
|
+
});
|
|
435
|
+
assert.equal(shutdown.ok, false);
|
|
436
|
+
assert.match(shutdown.ok ? '' : shutdown.error, /shutting down/u);
|
|
437
|
+
} finally {
|
|
438
|
+
h.close();
|
|
439
|
+
await rm(h.root, { recursive: true, force: true });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
void it('publishes exactly one correlated terminal after the run response for every terminal path', async () => {
|
|
444
|
+
async function runCase(options: {
|
|
445
|
+
label: string;
|
|
446
|
+
expectedStatus: 'completed' | 'failed' | 'killed';
|
|
447
|
+
timeoutMs?: number | undefined;
|
|
448
|
+
timeoutSeconds?: number | undefined;
|
|
449
|
+
onSpawn?: ((child: FakeChild) => void) | undefined;
|
|
450
|
+
afterRun?:
|
|
451
|
+
| ((h: ProtocolHarness, task: BgTaskSnapshot, order: string[]) => Promise<void> | void)
|
|
452
|
+
| undefined;
|
|
453
|
+
}): Promise<void> {
|
|
454
|
+
const h = await createProtocolHarness({ onSpawn: options.onSpawn });
|
|
455
|
+
const terminals: BackgroundTaskExtensionTerminal[] = [];
|
|
456
|
+
const order: string[] = [];
|
|
457
|
+
const unsubscribeResponse = h.bus.on(BG_RESPONSE_CHANNEL, (data) => {
|
|
458
|
+
const response = requireResponse(data);
|
|
459
|
+
if (response.request_id === `run-${options.label}`) order.push('run-response');
|
|
460
|
+
if (response.request_id === `kill-${options.label}`) order.push('kill-response');
|
|
461
|
+
});
|
|
462
|
+
const unsubscribeTerminal = h.bus.on(BG_TERMINAL_CHANNEL, (data) => {
|
|
463
|
+
const terminal = requireTerminal(data);
|
|
464
|
+
terminals.push(terminal);
|
|
465
|
+
order.push(`terminal:${terminal.task.id}:${terminal.task.status}`);
|
|
466
|
+
});
|
|
467
|
+
try {
|
|
468
|
+
// The spawn seam returns a FakeChild, so this command string is never
|
|
469
|
+
// executed by a real shell. It is asserted verbatim on the task record.
|
|
470
|
+
const echoCommand = `echo ${options.label}`;
|
|
471
|
+
const payload: Record<string, unknown> = {
|
|
472
|
+
name: `Case ${options.label}`,
|
|
473
|
+
command: echoCommand,
|
|
474
|
+
isAgent: false,
|
|
475
|
+
notifyOnCompletion: false,
|
|
476
|
+
triggerOnCompletion: false,
|
|
477
|
+
};
|
|
478
|
+
if (options.timeoutSeconds !== undefined)
|
|
479
|
+
payload['timeoutSeconds'] = options.timeoutSeconds;
|
|
480
|
+
const run = await emitRequest(h.bus, {
|
|
481
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
482
|
+
request_id: `run-${options.label}`,
|
|
483
|
+
operation: 'run',
|
|
484
|
+
payload,
|
|
485
|
+
});
|
|
486
|
+
assert.equal(run.ok, true, run.ok ? 'ok' : run.error);
|
|
487
|
+
const task = requireTask(run.ok ? run.result : undefined, `${options.label}.run.result`);
|
|
488
|
+
assert.equal(task.command, echoCommand);
|
|
489
|
+
await options.afterRun?.(h, task, order);
|
|
490
|
+
const terminal = await waitForTerminal(
|
|
491
|
+
terminals,
|
|
492
|
+
task.id,
|
|
493
|
+
options.timeoutMs ?? TERMINAL_WAIT_TIMEOUT_MS,
|
|
494
|
+
);
|
|
495
|
+
assert.equal(terminal.task.id, task.id);
|
|
496
|
+
assert.equal(terminal.task.status, options.expectedStatus);
|
|
497
|
+
assert.equal(
|
|
498
|
+
terminals.filter((entry) => entry.task.id === task.id).length,
|
|
499
|
+
1,
|
|
500
|
+
`${options.label} must publish exactly one terminal`,
|
|
501
|
+
);
|
|
502
|
+
const responseIndex = order.indexOf('run-response');
|
|
503
|
+
const terminalIndex = order.findIndex((entry) => entry.startsWith(`terminal:${task.id}:`));
|
|
504
|
+
assert.ok(responseIndex >= 0, `${options.label} missing run response order marker`);
|
|
505
|
+
assert.ok(
|
|
506
|
+
terminalIndex > responseIndex,
|
|
507
|
+
`${options.label} terminal must follow run response`,
|
|
508
|
+
);
|
|
509
|
+
if (options.expectedStatus === 'killed') {
|
|
510
|
+
const killResponseIndex = order.indexOf('kill-response');
|
|
511
|
+
assert.ok(killResponseIndex >= 0, 'killed case missing kill response marker');
|
|
512
|
+
assert.ok(terminalIndex > killResponseIndex, 'killed terminal must follow kill response');
|
|
513
|
+
}
|
|
514
|
+
} finally {
|
|
515
|
+
unsubscribeTerminal();
|
|
516
|
+
unsubscribeResponse();
|
|
517
|
+
h.close();
|
|
518
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
519
|
+
await rm(h.root, { recursive: true, force: true });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
await runCase({
|
|
524
|
+
label: 'immediate',
|
|
525
|
+
expectedStatus: 'completed',
|
|
526
|
+
onSpawn: (child) => queueMicrotask(() => child.close(0, null)),
|
|
527
|
+
});
|
|
528
|
+
await runCase({
|
|
529
|
+
label: 'normal',
|
|
530
|
+
expectedStatus: 'completed',
|
|
531
|
+
afterRun: (h) => h.children[0]?.close(0, null),
|
|
532
|
+
});
|
|
533
|
+
await runCase({
|
|
534
|
+
label: 'failed',
|
|
535
|
+
expectedStatus: 'failed',
|
|
536
|
+
afterRun: (h) => h.children[0]?.close(9, null),
|
|
537
|
+
});
|
|
538
|
+
await runCase({
|
|
539
|
+
label: 'timeout',
|
|
540
|
+
expectedStatus: 'failed',
|
|
541
|
+
timeoutSeconds: 1,
|
|
542
|
+
timeoutMs: 2500,
|
|
543
|
+
});
|
|
544
|
+
await runCase({
|
|
545
|
+
label: 'killed',
|
|
546
|
+
expectedStatus: 'killed',
|
|
547
|
+
afterRun: async (h, task) => {
|
|
548
|
+
const kill = await emitRequest(h.bus, {
|
|
549
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
550
|
+
request_id: 'kill-killed',
|
|
551
|
+
operation: 'kill',
|
|
552
|
+
payload: { taskId: task.id },
|
|
553
|
+
});
|
|
554
|
+
assert.equal(kill.ok, true, kill.ok ? 'ok' : kill.error);
|
|
555
|
+
const result = kill.ok ? kill.result : undefined;
|
|
556
|
+
assert.ok(isRecord(result), 'kill result must be an object');
|
|
557
|
+
const resultRecord: Record<string, unknown> = result;
|
|
558
|
+
assert.equal(requireTask(resultRecord['task'], 'kill.result.task').status, 'killed');
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
void it('unsubscribes cleanly when the service closes', async () => {
|
|
564
|
+
const h = await createHarness();
|
|
565
|
+
try {
|
|
566
|
+
h.close();
|
|
567
|
+
const pending = waitForResponse(h.bus, 'after-close');
|
|
568
|
+
h.bus.emit(BG_REQUEST_CHANNEL, {
|
|
569
|
+
schema_version: BG_REQUEST_SCHEMA,
|
|
570
|
+
request_id: 'after-close',
|
|
571
|
+
operation: 'capabilities',
|
|
572
|
+
payload: {},
|
|
573
|
+
});
|
|
574
|
+
await assert.rejects(pending, /timed out/u);
|
|
575
|
+
} finally {
|
|
576
|
+
await rm(h.root, { recursive: true, force: true });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
});
|