run the agent as a pm2 peer instead of a child of officer
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry and was spawned on demand by the main server, with stdout/stderr inherited. that made every agent session a grandchild of officer, so pm2's tree-kill took the session down on every `pm2 restart officer` — the single thing that makes it impossible to work on the platform while an agent is running. give it its own entry (officer-agent) and delete the spawn machinery: ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns no sidecar at all. for that to work the sidecar had to stop needing officer to start: - it resolves the owner from the database (getOwnerUser) instead of reading CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins when set, for manual runs, and a fresh install waits for bootstrap rather than exiting into a restart loop. - it reads the anthropic proxy secret from the proxy sidecar's own state file rather than being handed it in env. lazily, because ensureProxySecret persists on a 30s debounce and pm2 starts both processes together. it registers as 'agent' with capability 'claude', so the registry finds it the way it finds every other sidecar. that removes the email argument from killClaude, interruptClaude and clearClaudeSession, which only ever existed to locate a per-email sidecar by name. what officer keeps is a short wait-for-capability, because pm2 brings peers up together and the first request after a boot can beat the sidecar's registration. also align the two officer port fallbacks in the sidecar (5000 for the socket, 9010 for the rest base) — same instance, so they cannot disagree. this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while officer is down are still lost; that is stage 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
-101
@@ -1,6 +1,4 @@
|
||||
import { resolve } from 'node:path';
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type {
|
||||
SidecarCommand,
|
||||
SidecarEvent,
|
||||
@@ -113,13 +111,6 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findSidecarByName(name: string): RegisteredSidecar | undefined {
|
||||
for (const sc of sidecars.values()) {
|
||||
if (sc.name === name) return sc;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Event dispatch ──
|
||||
|
||||
function dispatchEvent(msg: SidecarEvent | PtyEvent) {
|
||||
@@ -195,82 +186,27 @@ function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand | PtyComma
|
||||
sc.ws.send(JSON.stringify(cmd));
|
||||
}
|
||||
|
||||
// ── On-demand Claude sidecar spawning ──
|
||||
// ── Waiting for a sidecar to appear ──
|
||||
|
||||
const USER_INSTANCE_SCRIPT = resolve(import.meta.dir, 'sidecar/claude/user-instance.ts');
|
||||
const SIDECAR_SPAWN_TIMEOUT_MS = 15_000;
|
||||
// Officer no longer spawns any sidecar; PM2 owns every one of them. The only thing left to handle is
|
||||
// startup order — PM2 brings `officer` and its peers up together, so the first request after a boot can
|
||||
// arrive a beat before the sidecar has finished dialling in. Wait briefly rather than failing the
|
||||
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
|
||||
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
|
||||
const CAPABILITY_WAIT_MS = 15_000;
|
||||
const CAPABILITY_POLL_MS = 100;
|
||||
|
||||
const claudeProcs = new Map<string, Subprocess>();
|
||||
const claudeSpawnWaiters = new Map<string, Promise<RegisteredSidecar>>();
|
||||
|
||||
async function ensureClaudeSidecar(email: string): Promise<RegisteredSidecar> {
|
||||
const name = `claude:${email}`;
|
||||
|
||||
// Already registered?
|
||||
const existing = findSidecarByName(name);
|
||||
async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
|
||||
const existing = findSidecarByCapability(cap);
|
||||
if (existing) return existing;
|
||||
|
||||
// Already spawning?
|
||||
const waiter = claudeSpawnWaiters.get(email);
|
||||
if (waiter) return waiter;
|
||||
|
||||
// Spawn and wait for registration
|
||||
const promise = spawnAndWaitForRegistration(email, name);
|
||||
claudeSpawnWaiters.set(email, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
claudeSpawnWaiters.delete(email);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await Bun.sleep(CAPABILITY_POLL_MS);
|
||||
const sc = findSidecarByCapability(cap);
|
||||
if (sc) return sc;
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndWaitForRegistration(email: string, name: string): Promise<RegisteredSidecar> {
|
||||
const proxySecret = await getProxySecret();
|
||||
const proxyPort = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
CLAUDE_USER_EMAIL: email,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}`,
|
||||
ANTHROPIC_API_KEY: proxySecret,
|
||||
};
|
||||
|
||||
const proc = Bun.spawn(['bun', 'run', USER_INSTANCE_SCRIPT], {
|
||||
env,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
claudeProcs.set(email, proc);
|
||||
|
||||
// Clean up on exit
|
||||
proc.exited.then(() => {
|
||||
claudeProcs.delete(email);
|
||||
});
|
||||
|
||||
// Wait for the sidecar to register
|
||||
return new Promise<RegisteredSidecar>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsub();
|
||||
reject(new Error(`Claude sidecar for ${email} failed to register within ${SIDECAR_SPAWN_TIMEOUT_MS}ms`));
|
||||
}, SIDECAR_SPAWN_TIMEOUT_MS);
|
||||
|
||||
// Poll for registration (the sidecar connects via WebSocket and registerSidecar is called)
|
||||
const check = () => {
|
||||
const sc = findSidecarByName(name);
|
||||
if (sc) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
resolve(sc);
|
||||
}
|
||||
};
|
||||
const interval = setInterval(check, 50);
|
||||
|
||||
const unsub = () => {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
throw new Error(`No sidecar with capability "${cap}" registered within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
@@ -303,10 +239,14 @@ export function getProxySecretSync(): string {
|
||||
return cachedState?.proxySecret ?? '';
|
||||
}
|
||||
|
||||
// ── Claude Code (per-user routing) ──
|
||||
// ── Claude Code (the `officer-agent` sidecar, capability 'claude') ──
|
||||
|
||||
// Single-user platform, so there is exactly one agent sidecar and it is found by capability like every
|
||||
// other one. The `email` on the params is still passed through to the sidecar — it needs it to resolve
|
||||
// paths — but officer no longer uses it to *locate* anything.
|
||||
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
const sc = await ensureClaudeSidecar(params.email);
|
||||
const sc = await waitForCapability('claude');
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
||||
if (res.type === 'claude:result') return res.result;
|
||||
if (res.type === 'claude:error') throw new Error(res.error);
|
||||
@@ -314,36 +254,24 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
}
|
||||
|
||||
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
||||
const sc = await ensureClaudeSidecar(params.email);
|
||||
const sc = await waitForCapability('claude');
|
||||
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
|
||||
if (res.type === 'claude:spawned') return;
|
||||
if (res.type === 'claude:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export function killClaude(sessionKey: string, email: string): void {
|
||||
const sc = findSidecarByName(`claude:${email}`);
|
||||
if (sc) sendFireToSidecar(sc, { type: 'claude:kill', id: nextId(), sessionKey });
|
||||
export function killClaude(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
||||
export function interruptClaude(sessionKey: string, email: string): void {
|
||||
const sc = findSidecarByName(`claude:${email}`);
|
||||
if (sc) sendFireToSidecar(sc, { type: 'claude:interrupt', id: nextId(), sessionKey });
|
||||
export function interruptClaude(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
export function clearClaudeSession(sessionKey: string, email?: string): void {
|
||||
if (email) {
|
||||
const sc = findSidecarByName(`claude:${email}`);
|
||||
if (sc) sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
} else {
|
||||
// Broadcast to all claude sidecars (used when email is not available)
|
||||
for (const sc of sidecars.values()) {
|
||||
if (sc.capabilities.includes('claude')) {
|
||||
sendFireToSidecar(sc, { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
export function clearClaudeSession(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
export function onClaudeEvent(handler: (sessionKey: string, event: ChatEvent) => void): () => void {
|
||||
|
||||
Reference in New Issue
Block a user