From a8dde34531ae68e5f68529204c598f49578d6344 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 12 Feb 2026 23:46:28 +0000 Subject: [PATCH] fix delegate timeout (#284) * pin all CLI installations to explicit versions and use pro models by default - codex: pin @openai/codex to 0.101.0 (was "latest") - opencode: pin opencode-ai to 1.1.56 (was "latest") - gemini: pin gemini-cli to v0.28.2 via new tag param on installFromGithub - cursor: pin to 2026.01.28-fd13201 via direct tarball download (replaces curl install script) - add installFromDirectTarball to install.ts for versioned tarball URLs - gemini auto effort now uses pro-preview instead of flash-preview - test runner model overrides updated to use pro-preview consistently Co-authored-by: Cursor * increase activity timeout * fix delegation timeout * fix delegate timeouts --------- Co-authored-by: Cursor --- agents/claude.ts | 1 + agents/codex.ts | 1 + agents/cursor.ts | 44 +++++++++++++++++---------- agents/gemini.ts | 1 + agents/opencode.ts | 1 + entry | 36 +++++++++++++++------- mcp/delegate.ts | 8 +++++ test/adhoc/delegateTimeout.ts | 56 +++++++++++++++++++++++++++++++++++ 8 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 test/adhoc/delegateTimeout.ts diff --git a/agents/claude.ts b/agents/claude.ts index b41e2c5..388f654 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -142,6 +142,7 @@ export const claude = agent({ activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput += chunk; + markActivity(); // reset activity timeout on any CLI output // buffer incomplete lines across chunks (NDJSON format) stdoutBuffer += chunk; diff --git a/agents/codex.ts b/agents/codex.ts index f1a1ed4..ff470ff 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -232,6 +232,7 @@ export const codex = agent({ activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput += chunk; + markActivity(); // reset activity timeout on any CLI output // buffer incomplete lines across chunks (NDJSON format) stdoutBuffer += chunk; diff --git a/agents/cursor.ts b/agents/cursor.ts index aaee874..ff9da90 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -274,6 +274,7 @@ export const cursor = agent({ let stdout = ""; let stderr = ""; + let stdoutBuffer = ""; child.on("spawn", () => { log.debug("Cursor CLI process spawned"); @@ -282,25 +283,36 @@ export const cursor = agent({ child.stdout?.on("data", async (data) => { const text = data.toString(); stdout += text; + markActivity(); // reset activity timeout on any CLI output - try { - const event = JSON.parse(text) as CursorEvent; - markActivity(); // reset activity timeout on every event - log.debug(JSON.stringify(event, null, 2)); + // buffer incomplete lines across chunks (NDJSON format) + stdoutBuffer += text; + const lines = stdoutBuffer.split("\n"); - // skip empty thinking deltas - if (event.type === "thinking" && event.subtype === "delta" && !event.text) { - return; + // keep the last element (may be incomplete) in the buffer + stdoutBuffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const event = JSON.parse(trimmed) as CursorEvent; + log.debug(JSON.stringify(event, null, 2)); + + // skip empty thinking deltas + if (event.type === "thinking" && event.subtype === "delta" && !event.text) { + continue; + } + + // route to appropriate handler + const handler = messageHandlers[event.type as keyof typeof messageHandlers]; + if (handler) { + await handler(event as never); + } + } catch { + // ignore parse errors - might be formatted tool call logs from cursor cli } - - // route to appropriate handler - const handler = messageHandlers[event.type as keyof typeof messageHandlers]; - if (handler) { - await handler(event as never); - } - } catch { - // ignore parse errors - might be formatted tool call logs from cursor cli - // our handlers log tool calls instead, so we don't need to display these } }); diff --git a/agents/gemini.ts b/agents/gemini.ts index c18a9ce..dac30f3 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -239,6 +239,7 @@ export const gemini = agent({ onStdout: async (chunk) => { const text = chunk.toString(); finalOutput += text; + markActivity(); // reset activity timeout on any CLI output // buffer incomplete lines across chunks (NDJSON format) stdoutBuffer += text; diff --git a/agents/opencode.ts b/agents/opencode.ts index c393d09..eca451c 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -123,6 +123,7 @@ export const opencode = agent({ onStdout: async (chunk) => { const text = chunk.toString(); output += text; + markActivity(); // reset activity timeout on any CLI output // buffer incomplete lines across chunks (NDJSON format) stdoutBuffer += text; diff --git a/entry b/entry index aefba59..a6b413c 100755 --- a/entry +++ b/entry @@ -141921,6 +141921,7 @@ function DelegateTool(ctx) { log.info( `\xBB delegating to ${selectedMode.name} mode (effort=${effort})${params.instructions ? " with orchestrator instructions" : ""}` ); + const keepAliveInterval = setInterval(markActivity, 3e4); try { const subagentPayload = { ...ctx.payload, effort }; const subagentInstructions = resolveSubagentInstructions({ @@ -141954,6 +141955,7 @@ function DelegateTool(ctx) { error: errorMessage }; } finally { + clearInterval(keepAliveInterval); ctx.toolState.delegationActive = false; } }) @@ -144858,6 +144860,7 @@ var claude = agent({ // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput2 += chunk; + markActivity(); stdoutBuffer += chunk; const lines = stdoutBuffer.split("\n"); stdoutBuffer = lines.pop() || ""; @@ -145140,6 +145143,7 @@ var codex = agent({ // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput2 += chunk; + markActivity(); stdoutBuffer += chunk; const lines = stdoutBuffer.split("\n"); stdoutBuffer = lines.pop() || ""; @@ -145412,24 +145416,32 @@ var cursor = agent({ }); let stdout = ""; let stderr = ""; + let stdoutBuffer = ""; child.on("spawn", () => { log.debug("Cursor CLI process spawned"); }); child.stdout?.on("data", async (data) => { const text = data.toString(); stdout += text; - try { - const event = JSON.parse(text); - markActivity(); - log.debug(JSON.stringify(event, null, 2)); - if (event.type === "thinking" && event.subtype === "delta" && !event.text) { - return; + markActivity(); + stdoutBuffer += text; + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() || ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const event = JSON.parse(trimmed); + log.debug(JSON.stringify(event, null, 2)); + if (event.type === "thinking" && event.subtype === "delta" && !event.text) { + continue; + } + const handler2 = messageHandlers5[event.type]; + if (handler2) { + await handler2(event); + } + } catch { } - const handler2 = messageHandlers5[event.type]; - if (handler2) { - await handler2(event); - } - } catch { } }); child.stderr?.on("data", (data) => { @@ -145660,6 +145672,7 @@ var gemini = agent({ onStdout: async (chunk) => { const text = chunk.toString(); finalOutput2 += text; + markActivity(); stdoutBuffer += text; const lines = stdoutBuffer.split("\n"); stdoutBuffer = lines.pop() || ""; @@ -145863,6 +145876,7 @@ var opencode = agent({ onStdout: async (chunk) => { const text = chunk.toString(); output += text; + markActivity(); stdoutBuffer += text; const lines = stdoutBuffer.split("\n"); stdoutBuffer = lines.pop() || ""; diff --git a/mcp/delegate.ts b/mcp/delegate.ts index ccce27d..b67f91b 100644 --- a/mcp/delegate.ts +++ b/mcp/delegate.ts @@ -1,6 +1,7 @@ import { type } from "arktype"; import { Effort } from "../external.ts"; import type { Mode } from "../modes.ts"; +import { markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; import { resolveSubagentInstructions } from "../utils/instructions.ts"; import type { ToolContext } from "./server.ts"; @@ -71,6 +72,12 @@ export function DelegateTool(ctx: ToolContext) { `» delegating to ${selectedMode.name} mode (effort=${effort})${params.instructions ? " with orchestrator instructions" : ""}` ); + // keep the process-level activity timeout alive while the subagent runs. + // agent CLIs can have long silent thinking phases (>60s) with no stdout, + // which would trigger the activity timeout. the overall run timeout (default 1h) + // is the real safety net for stalled agents. + const keepAliveInterval = setInterval(markActivity, 30_000); + try { // build subagent payload with effort override const subagentPayload = { ...ctx.payload, effort }; @@ -112,6 +119,7 @@ export function DelegateTool(ctx: ToolContext) { error: errorMessage, }; } finally { + clearInterval(keepAliveInterval); // always release the lock so the orchestrator can delegate again ctx.toolState.delegationActive = false; } diff --git a/test/adhoc/delegateTimeout.ts b/test/adhoc/delegateTimeout.ts new file mode 100644 index 0000000..20ee4c9 --- /dev/null +++ b/test/adhoc/delegateTimeout.ts @@ -0,0 +1,56 @@ +import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts"; +import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts"; + +/** + * delegateTimeout test - validates that the activity timeout does NOT fire + * during a delegation that takes longer than 60 seconds. + * + * uses effort: "auto" for both orchestrator and subagent so the total + * delegation time exceeds 60s. if the markActivity fix is missing, + * this test will fail with "activity timeout: no output for Xs". + */ + +const fixture = defineFixture( + { + prompt: `Delegate to the Plan mode with auto effort. Pass these instructions to the subagent: +"Carefully analyze the following engineering question and provide a thorough response, then call set_output with the value 'DELEGATE_TIMEOUT_PASSED'. + +Question: Design a comprehensive error handling strategy for a distributed microservices architecture. Consider: +1. Circuit breaker patterns — when to open, half-open, close. What thresholds to use. +2. Retry policies — exponential backoff with jitter. Maximum retry counts. Which errors are retryable. +3. Dead letter queues — when to use them, how to process failed messages, alerting. +4. Health check endpoints — liveness vs readiness probes, dependency health checks. +5. Graceful degradation — fallback responses, feature flags, bulkhead pattern. + +Provide a detailed analysis covering ALL 5 points with concrete examples before calling set_output."`, + effort: "auto", + timeout: "8m", + }, + { localOnly: true } +); + +function validator(result: AgentResult): ValidationCheck[] { + const output = getStructuredOutput(result); + const agentOutput = getAgentOutput(result); + + const setOutputCalled = output !== null; + const correctValue = setOutputCalled && /DELEGATE_TIMEOUT_PASSED/i.test(output); + const delegationOccurred = /» delegating to \w+ mode/i.test(agentOutput); + // the critical check: no activity timeout occurred + const noActivityTimeout = !/activity timeout/i.test(agentOutput); + + return [ + { name: "set_output", passed: setOutputCalled }, + { name: "correct_value", passed: correctValue }, + { name: "delegation_occurred", passed: delegationOccurred }, + { name: "no_activity_timeout", passed: noActivityTimeout }, + ]; +} + +export const test: TestRunnerOptions = { + name: "delegate-timeout", + fixture, + validator, + env: { GITHUB_REPOSITORY: "pullfrog/test-repo" }, + tags: ["adhoc"], +};