This commit is contained in:
310
packages/agent/tests/tool-process-tracker.test.ts
Normal file
310
packages/agent/tests/tool-process-tracker.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* AI-OS Phase 4 polish — process tracker tests.
|
||||
*
|
||||
* Hermetic — no real processes involved. Liveness is injected.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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('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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user