@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.
Files changed (59) hide show
  1. package/package.json +3 -2
  2. package/src/tools.ts +3 -1
  3. package/src/__tests__/anthropic-attribution.test.ts +0 -195
  4. package/src/__tests__/config.test.ts +0 -137
  5. package/src/__tests__/core.test.ts +0 -493
  6. package/src/__tests__/delegate-artifacts.test.ts +0 -528
  7. package/src/__tests__/delegate-budget.test.ts +0 -456
  8. package/src/__tests__/delegate-launch.test.ts +0 -676
  9. package/src/__tests__/delegate-result-package.test.ts +0 -350
  10. package/src/__tests__/delegate-seed.test.ts +0 -392
  11. package/src/__tests__/durable-fs.test.ts +0 -559
  12. package/src/__tests__/extension-api.test.ts +0 -579
  13. package/src/__tests__/fusion-artifacts.test.ts +0 -1039
  14. package/src/__tests__/fusion-budget.test.ts +0 -1356
  15. package/src/__tests__/fusion-claude-cache.test.ts +0 -320
  16. package/src/__tests__/fusion-config.test.ts +0 -335
  17. package/src/__tests__/fusion-context-prompts.test.ts +0 -670
  18. package/src/__tests__/fusion-evaluation.test.ts +0 -315
  19. package/src/__tests__/fusion-extraction-equivalence.test.ts +0 -58
  20. package/src/__tests__/fusion-golden-bytes.test.ts +0 -35
  21. package/src/__tests__/fusion-high-cardinality.test.ts +0 -192
  22. package/src/__tests__/fusion-model-selector.test.ts +0 -205
  23. package/src/__tests__/fusion-orchestrator.test.ts +0 -1194
  24. package/src/__tests__/fusion-rpc.test.ts +0 -369
  25. package/src/__tests__/fusion-sdk.test.ts +0 -1226
  26. package/src/__tests__/fusion-v5-core.test.ts +0 -219
  27. package/src/__tests__/fusion-validate-orchestrator.test.ts +0 -240
  28. package/src/__tests__/fusion-web-fetch.test.ts +0 -485
  29. package/src/__tests__/fusion-workflows.test.ts +0 -59
  30. package/src/__tests__/helpers/delegate-deterministic-seed.ts +0 -109
  31. package/src/__tests__/helpers/delegate-seed-subprocess.ts +0 -10
  32. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +0 -21
  33. package/src/__tests__/helpers/fusion-canonical.ts +0 -140
  34. package/src/__tests__/helpers/fusion-fake-pi.ts +0 -279
  35. package/src/__tests__/helpers/fusion-golden-corpus.ts +0 -500
  36. package/src/__tests__/helpers/fusion-high-cardinality.ts +0 -140
  37. package/src/__tests__/helpers/normalize.ts +0 -22
  38. package/src/__tests__/helpers/pi-hook-contract-evidence.json +0 -18
  39. package/src/__tests__/pi-launch.test.ts +0 -202
  40. package/src/__tests__/registry.test.ts +0 -1580
  41. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +0 -130
  42. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +0 -631
  43. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +0 -403
  44. package/src/__tests__/scripted-provider/follow-up.test.ts +0 -448
  45. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +0 -132
  46. package/src/__tests__/scripted-provider/fusion-reason.test.ts +0 -310
  47. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +0 -163
  48. package/src/__tests__/scripted-provider/hook-contract-provider.ts +0 -179
  49. package/src/__tests__/scripted-provider/hook-probe-a.ts +0 -3
  50. package/src/__tests__/scripted-provider/hook-probe-b.ts +0 -3
  51. package/src/__tests__/scripted-provider/hook-probe-extension.ts +0 -126
  52. package/src/__tests__/scripted-provider/output-recovery-provider.ts +0 -153
  53. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +0 -18
  54. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +0 -477
  55. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +0 -28
  56. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +0 -49
  57. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +0 -408
  58. package/src/__tests__/task-manager.test.ts +0 -479
  59. package/src/__tests__/windows-taskkill.test.ts +0 -161
@@ -1,579 +0,0 @@
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
- });