Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ metadata and the backend fallback mirror it.

### Fixed

- Honor queued agent completion deadlines while another CLI call is still running — thanks @rudycelekli! (#2658)

- MCP speech tools wait through model loading and progress-extended CPU renders instead of timing out before the backend (#2609)

## [0.5.7] — 2026-10-05
Expand Down
2 changes: 2 additions & 0 deletions docs/electron-repair.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,5 @@ Automatic renderer-crash repair uses the source workspace. Without an attached
checkout, it opens the source-folder controls and preserves the request; choose
a checkout and press Send to continue. Explicit app action requests continue to
use the app workspace without a checkout.

Shared CLI completion requests retain their own HTTP deadline while waiting for another agent call. An expired queued request returns HTTP 504 without starting a second CLI runner or waiting for the first call to finish. The same timeout classification applies when the queue reaches a request with less than one second remaining to start.
84 changes: 84 additions & 0 deletions electron/src/main/llm-agent-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { channel } from 'node:diagnostics_channel';
import type { IncomingMessage } from 'node:http';
import {
startLlmAgentBridge,
validateAgentCompletion,
Expand Down Expand Up @@ -96,3 +98,85 @@ it('reports a shared agent runner busy state as retryable', async () => {
bridge.close();
}
});


it('expires a queued completion at its own deadline while an earlier call is still running', async () => {
let releaseFirst!: () => void;
let enteredFirst!: () => void;
const entered = new Promise<void>((resolve) => { enteredFirst = resolve; });
const waiting = new Promise<void>((resolve) => { releaseFirst = resolve; });
const complete = vi.fn(async () => { enteredFirst(); await waiting; return 'ok'; });
const bridge = await startLlmAgentBridge(complete);
const call = (timeoutMs: number) => fetch(bridge.url + '/complete', {
method: 'POST', headers: { Authorization: 'Bearer ' + bridge.token },
body: JSON.stringify({ ...request, timeoutMs }),
});
const first = call(5000);
let second: Promise<Response> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await entered;
second = call(1000);
const response = await Promise.race([
second,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('queued request ignored its deadline')), 1800);
}),
]);
expect(response.status).toBe(504);
expect(complete).toHaveBeenCalledOnce();
} finally {
clearTimeout(timer);
releaseFirst();
await first;
await second;
bridge.close();
}
});

it('classifies a queued completion with too little time to start as a timeout', async () => {
let releaseFirst!: () => void;
let enteredFirst!: () => void;
const entered = new Promise<void>((resolve) => { enteredFirst = resolve; });
const waiting = new Promise<void>((resolve) => { releaseFirst = resolve; });
const complete = vi.fn(async () => { enteredFirst(); await waiting; return 'ok'; });
const bridge = await startLlmAgentBridge(complete);
const call = (timeoutMs: number) => fetch(bridge.url + '/complete', {
method: 'POST', headers: { Authorization: 'Bearer ' + bridge.token },
body: JSON.stringify({ ...request, timeoutMs }),
});
const first = call(5000);
let second: Promise<Response> | undefined;
let receivedSecond!: () => void;
const received = new Promise<void>((resolve) => { receivedSecond = resolve; });
const incoming = channel('http.server.request.start');
const observe = (message: unknown) => {
const { request } = message as { request: IncomingMessage };
if (request.headers.authorization === 'Bearer ' + bridge.token) {
request.once('end', receivedSecond);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await entered;
incoming.subscribe(observe);
second = call(1000);
await Promise.race([received, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Queued request did not reach the native HTTP server')), 2000);
})]);
clearTimeout(timer);
// The real HTTP server received the entire second body while the first
// runner is held. Release it with less than one second left to start.
await new Promise((resolve) => setTimeout(resolve, 150));
releaseFirst();
expect((await first).status).toBe(200);
expect((await second).status).toBe(504);
expect(complete).toHaveBeenCalledOnce();
} finally {
clearTimeout(timer);
incoming.unsubscribe(observe);
releaseFirst();
bridge.close();
await Promise.allSettled([first, ...(second ? [second] : [])]);
}
});
24 changes: 21 additions & 3 deletions electron/src/main/llm-agent-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ export async function startLlmAgentBridge(
queued += 1;
const task = tail.then(async () => {
const remaining = deadline - Date.now();
if (closed || res.destroyed || remaining < 1000) throw new Error('Agent request expired');
if (closed || res.destroyed) throw new Error('Agent request expired');
if (remaining < 1000) throw Object.assign(new Error('Agent request expired'), {
name: 'AgentTimeoutError',
});
return complete({ ...body, timeoutMs: remaining });
});
tail = task
Expand All @@ -97,8 +100,22 @@ export async function startLlmAgentBridge(
.finally(() => {
queued -= 1;
});
const text = await task;
send(200, { text });
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
// A queued request must expire independently of the running CLI call.
// Keep `tail` attached to the real task so expiry never overlaps runners.
const text = await Promise.race([
task,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(Object.assign(new Error('Agent request expired'), {
name: 'AgentTimeoutError',
})), Math.max(0, deadline - Date.now()));
}),
]);
send(200, { text });
} finally {
clearTimeout(timeout);
}
} catch (error) {
// Never expose subprocess output, login tokens, or source dialogue in errors.
const status =
Expand All @@ -108,6 +125,7 @@ export async function startLlmAgentBridge(
AgentAuthenticationError: 401,
AgentRateLimitError: 429,
AgentModelError: 404,
AgentTimeoutError: 504,
Comment thread
rudycelekli marked this conversation as resolved.
} as Record<string, number>
)[error.name]
: undefined;
Expand Down
Loading