Files
platform/src/servers/sidecar-registry.test.ts
T
pastilhasandClaude Opus 5 4eeaa3c93d fail only the disconnected sidecar's in-flight requests
unregisterSidecar rejected every entry in the pending map, not just the ones
belonging to the sidecar that went away. Restarting any single sidecar failed
in-flight work on every other: `pm2 restart officer-music` could kill a running
agent turn with `Sidecar "music" disconnected` — a message pointing at a process
that had nothing to do with it. Pending entries now carry their owning sidecar
id and the rejection loop skips the rest.

Also deletes src/servers/api/anthropic-proxy.ts. It had no importers; PM2's
officer-anthropic-proxy runs src/servers/sidecar/claude/index.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:33:29 +00:00

110 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<void> {
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<unknown>): 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 sidecars 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 names 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' });
});
});