fix: scope held Claude processes and honor MCP cancellation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export type LiveProcess = { readonly owner: symbol; toolCallIds: string[]; kill(): void };
|
||||
export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void };
|
||||
|
||||
// Tools usually call back within seconds, but a question tool holds its call until the learner
|
||||
// answers. Past this, the process is killed and the answer starts a fresh one with a full replay.
|
||||
@@ -27,11 +27,11 @@ function hasSameIds(ids: string[], otherIds: string[]): boolean {
|
||||
return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id));
|
||||
}
|
||||
|
||||
export function getLiveProcess(toolCallIds: string[], owner: symbol): LiveProcess | null {
|
||||
if (toolCallIds.length === 0) return null;
|
||||
export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null {
|
||||
if (!sessionId || toolCallIds.length === 0) return null;
|
||||
|
||||
for (const process of liveProcesses.keys()) {
|
||||
if (process.owner === owner && hasSameIds(process.toolCallIds, toolCallIds)) return process;
|
||||
if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -392,6 +392,7 @@ class ClaudeProcess implements LiveProcess {
|
||||
private sourceCalls: ToolCall[] = [];
|
||||
private sourceComplete = false;
|
||||
private admitted: PendingToolCall[] = [];
|
||||
private readonly cancelledIds = new Set<string>();
|
||||
private pendingError: string | undefined;
|
||||
|
||||
constructor(
|
||||
@@ -400,6 +401,7 @@ class ClaudeProcess implements LiveProcess {
|
||||
private readonly files: TurnFiles,
|
||||
private turn: ClaudeTurn,
|
||||
readonly owner: symbol,
|
||||
readonly sessionId: string | undefined,
|
||||
private readonly configuration: unknown,
|
||||
private conversation: unknown[],
|
||||
) {}
|
||||
@@ -422,9 +424,9 @@ class ClaudeProcess implements LiveProcess {
|
||||
this.setSignal(signal);
|
||||
}
|
||||
|
||||
canContinue(owner: symbol, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean {
|
||||
canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean {
|
||||
const conversation = conversationForReuse(messages);
|
||||
return !this.stopping && owner === this.owner
|
||||
return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId
|
||||
&& isDeepStrictEqual(configuration, this.configuration)
|
||||
&& isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation)
|
||||
&& conversation.length === this.conversation.length + this.admitted.length
|
||||
@@ -462,7 +464,7 @@ class ClaudeProcess implements LiveProcess {
|
||||
// A parallel request can fail after the admitted slice was handed to
|
||||
// Pi. Retain that failure for its continuation instead of silently
|
||||
// replaying and hiding the compatibility error.
|
||||
if (this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message;
|
||||
if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message;
|
||||
this.turn.fail(message, "error");
|
||||
this.kill();
|
||||
if (this.pendingError) addLiveProcess(this);
|
||||
@@ -477,6 +479,13 @@ class ClaudeProcess implements LiveProcess {
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelCall(call: PendingToolCall): void {
|
||||
this.cancelledIds.add(call.id);
|
||||
// Already admitted work belongs to Pi and cannot be undone. A queued
|
||||
// request, however, must never become executable after cancellation.
|
||||
this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id);
|
||||
}
|
||||
|
||||
/** Only authenticated MCP requests, never stdout intent alone, admit side effects. */
|
||||
flushAdmissions(): void {
|
||||
if (this.stopping || this.turn.finished || !this.sourceComplete) return;
|
||||
@@ -496,7 +505,8 @@ class ClaudeProcess implements LiveProcess {
|
||||
this.toolCallIds = pending.map((call) => call.id);
|
||||
this.turn.admit(calls);
|
||||
this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()])));
|
||||
addLiveProcess(this);
|
||||
if (this.sessionId) addLiveProcess(this);
|
||||
else this.kill(); // No trusted scope: return the batch, then use fresh replay.
|
||||
}
|
||||
|
||||
private readonly abort = () => {
|
||||
@@ -534,11 +544,12 @@ class ClaudeProcess implements LiveProcess {
|
||||
this.turn.handle(event);
|
||||
if (this.turn.finished) { this.kill(); return; }
|
||||
if (event.type === "messageStop" && this.turn.waitingForAdmission) {
|
||||
this.sourceCalls = this.turn.getIntents();
|
||||
if (this.sourceCalls.length === 0 || new Set(this.sourceCalls.map((call) => call.id)).size !== this.sourceCalls.length) {
|
||||
const intents = this.turn.getIntents();
|
||||
if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) {
|
||||
this.fail("claude CLI emitted an invalid tool batch");
|
||||
return;
|
||||
}
|
||||
this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id));
|
||||
this.sourceComplete = true;
|
||||
this.flushAdmissions();
|
||||
}
|
||||
@@ -559,17 +570,17 @@ class ClaudeProcess implements LiveProcess {
|
||||
}
|
||||
}
|
||||
|
||||
function continueLiveProcess(turn: ClaudeTurn, owner: symbol, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean {
|
||||
function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean {
|
||||
const assistantIndex = messages.findLastIndex((message) => message.role === "assistant");
|
||||
const assistant = messages[assistantIndex];
|
||||
if (assistant?.role !== "assistant") return false;
|
||||
const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []);
|
||||
const process = getLiveProcess(ids, owner);
|
||||
const process = getLiveProcess(ids, owner, sessionId);
|
||||
if (!(process instanceof ClaudeProcess)) return false;
|
||||
if (process.consumeFailure(owner, turn)) return true;
|
||||
const trailing = messages.slice(assistantIndex + 1);
|
||||
const results = trailing.filter((message) => message.role === "toolResult");
|
||||
if (trailing.length !== results.length || !process.canContinue(owner, configuration, messages, results)) {
|
||||
if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) {
|
||||
process.kill();
|
||||
return false;
|
||||
}
|
||||
@@ -604,6 +615,7 @@ async function startProcess(
|
||||
const bridge = await createToolBridge(input.tools, {
|
||||
onCall: () => cliProcess?.flushAdmissions(),
|
||||
onError: (error) => cliProcess?.fail(error.message),
|
||||
onCancel: (call) => cliProcess?.cancelCall(call),
|
||||
});
|
||||
let files: TurnFiles;
|
||||
try {
|
||||
@@ -639,7 +651,7 @@ async function startProcess(
|
||||
return;
|
||||
}
|
||||
|
||||
cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, configuration, structuredClone(conversationForReuse(context.messages)));
|
||||
cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages)));
|
||||
cliProcess.start(toStreamJsonLine(input.userContent), options?.signal);
|
||||
}
|
||||
|
||||
@@ -650,7 +662,7 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc
|
||||
const turn = new ClaudeTurn(stream, model);
|
||||
|
||||
const configuration = configurationForReuse(model, context, toEffort(options?.reasoning));
|
||||
if (continueLiveProcess(turn, owner, configuration, context.messages, options?.signal)) return stream;
|
||||
if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream;
|
||||
|
||||
startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => {
|
||||
console.error("claude CLI turn failed:", error);
|
||||
|
||||
@@ -22,7 +22,7 @@ const TOKEN_BYTES = 32;
|
||||
|
||||
export type PendingToolCall = { id: string; name: string; arguments: JsonObject };
|
||||
|
||||
type BridgeCallbacks = { onCall(): void; onError(error: Error): void };
|
||||
type BridgeCallbacks = { onCall(): void; onError(error: Error): void; onCancel?(call: PendingToolCall): void };
|
||||
|
||||
export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId";
|
||||
|
||||
@@ -67,8 +67,22 @@ class ToolCallQueue {
|
||||
private readonly results = new Map<PendingToolCall, (result: CallToolResult) => void>();
|
||||
private waiters: ToolCallWaiter[] = [];
|
||||
|
||||
add(call: PendingToolCall): Promise<CallToolResult> {
|
||||
const result = new Promise<CallToolResult>((resolve) => this.results.set(call, resolve));
|
||||
add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise<CallToolResult> {
|
||||
const result = new Promise<CallToolResult>((resolve) => {
|
||||
const cancel = () => {
|
||||
const settle = this.results.get(call);
|
||||
if (!settle) return;
|
||||
settle({ content: [{ type: "text", text: "MCP request cancelled" }], isError: true });
|
||||
onCancel?.(call);
|
||||
};
|
||||
this.results.set(call, (result) => {
|
||||
this.results.delete(call);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
resolve(result);
|
||||
});
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
if (signal.aborted) cancel();
|
||||
});
|
||||
|
||||
this.wakeWaiters();
|
||||
|
||||
@@ -84,7 +98,6 @@ class ToolCallQueue {
|
||||
|
||||
resolve(call: PendingToolCall, result: ToolResultMessage): void {
|
||||
this.results.get(call)?.(toCallToolResult(result));
|
||||
this.results.delete(call);
|
||||
}
|
||||
|
||||
list(): PendingToolCall[] {
|
||||
@@ -92,7 +105,9 @@ class ToolCallQueue {
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.results.clear();
|
||||
for (const settle of this.results.values()) {
|
||||
settle({ content: [{ type: "text", text: "MCP bridge closed" }], isError: true });
|
||||
}
|
||||
|
||||
for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed"));
|
||||
this.waiters = [];
|
||||
@@ -116,7 +131,7 @@ function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: Bridge
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) }));
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
server.setRequestHandler(CallToolRequestSchema, (request) => {
|
||||
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
|
||||
const id = request.params._meta?.["claudecode/toolUseId"];
|
||||
if (typeof id !== "string" || !id.trim() || seenIds.has(id)) {
|
||||
const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR);
|
||||
@@ -129,7 +144,7 @@ function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: Bridge
|
||||
throw error;
|
||||
}
|
||||
seenIds.add(id);
|
||||
const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject });
|
||||
const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }, extra.signal, callbacks?.onCancel);
|
||||
callbacks?.onCall();
|
||||
return result;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user