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 <cursoragent@cursor.com> * increase activity timeout * fix delegation timeout * fix delegate timeouts --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
ceadb3120a
commit
a8dde34531
@@ -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)
|
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
finalOutput += chunk;
|
finalOutput += chunk;
|
||||||
|
markActivity(); // reset activity timeout on any CLI output
|
||||||
|
|
||||||
// buffer incomplete lines across chunks (NDJSON format)
|
// buffer incomplete lines across chunks (NDJSON format)
|
||||||
stdoutBuffer += chunk;
|
stdoutBuffer += chunk;
|
||||||
|
|||||||
@@ -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)
|
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
finalOutput += chunk;
|
finalOutput += chunk;
|
||||||
|
markActivity(); // reset activity timeout on any CLI output
|
||||||
|
|
||||||
// buffer incomplete lines across chunks (NDJSON format)
|
// buffer incomplete lines across chunks (NDJSON format)
|
||||||
stdoutBuffer += chunk;
|
stdoutBuffer += chunk;
|
||||||
|
|||||||
+28
-16
@@ -274,6 +274,7 @@ export const cursor = agent({
|
|||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
let stdoutBuffer = "";
|
||||||
|
|
||||||
child.on("spawn", () => {
|
child.on("spawn", () => {
|
||||||
log.debug("Cursor CLI process spawned");
|
log.debug("Cursor CLI process spawned");
|
||||||
@@ -282,25 +283,36 @@ export const cursor = agent({
|
|||||||
child.stdout?.on("data", async (data) => {
|
child.stdout?.on("data", async (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
stdout += text;
|
stdout += text;
|
||||||
|
markActivity(); // reset activity timeout on any CLI output
|
||||||
|
|
||||||
try {
|
// buffer incomplete lines across chunks (NDJSON format)
|
||||||
const event = JSON.parse(text) as CursorEvent;
|
stdoutBuffer += text;
|
||||||
markActivity(); // reset activity timeout on every event
|
const lines = stdoutBuffer.split("\n");
|
||||||
log.debug(JSON.stringify(event, null, 2));
|
|
||||||
|
|
||||||
// skip empty thinking deltas
|
// keep the last element (may be incomplete) in the buffer
|
||||||
if (event.type === "thinking" && event.subtype === "delta" && !event.text) {
|
stdoutBuffer = lines.pop() || "";
|
||||||
return;
|
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export const gemini = agent({
|
|||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
finalOutput += text;
|
finalOutput += text;
|
||||||
|
markActivity(); // reset activity timeout on any CLI output
|
||||||
|
|
||||||
// buffer incomplete lines across chunks (NDJSON format)
|
// buffer incomplete lines across chunks (NDJSON format)
|
||||||
stdoutBuffer += text;
|
stdoutBuffer += text;
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const opencode = agent({
|
|||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
output += text;
|
output += text;
|
||||||
|
markActivity(); // reset activity timeout on any CLI output
|
||||||
|
|
||||||
// buffer incomplete lines across chunks (NDJSON format)
|
// buffer incomplete lines across chunks (NDJSON format)
|
||||||
stdoutBuffer += text;
|
stdoutBuffer += text;
|
||||||
|
|||||||
@@ -141921,6 +141921,7 @@ function DelegateTool(ctx) {
|
|||||||
log.info(
|
log.info(
|
||||||
`\xBB delegating to ${selectedMode.name} mode (effort=${effort})${params.instructions ? " with orchestrator instructions" : ""}`
|
`\xBB delegating to ${selectedMode.name} mode (effort=${effort})${params.instructions ? " with orchestrator instructions" : ""}`
|
||||||
);
|
);
|
||||||
|
const keepAliveInterval = setInterval(markActivity, 3e4);
|
||||||
try {
|
try {
|
||||||
const subagentPayload = { ...ctx.payload, effort };
|
const subagentPayload = { ...ctx.payload, effort };
|
||||||
const subagentInstructions = resolveSubagentInstructions({
|
const subagentInstructions = resolveSubagentInstructions({
|
||||||
@@ -141954,6 +141955,7 @@ function DelegateTool(ctx) {
|
|||||||
error: errorMessage
|
error: errorMessage
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
ctx.toolState.delegationActive = false;
|
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)
|
// disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
finalOutput2 += chunk;
|
finalOutput2 += chunk;
|
||||||
|
markActivity();
|
||||||
stdoutBuffer += chunk;
|
stdoutBuffer += chunk;
|
||||||
const lines = stdoutBuffer.split("\n");
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() || "";
|
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)
|
// disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
finalOutput2 += chunk;
|
finalOutput2 += chunk;
|
||||||
|
markActivity();
|
||||||
stdoutBuffer += chunk;
|
stdoutBuffer += chunk;
|
||||||
const lines = stdoutBuffer.split("\n");
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() || "";
|
stdoutBuffer = lines.pop() || "";
|
||||||
@@ -145412,24 +145416,32 @@ var cursor = agent({
|
|||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
let stdoutBuffer = "";
|
||||||
child.on("spawn", () => {
|
child.on("spawn", () => {
|
||||||
log.debug("Cursor CLI process spawned");
|
log.debug("Cursor CLI process spawned");
|
||||||
});
|
});
|
||||||
child.stdout?.on("data", async (data) => {
|
child.stdout?.on("data", async (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
stdout += text;
|
stdout += text;
|
||||||
try {
|
markActivity();
|
||||||
const event = JSON.parse(text);
|
stdoutBuffer += text;
|
||||||
markActivity();
|
const lines = stdoutBuffer.split("\n");
|
||||||
log.debug(JSON.stringify(event, null, 2));
|
stdoutBuffer = lines.pop() || "";
|
||||||
if (event.type === "thinking" && event.subtype === "delta" && !event.text) {
|
for (const line of lines) {
|
||||||
return;
|
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) => {
|
child.stderr?.on("data", (data) => {
|
||||||
@@ -145660,6 +145672,7 @@ var gemini = agent({
|
|||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
finalOutput2 += text;
|
finalOutput2 += text;
|
||||||
|
markActivity();
|
||||||
stdoutBuffer += text;
|
stdoutBuffer += text;
|
||||||
const lines = stdoutBuffer.split("\n");
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() || "";
|
stdoutBuffer = lines.pop() || "";
|
||||||
@@ -145863,6 +145876,7 @@ var opencode = agent({
|
|||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
output += text;
|
output += text;
|
||||||
|
markActivity();
|
||||||
stdoutBuffer += text;
|
stdoutBuffer += text;
|
||||||
const lines = stdoutBuffer.split("\n");
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() || "";
|
stdoutBuffer = lines.pop() || "";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { Effort } from "../external.ts";
|
import { Effort } from "../external.ts";
|
||||||
import type { Mode } from "../modes.ts";
|
import type { Mode } from "../modes.ts";
|
||||||
|
import { markActivity } from "../utils/activity.ts";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { resolveSubagentInstructions } from "../utils/instructions.ts";
|
import { resolveSubagentInstructions } from "../utils/instructions.ts";
|
||||||
import type { ToolContext } from "./server.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" : ""}`
|
`» 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 {
|
try {
|
||||||
// build subagent payload with effort override
|
// build subagent payload with effort override
|
||||||
const subagentPayload = { ...ctx.payload, effort };
|
const subagentPayload = { ...ctx.payload, effort };
|
||||||
@@ -112,6 +119,7 @@ export function DelegateTool(ctx: ToolContext) {
|
|||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
// always release the lock so the orchestrator can delegate again
|
// always release the lock so the orchestrator can delegate again
|
||||||
ctx.toolState.delegationActive = false;
|
ctx.toolState.delegationActive = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user