44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
// 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, handles: string[]) {
|
||
const socket = silentSocket();
|
||
const id = registerSidecar(socket.ws, { type: 'register', name, handles });
|
||
registered.push(id);
|
||
return { ...socket, id };
|
||
}
|
||
|
||
afterEach(() => {
|
||
while (registered.length) unregisterSidecar(registered.pop()!);
|
||
});
|
||
|
||
/** Wait until a request has actually reached the socket — spawnClaude awaits waitForPermission 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 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' });
|
||
});
|
||
});
|