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 {
|
): void {
|
||||||
const sessionId = wsToSessionMap.get(ws);
|
const sessionId = wsToSessionMap.get(ws);
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
sidecar.decideClaudePlan({
|
const delivered = sidecar.decideClaudePlan({
|
||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
userId: ws.data.userId,
|
userId: ws.data.userId,
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
approve: msg.approve,
|
approve: msg.approve,
|
||||||
feedback: msg.feedback,
|
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 {
|
function handleQuestionAnswer(ws: ServerWebSocket<WSData>, msg: { toolCallId: string; answer: string }): void {
|
||||||
const sessionId = wsToSessionMap.get(ws);
|
const sessionId = wsToSessionMap.get(ws);
|
||||||
if (!sessionId) return;
|
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,
|
sessionKey: sessionId,
|
||||||
userId: ws.data.userId,
|
userId: ws.data.userId,
|
||||||
toolCallId: msg.toolCallId,
|
toolCallId: msg.toolCallId,
|
||||||
answer: msg.answer,
|
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> {
|
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);
|
const sc = findSidecarHandling(kind);
|
||||||
if (sc) {
|
if (!sc) {
|
||||||
sc.ws.send(JSON.stringify(cmd));
|
console.warn(`[sidecar] dropped ${cmd.type}: no sidecar registered for "${kind}"`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
sc.ws.send(JSON.stringify(cmd));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendCommandToSidecar(
|
function sendCommandToSidecar(
|
||||||
@@ -368,8 +385,8 @@ export function decideClaudePlan(params: {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
approve: boolean;
|
approve: boolean;
|
||||||
feedback?: string;
|
feedback?: string;
|
||||||
}): void {
|
}): boolean {
|
||||||
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
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
|
// 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;
|
userId: number;
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
}): void {
|
}): boolean {
|
||||||
sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
return sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user