From 0d306c267543f9e0e415748a1a2c2d5b5102c15e Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:38:12 -0400 Subject: [PATCH 1/2] fix(agents): honor deadlines for queued completions Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- CHANGELOG.md | 2 ++ docs/electron-repair.md | 2 ++ electron/src/main/llm-agent-bridge.test.ts | 35 ++++++++++++++++++++++ electron/src/main/llm-agent-bridge.ts | 19 ++++++++++-- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534142f5a..25b27adf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/electron-repair.md b/docs/electron-repair.md index 74eae5bcd..a9c160481 100644 --- a/docs/electron-repair.md +++ b/docs/electron-repair.md @@ -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 a timeout without starting a second CLI runner or waiting for the first call to finish. diff --git a/electron/src/main/llm-agent-bridge.test.ts b/electron/src/main/llm-agent-bridge.test.ts index 03be7abaa..e14c52685 100644 --- a/electron/src/main/llm-agent-bridge.test.ts +++ b/electron/src/main/llm-agent-bridge.test.ts @@ -96,3 +96,38 @@ 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((resolve) => { enteredFirst = resolve; }); + const waiting = new Promise((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 | undefined; + let timer: ReturnType | undefined; + try { + await entered; + second = call(1000); + const response = await Promise.race([ + second, + new Promise((_, 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(); + } +}); diff --git a/electron/src/main/llm-agent-bridge.ts b/electron/src/main/llm-agent-bridge.ts index 9a284a186..3de16542e 100644 --- a/electron/src/main/llm-agent-bridge.ts +++ b/electron/src/main/llm-agent-bridge.ts @@ -97,8 +97,22 @@ export async function startLlmAgentBridge( .finally(() => { queued -= 1; }); - const text = await task; - send(200, { text }); + let timeout: ReturnType | 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((_, 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 = @@ -108,6 +122,7 @@ export async function startLlmAgentBridge( AgentAuthenticationError: 401, AgentRateLimitError: 429, AgentModelError: 404, + AgentTimeoutError: 504, } as Record )[error.name] : undefined; From d303a818a76ac922c12cdcc3e566e40098ae8bf6 Mon Sep 17 00:00:00 2001 From: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:19:37 -0400 Subject: [PATCH 2/2] fix(agents): classify queued start expiry as a timeout Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- docs/electron-repair.md | 2 +- electron/src/main/llm-agent-bridge.test.ts | 49 ++++++++++++++++++++++ electron/src/main/llm-agent-bridge.ts | 5 ++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/electron-repair.md b/docs/electron-repair.md index a9c160481..854b644b1 100644 --- a/docs/electron-repair.md +++ b/docs/electron-repair.md @@ -126,4 +126,4 @@ 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 a timeout without starting a second CLI runner or waiting for the first call to finish. +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. diff --git a/electron/src/main/llm-agent-bridge.test.ts b/electron/src/main/llm-agent-bridge.test.ts index e14c52685..abe710139 100644 --- a/electron/src/main/llm-agent-bridge.test.ts +++ b/electron/src/main/llm-agent-bridge.test.ts @@ -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, @@ -131,3 +133,50 @@ it('expires a queued completion at its own deadline while an earlier call is sti 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((resolve) => { enteredFirst = resolve; }); + const waiting = new Promise((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 | undefined; + let receivedSecond!: () => void; + const received = new Promise((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 | undefined; + try { + await entered; + incoming.subscribe(observe); + second = call(1000); + await Promise.race([received, new Promise((_, 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] : [])]); + } +}); diff --git a/electron/src/main/llm-agent-bridge.ts b/electron/src/main/llm-agent-bridge.ts index 3de16542e..f99618eb5 100644 --- a/electron/src/main/llm-agent-bridge.ts +++ b/electron/src/main/llm-agent-bridge.ts @@ -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