a command dropped for want of a sidecar has to say so
`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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<WSData>, 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<WSData>): Promise<void> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user