From 5c802b07b59a9058929b8731e211786199dd5099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 00:06:30 +0000 Subject: [PATCH] a command dropped for want of a sidecar has to say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sendFire` looked up a sidecar, and if none was registered it did nothing. No log, no error, no return value — the command simply stopped existing. That window is real and entirely predictable: every `pm2 restart officer` has one while the sidecars reconnect, and somebody clicking a button has no idea they are in it. The cost landed hardest on answering a question. `answerClaudeQuestion` in the agent documents this exact failure in a careful comment and cannot report it, because the command never arrives to be reported. So the question card kept spinning, which from the person's side is indistinguishable from a slow agent, and the only move left is to send another message — the worst possible thing to do to a waiting tool call. Observed tonight, on a restart I ran myself while a commander was mid-question. So `sendFire` returns whether anyone took the command and warns when nobody did, and the two paths where a person is actively waiting — a question answer and a plan decision — tell them instead of leaving the card to spin. The other five callers are unchanged and now leave a log line, which is the part that was missing when this was diagnosed. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/websocket.ts | 28 ++++++++++++++++++++++++++-- src/servers/sidecar-registry.ts | 31 ++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index c5dfde9b..f23b7424 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -1396,13 +1396,20 @@ function handlePlanDecision( ): void { const sessionId = wsToSessionMap.get(ws); if (!sessionId) return; - sidecar.decideClaudePlan({ + const delivered = sidecar.decideClaudePlan({ sessionKey: sessionId, userId: ws.data.userId, requestId: msg.requestId, approve: msg.approve, feedback: msg.feedback, }); + // Same drop as `handleQuestionAnswer`, same spinning card — see `sendFire`. + if (!delivered) { + sendToClient(ws, { + type: 'error', + message: 'The agent is not connected right now, so that decision did not reach it. Try again in a moment.', + }); + } } /** @@ -1416,12 +1423,29 @@ function handlePlanDecision( function handleQuestionAnswer(ws: ServerWebSocket, msg: { toolCallId: string; answer: string }): void { const sessionId = wsToSessionMap.get(ws); if (!sessionId) return; - sidecar.answerClaudeQuestion({ + /* + A dropped answer has to reach the person who sent it. + + `sendFire` does nothing when no sidecar is registered, and that window is real — every restart of this + process has one while the agent reconnects. The answer vanished with no log and no error, so the + question card spun on, which is indistinguishable from a slow agent. The only move left was to send + another message, which is the worst possible thing to do to a waiting tool call. + + The agent's own handler already reports the failures it CAN see (no such session, question no longer + waiting). This is the one it cannot, because the command never arrives. + */ + const delivered = sidecar.answerClaudeQuestion({ sessionKey: sessionId, userId: ws.data.userId, toolCallId: msg.toolCallId, answer: msg.answer, }); + if (!delivered) { + sendToClient(ws, { + type: 'error', + message: 'The agent is not connected right now, so that answer did not reach it. Try again in a moment.', + }); + } } async function handleStop(ws: ServerWebSocket): Promise { diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index 05833590..81cc3d04 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -224,11 +224,28 @@ function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIME }); } -function sendFire(kind: string, cmd: SidecarCommand): void { +/** + * Fire a command at whichever sidecar handles this kind. Returns whether one was there to take it. + * + * It used to return void and drop the command when no sidecar was registered — no log, no error, nothing. + * That window is real and predictable: every `pm2 restart officer` has one while the sidecars reconnect, and + * a person clicking a button has no idea they are in it. + * + * The cost landed on `claude:question-answer`, whose own handler documents this exact failure and cannot + * report it, because the message never arrives to be reported. From the person's side the question card just + * kept spinning — indistinguishable from a slow agent, so the only move left was to send another message. + * + * Callers that can tell somebody are now able to. The boolean is the whole mechanism; the warning is for + * whoever reads the log afterwards wondering why one click did nothing. + */ +function sendFire(kind: string, cmd: SidecarCommand): boolean { const sc = findSidecarHandling(kind); - if (sc) { - sc.ws.send(JSON.stringify(cmd)); + if (!sc) { + console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`); + return false; } + sc.ws.send(JSON.stringify(cmd)); + return true; } function sendCommandToSidecar( @@ -368,8 +385,8 @@ export function decideClaudePlan(params: { requestId: string; approve: boolean; feedback?: string; -}): void { - sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params }); +}): boolean { + return sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params }); } // The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the @@ -380,8 +397,8 @@ export function answerClaudeQuestion(params: { userId: number; toolCallId: string; answer: string; -}): void { - sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params }); +}): boolean { + return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params }); } /**