Files
waggle-os/packages/agent/tests/tool-process-tracker.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

414 lines
15 KiB
TypeScript

/**
* AI-OS Phase 4 polish — process tracker tests.
*
* Hermetic except for the bounded Windows process-tree regression. Liveness
* and termination are injected everywhere else.
*/
import { describe, it, expect } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveWindowsTaskkillPath } from '../src/external-tool-runner.js';
import { ToolProcessTracker, type TrackedProcess } from '../src/tool-process-tracker.js';
describe('ToolProcessTracker', () => {
it('registers a pid and reports it', () => {
const tracker = new ToolProcessTracker({
isAlive: () => true,
now: () => new Date('2026-05-20T12:00:00Z'),
});
const rec = tracker.register(1234, 'claude-code', 'ws-A');
expect(rec.pid).toBe(1234);
expect(rec.toolId).toBe('claude-code');
expect(rec.workspaceId).toBe('ws-A');
expect(rec.startedAt).toBe('2026-05-20T12:00:00.000Z');
expect(tracker.list()).toHaveLength(1);
});
it('registers third-party adapter ids', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
const rec = tracker.register(4321, 'foo-cli');
expect(rec.toolId).toBe('foo-cli');
expect(tracker.list()[0].toolId).toBe('foo-cli');
});
it('omits workspaceId from the record when not provided', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
const rec = tracker.register(1234, 'cursor');
expect(rec.workspaceId).toBeUndefined();
});
it('re-registering the same pid replaces the prior entry', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
tracker.register(1234, 'claude-code', 'ws-A');
tracker.register(1234, 'cursor', 'ws-B');
const all = tracker.list();
expect(all).toHaveLength(1);
expect(all[0].toolId).toBe('cursor');
expect(all[0].workspaceId).toBe('ws-B');
});
it('list() omits dead pids and garbage-collects them', () => {
const alive = new Set<number>([1, 2]);
const tracker = new ToolProcessTracker({
isAlive: (pid) => alive.has(pid),
});
tracker.register(1, 'claude-code');
tracker.register(2, 'cursor');
tracker.register(3, 'claude-desktop'); // not alive
expect(tracker.size).toBe(3);
const live = tracker.list();
expect(live.map((p) => p.pid).sort()).toEqual([1, 2]);
expect(tracker.size).toBe(2);
});
it('forget() removes a pid', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
tracker.register(7, 'hermes');
expect(tracker.forget(7)).toBe(true);
expect(tracker.size).toBe(0);
expect(tracker.forget(7)).toBe(false); // already gone
});
it('clear() empties the tracker', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
tracker.register(1, 'claude-code');
tracker.register(2, 'cursor');
tracker.clear();
expect(tracker.size).toBe(0);
expect(tracker.list()).toEqual([]);
});
it('default isAlive returns false for an obviously-dead pid', () => {
// PID 1 is init on POSIX (always alive) but using a giant unlikely
// pid is the cross-platform safe probe.
const tracker = new ToolProcessTracker();
const FAKE_PID = 2147483646; // near max int32
tracker.register(FAKE_PID, 'claude-code');
// list() will probe with the real process.kill(0) — which throws
// on dead pids — and GC the entry.
expect(tracker.list().length).toBeLessThanOrEqual(1); // tolerant
// Now register a definitely-alive pid (this process itself) and
// confirm it stays.
tracker.clear();
tracker.register(process.pid, 'claude-code');
expect(tracker.list()).toHaveLength(1);
});
});
// ── kill() — E-1 ────────────────────────────────────────────────────
describe('ToolProcessTracker.kill', () => {
it.runIf(process.platform === 'win32')('kills the full detached Windows launcher process tree', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle tracker tree '));
const readyPath = path.join(tempRoot, 'descendant ready.txt');
const childCode = [
`const fs = require('node:fs')`,
`fs.writeFileSync(${JSON.stringify(readyPath)}, String(process.pid))`,
'setInterval(() => {}, 1000)',
'setTimeout(() => process.exit(0), 15000)',
].join(';');
const parentCode = [
`const { spawn } = require('node:child_process')`,
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childCode)}], { stdio: 'ignore', windowsHide: true })`,
'child.unref()',
'setInterval(() => {}, 1000)',
'setTimeout(() => process.exit(0), 15000)',
].join(';');
const parent = spawn(process.execPath, ['-e', parentCode], {
cwd: tempRoot,
detached: true,
stdio: 'ignore',
windowsHide: true,
});
if (parent.pid == null) throw new Error('Windows process-tree fixture did not start');
parent.unref();
let childPid: number | undefined;
try {
const readyDeadline = Date.now() + 5_000;
while (!fs.existsSync(readyPath) && Date.now() < readyDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(fs.existsSync(readyPath)).toBe(true);
childPid = Number(fs.readFileSync(readyPath, 'utf8'));
expect(Number.isSafeInteger(childPid) && childPid > 0).toBe(true);
const tracker = new ToolProcessTracker();
tracker.register(parent.pid, 'codex', 'workspace with spaces');
const result = await tracker.kill(parent.pid, 200);
expect(result).toEqual({ ok: true, pid: parent.pid, reason: 'tree-kill-ok' });
expect(() => process.kill(childPid!, 0)).toThrow();
expect(tracker.size).toBe(0);
} finally {
for (const pid of [parent.pid, childPid]) {
if (!pid || !Number.isSafeInteger(pid)) continue;
try {
execFileSync(resolveWindowsTaskkillPath(), ['/PID', String(pid), '/T', '/F'], {
timeout: 5_000,
windowsHide: true,
stdio: 'ignore',
});
} catch {
// The fixed path already removed the process tree.
}
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
it('fails closed when Windows process-tree termination cannot be proven', async () => {
let signalCalls = 0;
const tracker = new ToolProcessTracker({
platform: 'win32',
isAlive: () => true,
killTree: async () => false,
sendSignal: () => {
signalCalls += 1;
return true;
},
});
tracker.register(456, 'codex', 'ws-A');
const result = await tracker.kill(456);
expect(result).toEqual({ ok: false, pid: 456, reason: 'tree-kill-failed' });
expect(signalCalls).toBe(0);
expect(tracker.size).toBe(1);
});
it('does not claim Windows tree cleanup when the tracked root is already gone', async () => {
const tracker = new ToolProcessTracker({
platform: 'win32',
isAlive: () => false,
});
tracker.register(457, 'codex', 'ws-A');
const result = await tracker.kill(457);
expect(result).toEqual({ ok: false, pid: 457, reason: 'tree-cleanup-unverified' });
expect(tracker.size).toBe(1);
});
it('refuses to kill a pid we do not track (UX guard)', async () => {
const tracker = new ToolProcessTracker({
isAlive: () => true,
sendSignal: () => true,
});
const result = await tracker.kill(99999);
expect(result.ok).toBe(false);
expect(result.reason).toBe('not-tracked');
});
it('reports already-dead and GCs the entry when pid is gone', async () => {
const tracker = new ToolProcessTracker({
platform: 'linux',
isAlive: () => false, // already dead
sendSignal: () => true,
});
tracker.register(123, 'claude-code');
const result = await tracker.kill(123);
expect(result.ok).toBe(true);
expect(result.reason).toBe('already-dead');
expect(tracker.size).toBe(0);
});
it('SIGTERM happy path — sends term, process dies within grace, GC entry', async () => {
const signals: Array<{ pid: number; signal: NodeJS.Signals | number }> = [];
let alive = true;
const tracker = new ToolProcessTracker({
platform: 'linux',
isAlive: () => alive,
sendSignal: (pid, signal) => {
signals.push({ pid, signal });
if (signal === 'SIGTERM') alive = false; // simulate graceful exit
return true;
},
delay: async () => {
/* skip the wait in tests */
},
});
tracker.register(456, 'cursor');
const result = await tracker.kill(456, 1000);
expect(result.ok).toBe(true);
expect(result.reason).toBe('sigterm-ok');
expect(signals).toEqual([{ pid: 456, signal: 'SIGTERM' }]);
expect(tracker.size).toBe(0);
});
it('escalates to SIGKILL when SIGTERM is ignored', async () => {
const signals: Array<NodeJS.Signals | number> = [];
let alive = true;
const tracker = new ToolProcessTracker({
platform: 'linux',
isAlive: () => alive,
sendSignal: (_pid, signal) => {
signals.push(signal);
// SIGTERM ignored; SIGKILL succeeds.
if (signal === 'SIGKILL') alive = false;
return true;
},
delay: async () => undefined,
});
tracker.register(789, 'claude-desktop');
const result = await tracker.kill(789);
expect(result.ok).toBe(true);
expect(result.reason).toBe('sigkill-ok');
expect(signals).toEqual(['SIGTERM', 'SIGKILL']);
expect(tracker.size).toBe(0);
});
it('reports both-failed when neither signal lands', async () => {
const tracker = new ToolProcessTracker({
platform: 'linux',
isAlive: () => true,
sendSignal: () => false, // both signals refused (e.g. EPERM)
delay: async () => undefined,
});
tracker.register(111, 'claude-code');
const result = await tracker.kill(111);
expect(result.ok).toBe(false);
expect(result.reason).toBe('sigterm-failed-sigkill-failed');
// Entry retained so the UI can surface the failure + retry.
expect(tracker.size).toBe(1);
});
});
// ── persistence + boot reconciliation (#2) ──────────────────────────
// Cross-restart attribution: the spawned tool outlives the sidecar, so
// on restart we reload a pidfile, probe liveness, keep the survivors,
// and prune the dead — instead of dropping everything.
describe('ToolProcessTracker — persistence', () => {
function memStore(initial: TrackedProcess[] = []) {
let saved: TrackedProcess[] = initial.map((r) => ({ ...r }));
const saves: TrackedProcess[][] = [];
return {
loadPersisted: (): TrackedProcess[] => saved.map((r) => ({ ...r })),
savePersisted: (recs: readonly TrackedProcess[]): void => {
saved = recs.map((r) => ({ ...r }));
saves.push(recs.map((r) => ({ ...r })));
},
get current(): TrackedProcess[] {
return saved;
},
saves,
};
}
it('saves to the store on register', () => {
const store = memStore();
const tracker = new ToolProcessTracker({
isAlive: () => true,
loadPersisted: store.loadPersisted,
savePersisted: store.savePersisted,
});
tracker.register(100, 'claude-code', 'ws-A');
expect(store.current.map((r) => r.pid)).toEqual([100]);
});
it('reconciles on construction: keeps alive pids, drops dead, prunes the store', () => {
const store = memStore([
{ pid: 1, toolId: 'claude-code', startedAt: '2026-06-29T00:00:00.000Z' },
{ pid: 2, toolId: 'cursor', startedAt: '2026-06-29T00:00:00.000Z' },
]);
const tracker = new ToolProcessTracker({
isAlive: (pid) => pid === 1,
loadPersisted: store.loadPersisted,
savePersisted: store.savePersisted,
});
expect(tracker.list().map((r) => r.pid)).toEqual([1]);
expect(store.current.map((r) => r.pid)).toEqual([1]); // dead pid pruned from disk
});
it('persists on forget', () => {
const store = memStore();
const tracker = new ToolProcessTracker({
isAlive: () => true,
loadPersisted: store.loadPersisted,
savePersisted: store.savePersisted,
});
tracker.register(5, 'hermes');
tracker.forget(5);
expect(store.current).toEqual([]);
});
it('persists dead pid pruning performed by list()', () => {
const store = memStore();
const alive = new Set([5, 6]);
const tracker = new ToolProcessTracker({
isAlive: (pid) => alive.has(pid),
loadPersisted: store.loadPersisted,
savePersisted: store.savePersisted,
});
tracker.register(5, 'hermes');
tracker.register(6, 'cursor');
alive.delete(6);
expect(tracker.list().map((process) => process.pid)).toEqual([5]);
expect(store.current.map((process) => process.pid)).toEqual([5]);
});
it('persists on kill', async () => {
const store = memStore();
let alive = true;
const tracker = new ToolProcessTracker({
platform: 'linux',
isAlive: () => alive,
sendSignal: (_p, s) => {
if (s === 'SIGTERM') alive = false;
return true;
},
delay: async () => undefined,
loadPersisted: store.loadPersisted,
savePersisted: store.savePersisted,
});
tracker.register(9, 'cursor');
await tracker.kill(9, 10);
expect(store.current).toEqual([]);
});
it('is pure in-memory (no throw) when no persistence is configured', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
expect(() => tracker.register(1, 'claude-code')).not.toThrow();
});
it('reconcile does not write when the store was empty (no eager file create)', () => {
const store = memStore([]);
new ToolProcessTracker({
isAlive: () => true,
loadPersisted: store.loadPersisted,
savePersisted: store.savePersisted,
});
expect(store.saves).toHaveLength(0);
});
});
describe('observed processes are in-memory only', () => {
it('lists observed pids but never persists them', () => {
const saved: TrackedProcess[][] = [];
const tracker = new ToolProcessTracker({
savePersisted: (recs) => saved.push([...recs]),
isAlive: () => true,
now: () => new Date('2026-06-30T00:00:00Z'),
});
tracker.register(11, 'claude-code', 'ws1'); // detached
tracker.register(22, 'cursor', 'ws1', { observed: true }); // observed
// Both are live in memory:
expect(tracker.list().map((p) => p.pid).sort((a, b) => a - b)).toEqual([11, 22]);
// But the most recent persisted snapshot excludes the observed pid:
const last = saved[saved.length - 1];
expect(last.map((p) => p.pid)).toEqual([11]);
});
it('marks the record observed:true', () => {
const tracker = new ToolProcessTracker({ isAlive: () => true });
const rec = tracker.register(33, 'codex', undefined, { observed: true });
expect(rec.observed).toBe(true);
});
});