// A disconnect must fail only the work that was in flight to the sidecar that actually went away. // // This exists because it did not. `unregisterSidecar` walked the whole `pending` map and rejected every // entry, so `pm2 restart officer-music` would fail a running agent turn with `Sidecar "music" disconnected` // — a message that points at the wrong process entirely and sends anyone debugging it in the wrong // direction. Sidecars restart independently by design; that is the whole point of them being PM2 peers. import { afterEach, describe, expect, test } from 'bun:test'; import { registerSidecar, unregisterSidecar, spawnClaude, startVnc } from './sidecar-registry'; /** A socket that records what was written and never answers, so requests stay pending. */ function silentSocket() { const sent: string[] = []; return { sent, ws: { send: (data: string) => void sent.push(data) } as never }; } const registered: string[] = []; /** Register a fake sidecar and remember it, so a failed assertion can't leak it into the next test. */ function register(name: string, capabilities: string[]) { const socket = silentSocket(); const id = registerSidecar(socket.ws, { type: 'register', name, capabilities }); registered.push(id); return { ...socket, id }; } afterEach(() => { while (registered.length) unregisterSidecar(registered.pop()!); }); /** Wait until a request has actually reached the socket — spawnClaude awaits waitForCapability first. */ async function awaitSend(socket: { sent: string[] }): Promise { for (let i = 0; i < 50 && socket.sent.length === 0; i++) await Bun.sleep(1); expect(socket.sent).not.toHaveLength(0); } /** Resolves to 'pending' when the promise has not settled shortly after. */ async function settleState(promise: Promise): Promise<'pending' | 'resolved' | { rejected: string }> { const marker = Symbol('pending'); const outcome = await Promise.race([ promise.then( () => 'resolved' as const, (err: Error) => ({ rejected: err.message }), ), Bun.sleep(25).then(() => marker), ]); return outcome === marker ? 'pending' : (outcome as 'resolved' | { rejected: string }); } const CLAUDE_PARAMS = { userId: 1, email: 'owner@example.com', username: 'owner', prompt: 'hello', sessionKey: 'test-session', }; const VNC_PARAMS = { email: 'owner@example.com', username: 'owner' }; describe('unregisterSidecar', () => { test('an unrelated sidecar disconnecting leaves other sidecars’ requests in flight', async () => { const agent = register('agent', ['claude']); const vnc = register('vnc', ['vnc']); const turn = spawnClaude(CLAUDE_PARAMS); // Take ownership of the eventual rejection immediately, so it is never an unhandled one. const turnState = settleState(turn); await awaitSend(agent); unregisterSidecar(vnc.id); // The agent never disconnected, so its turn is still running. expect(await turnState).toBe('pending'); // And when the agent itself goes, its own request fails — naming the right process. unregisterSidecar(agent.id); expect(await settleState(turn)).toEqual({ rejected: 'Sidecar "agent" disconnected' }); }); test('a sidecar’s own disconnect still rejects its own request', async () => { const vnc = register('vnc', ['vnc']); const start = startVnc(VNC_PARAMS); const state = settleState(start); await awaitSend(vnc); unregisterSidecar(vnc.id); expect(await state).toEqual({ rejected: 'Sidecar "vnc" disconnected' }); }); test('re-registering under the same name only clears that name’s work', async () => { // registerSidecar unregisters an existing same-named sidecar first — a reconnect after a restart. // That path runs the same rejection loop, so it has the same blast radius if it is not scoped. register('agent', ['claude']); const vnc = register('vnc', ['vnc']); const desktop = startVnc(VNC_PARAMS); const desktopState = settleState(desktop); await awaitSend(vnc); // The agent reconnects — a different process entirely. register('agent', ['claude']); expect(await desktopState).toBe('pending'); unregisterSidecar(vnc.id); expect(await settleState(desktop)).toEqual({ rejected: 'Sidecar "vnc" disconnected' }); }); });