diff --git a/src/tools/delegate-task/sync-poll-timeout.test.ts b/src/tools/delegate-task/sync-poll-timeout.test.ts index b89b7887a..fa94568de 100644 --- a/src/tools/delegate-task/sync-poll-timeout.test.ts +++ b/src/tools/delegate-task/sync-poll-timeout.test.ts @@ -1,6 +1,6 @@ declare const require: (name: string) => any const { describe, test, expect, beforeEach, afterEach } = require("bun:test") -import { __setTimingConfig, __resetTimingConfig, DEFAULT_SYNC_POLL_TIMEOUT_MS } from "./timing" +import { __setTimingConfig, __resetTimingConfig, getTimingConfig } from "./timing" function createMockCtx(aborted = false) { const controller = new AbortController() @@ -78,8 +78,7 @@ describe("syncPollTimeoutMs threading", () => { test("#then default timeout constant is used", async () => { const { pollSyncSession } = require("./sync-session-poller") const mockClient = createNeverCompleteClient("ses_default") - - expect(DEFAULT_SYNC_POLL_TIMEOUT_MS).toBe(600_000) + const { MAX_POLL_TIME_MS } = getTimingConfig() await withMockedDateNow(300_000, async () => { const result = await pollSyncSession(createMockCtx(), mockClient, { @@ -89,7 +88,25 @@ describe("syncPollTimeoutMs threading", () => { taskId: undefined, }) - expect(result).toBe(`Poll timeout reached after ${DEFAULT_SYNC_POLL_TIMEOUT_MS}ms for session ses_default`) + expect(result).toBe(`Poll timeout reached after ${MAX_POLL_TIME_MS}ms for session ses_default`) + }) + }) + + test("#then MAX_POLL_TIME_MS override is respected for backward compatibility", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_legacy") + + __setTimingConfig({ MAX_POLL_TIME_MS: 120_000 }) + + await withMockedDateNow(60_000, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_legacy", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }) + + expect(result).toBe("Poll timeout reached after 120000ms for session ses_legacy") }) }) }) @@ -169,7 +186,7 @@ describe("syncPollTimeoutMs threading", () => { ) expect(statusCallCount).toBe(0) - expect(result).toContain("SUPERVISED TASK COMPLETED SUCCESSFULLY") + expect(result).toContain("SUPERVISED TASK TIMED OUT") }) }) }) diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 3d5e88df2..c0ba40a4f 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -1,6 +1,6 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" -import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing" +import { getDefaultSyncPollTimeoutMs, getTimingConfig } from "./timing" import { log } from "../../shared/logger" import { normalizeSDKResponse } from "../../shared" @@ -36,7 +36,7 @@ export async function pollSyncSession( timeoutMs?: number ): Promise { const syncTiming = getTimingConfig() - const maxPollTimeMs = Math.max(timeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS, 50) + const maxPollTimeMs = Math.max(timeoutMs ?? getDefaultSyncPollTimeoutMs(), 50) const pollStart = Date.now() let pollCount = 0 let timedOut = false diff --git a/src/tools/delegate-task/timing.test.ts b/src/tools/delegate-task/timing.test.ts new file mode 100644 index 000000000..dba1da826 --- /dev/null +++ b/src/tools/delegate-task/timing.test.ts @@ -0,0 +1,18 @@ +declare const require: (name: string) => any +const { describe, expect, test } = require("bun:test") +import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs } from "./timing" + +describe("timing sync poll timeout defaults", () => { + test("default sync timeout accessor follows MAX_POLL_TIME_MS config", () => { + // #given + __resetTimingConfig() + + // #when + __setTimingConfig({ MAX_POLL_TIME_MS: 123_456 }) + + // #then + expect(getDefaultSyncPollTimeoutMs()).toBe(123_456) + + __resetTimingConfig() + }) +}) diff --git a/src/tools/delegate-task/timing.ts b/src/tools/delegate-task/timing.ts index 5b404f3b8..8ebd1dfd6 100644 --- a/src/tools/delegate-task/timing.ts +++ b/src/tools/delegate-task/timing.ts @@ -3,10 +3,15 @@ let MIN_STABILITY_TIME_MS = 10000 let STABILITY_POLLS_REQUIRED = 3 let WAIT_FOR_SESSION_INTERVAL_MS = 100 let WAIT_FOR_SESSION_TIMEOUT_MS = 30000 -let MAX_POLL_TIME_MS = 10 * 60 * 1000 +const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000 +let MAX_POLL_TIME_MS = DEFAULT_POLL_TIMEOUT_MS let SESSION_CONTINUATION_STABILITY_MS = 5000 -export const DEFAULT_SYNC_POLL_TIMEOUT_MS = 600_000 +export const DEFAULT_SYNC_POLL_TIMEOUT_MS = DEFAULT_POLL_TIMEOUT_MS + +export function getDefaultSyncPollTimeoutMs(): number { + return MAX_POLL_TIME_MS +} export function getTimingConfig() { return { @@ -26,7 +31,7 @@ export function __resetTimingConfig(): void { STABILITY_POLLS_REQUIRED = 3 WAIT_FOR_SESSION_INTERVAL_MS = 100 WAIT_FOR_SESSION_TIMEOUT_MS = 30000 - MAX_POLL_TIME_MS = 10 * 60 * 1000 + MAX_POLL_TIME_MS = DEFAULT_POLL_TIMEOUT_MS SESSION_CONTINUATION_STABILITY_MS = 5000 } diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index ca6c38ee1..6f588482b 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -79,6 +79,7 @@ export async function executeUnstableAgentTask( let lastMsgCount = 0 let stablePolls = 0 let terminalStatus: { status: string; error?: string } | undefined + let completedDuringMonitoring = false while (Date.now() - pollStart < (syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS)) { if (ctx.abort?.aborted) { @@ -113,7 +114,10 @@ export async function executeUnstableAgentTask( if (currentMsgCount === lastMsgCount) { stablePolls++ - if (stablePolls >= timingCfg.STABILITY_POLLS_REQUIRED) break + if (stablePolls >= timingCfg.STABILITY_POLLS_REQUIRED) { + completedDuringMonitoring = true + break + } } else { stablePolls = 0 lastMsgCount = currentMsgCount @@ -133,6 +137,25 @@ Model: ${actualModel} The task session may contain partial results. + +session_id: ${sessionID} +` + } + + if (!completedDuringMonitoring) { + const duration = formatDuration(startTime) + const timeoutBudgetMs = syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS + return `SUPERVISED TASK TIMED OUT + +Task did not reach a stable completion signal within the monitored timeout budget. +Timeout budget: ${timeoutBudgetMs}ms + +Duration: ${duration} +Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} +Model: ${actualModel} + +The task session may still contain partial results. + session_id: ${sessionID} ` diff --git a/src/tools/delegate-task/unstable-agent-timeout.test.ts b/src/tools/delegate-task/unstable-agent-timeout.test.ts new file mode 100644 index 000000000..30bdc1fe2 --- /dev/null +++ b/src/tools/delegate-task/unstable-agent-timeout.test.ts @@ -0,0 +1,81 @@ +declare const require: (name: string) => any +const { describe, test, expect, beforeEach, afterEach } = require("bun:test") +import { __setTimingConfig, __resetTimingConfig } from "./timing" + +describe("executeUnstableAgentTask timeout handling", () => { + beforeEach(() => { + __setTimingConfig({ + POLL_INTERVAL_MS: 10, + MIN_STABILITY_TIME_MS: 0, + STABILITY_POLLS_REQUIRED: 1, + WAIT_FOR_SESSION_TIMEOUT_MS: 100, + WAIT_FOR_SESSION_INTERVAL_MS: 10, + }) + }) + + afterEach(() => { + __resetTimingConfig() + }) + + test("returns timeout status instead of success when monitored poll budget is exhausted", async () => { + // #given + const { executeUnstableAgentTask } = require("./unstable-agent-task") + + const mockManager = { + launch: async () => ({ id: "task_001", sessionID: "ses_timeout", status: "running" }), + getTask: () => ({ id: "task_001", sessionID: "ses_timeout", status: "running" }), + } + + const mockClient = { + session: { + status: async () => ({ data: { ses_timeout: { type: "running" } } }), + messages: async () => ({ + data: [ + { + info: { id: "msg_002", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "This should not be treated as success" }], + }, + ], + }), + }, + } + + const args = { + description: "timeout case", + prompt: "run", + category: "unspecified-low", + run_in_background: false, + load_skills: [], + command: undefined, + } + + // #when + const result = await executeUnstableAgentTask( + args, + { + sessionID: "parent-session", + messageID: "parent-message", + metadata: () => Promise.resolve(), + }, + { + manager: mockManager, + client: mockClient, + syncPollTimeoutMs: 0, + }, + { + sessionID: "parent-session", + messageID: "parent-message", + model: "gpt-test", + agent: "test-agent", + }, + "test-agent", + undefined, + undefined, + "gpt-test" + ) + + // #then + expect(result).toContain("TIMED OUT") + expect(result).not.toContain("SUPERVISED TASK COMPLETED SUCCESSFULLY") + }) +})