moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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);
});
});