feat(delegate-task): ⚙️ make sync subagent timeout configurable via syncPollTimeoutMs

Allow users to set `background_task.syncPollTimeoutMs` in config to override
the default 10-minute sync subagent timeout. Affects sync task, sync continuation,
and unstable agent task paths. Minimum value: 60000ms (1 minute).

Co-authored-by: Wine Fox <fox@ling.plus>
This commit is contained in:
Lynricsy
2026-02-27 13:54:50 +08:00
parent 2eb7994163
commit d09cf56e15
14 changed files with 296 additions and 29 deletions

View File

@@ -3685,6 +3685,10 @@
"messageStalenessTimeoutMs": {
"type": "number",
"minimum": 60000
},
"syncPollTimeoutMs": {
"type": "number",
"minimum": 60000
}
},
"additionalProperties": false

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { ZodError } from "zod/v4"
import { BackgroundTaskConfigSchema } from "./background-task"
describe("BackgroundTaskConfigSchema", () => {
describe("syncPollTimeoutMs", () => {
describe("#given valid syncPollTimeoutMs (120000)", () => {
test("#when parsed #then returns correct value", () => {
const result = BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: 120000 })
expect(result.syncPollTimeoutMs).toBe(120000)
})
})
describe("#given syncPollTimeoutMs below minimum (59999)", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: 59999 })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
describe("#given syncPollTimeoutMs not provided", () => {
test("#when parsed #then field is undefined", () => {
const result = BackgroundTaskConfigSchema.parse({})
expect(result.syncPollTimeoutMs).toBeUndefined()
})
})
describe('#given syncPollTimeoutMs is non-number ("abc")', () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: "abc" })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})
})

View File

@@ -8,6 +8,7 @@ export const BackgroundTaskConfigSchema = z.object({
staleTimeoutMs: z.number().min(60000).optional(),
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */
messageStalenessTimeoutMs: z.number().min(60000).optional(),
syncPollTimeoutMs: z.number().min(60000).optional(),
})
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>

View File

@@ -67,6 +67,7 @@ export function createToolRegistry(args: {
disabledSkills: skillContext.disabledSkills,
availableCategories,
availableSkills: skillContext.availableSkills,
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
onSyncSessionCreated: async (event) => {
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,

View File

@@ -12,6 +12,7 @@ export interface ExecutorContext {
browserProvider?: BrowserAutomationProvider
agentOverrides?: AgentOverrides
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
syncPollTimeoutMs?: number
}
export interface ParentContext {

View File

@@ -18,7 +18,7 @@ export async function executeSyncContinuation(
executorCtx: ExecutorContext,
deps: SyncContinuationDeps = syncContinuationDeps
): Promise<string> {
const { client } = executorCtx
const { client, syncPollTimeoutMs } = executorCtx
const toastManager = getTaskToastManager()
const taskId = `resume_sync_${args.session_id!.slice(0, 8)}`
const startTime = new Date()
@@ -112,7 +112,7 @@ export async function executeSyncContinuation(
toastManager,
taskId,
anchorMessageCount,
})
}, syncPollTimeoutMs)
if (pollError) {
return pollError
}

View File

@@ -0,0 +1,176 @@
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"
function createMockCtx(aborted = false) {
const controller = new AbortController()
if (aborted) controller.abort()
return {
sessionID: "parent-session",
messageID: "parent-message",
agent: "test-agent",
abort: controller.signal,
}
}
function createNeverCompleteClient(sessionID: string) {
return {
session: {
messages: async () => ({
data: [{ info: { id: "msg_001", role: "user", time: { created: 1000 } } }],
}),
status: async () => ({ data: { [sessionID]: { type: "idle" } } }),
},
}
}
async function withMockedDateNow(stepMs: number, run: () => Promise<void>) {
const originalDateNow = Date.now
let now = 0
Date.now = () => {
const current = now
now += stepMs
return current
}
try {
await run()
} finally {
Date.now = originalDateNow
}
}
describe("syncPollTimeoutMs threading", () => {
beforeEach(() => {
__setTimingConfig({
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 0,
STABILITY_POLLS_REQUIRED: 1,
MAX_POLL_TIME_MS: 5000,
})
})
afterEach(() => {
__resetTimingConfig()
})
describe("#given pollSyncSession timeoutMs input", () => {
describe("#when custom timeout is provided", () => {
test("#then custom timeout value is used", async () => {
const { pollSyncSession } = require("./sync-session-poller")
const mockClient = createNeverCompleteClient("ses_custom")
await withMockedDateNow(60_000, async () => {
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_custom",
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
}, 120_000)
expect(result).toBe("Poll timeout reached after 120000ms for session ses_custom")
})
})
})
describe("#when timeoutMs is omitted", () => {
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)
await withMockedDateNow(300_000, async () => {
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_default",
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
})
expect(result).toBe(`Poll timeout reached after ${DEFAULT_SYNC_POLL_TIMEOUT_MS}ms for session ses_default`)
})
})
})
describe("#when timeoutMs is lower than minimum guard", () => {
test("#then minimum 50ms timeout is enforced", async () => {
const { pollSyncSession } = require("./sync-session-poller")
const mockClient = createNeverCompleteClient("ses_guard")
await withMockedDateNow(25, async () => {
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_guard",
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
}, 10)
expect(result).toBe("Poll timeout reached after 50ms for session ses_guard")
})
})
})
})
describe("#given unstable-agent-task path", () => {
describe("#when syncPollTimeoutMs is set in executor context", () => {
test("#then unstable path uses configured timeout budget", async () => {
const { executeUnstableAgentTask } = require("./unstable-agent-task")
let statusCallCount = 0
const mockClient = {
session: {
status: async () => {
statusCallCount++
return { data: { ses_unstable: { type: "idle" } } }
},
messages: async () => ({
data: [
{
info: { id: "msg_001", role: "assistant", time: { created: 2000 } },
parts: [{ type: "text", text: "unstable path done" }],
},
],
}),
},
}
const mockManager = {
launch: async () => ({ id: "task_001", sessionID: "ses_unstable", status: "running" }),
getTask: () => ({ id: "task_001", sessionID: "ses_unstable", status: "running" }),
}
const result = await executeUnstableAgentTask(
{
description: "unstable timeout threading",
prompt: "run",
category: "unspecified-low",
run_in_background: false,
load_skills: [],
command: undefined,
},
createMockCtx(),
{
manager: mockManager,
client: mockClient,
syncPollTimeoutMs: 0,
},
{
sessionID: "parent-session",
messageID: "parent-message",
model: "gpt-test",
agent: "test-agent",
},
"test-agent",
undefined,
undefined,
"gpt-test"
)
expect(statusCallCount).toBe(0)
expect(result).toContain("SUPERVISED TASK COMPLETED SUCCESSFULLY")
})
})
})
})

View File

@@ -273,7 +273,7 @@ describe("pollSyncSession", () => {
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
})
}, 0)
//#then - timeout returns error string
expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout")

View File

@@ -1,6 +1,6 @@
import type { ToolContextWithMetadata, OpencodeClient } from "./types"
import type { SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing"
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
@@ -32,10 +32,11 @@ export async function pollSyncSession(
toastManager: { removeTask: (id: string) => void } | null | undefined
taskId: string | undefined
anchorMessageCount?: number
}
},
timeoutMs?: number
): Promise<string | null> {
const syncTiming = getTimingConfig()
const maxPollTimeMs = Math.max(syncTiming.MAX_POLL_TIME_MS, 50)
const maxPollTimeMs = Math.max(timeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS, 50)
const pollStart = Date.now()
let pollCount = 0
let timedOut = false

View File

@@ -23,7 +23,7 @@ export async function executeSyncTask(
fallbackChain?: import("../../shared/model-requirements").FallbackEntry[],
deps: SyncTaskDeps = syncTaskDeps
): Promise<string> {
const { client, directory, onSyncSessionCreated } = executorCtx
const { client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx
const toastManager = getTaskToastManager()
let taskId: string | undefined
let syncSessionID: string | undefined
@@ -117,7 +117,7 @@ export async function executeSyncTask(
agentToUse,
toastManager,
taskId,
})
}, syncPollTimeoutMs)
if (pollError) {
return pollError
}

View File

@@ -6,6 +6,8 @@ let WAIT_FOR_SESSION_TIMEOUT_MS = 30000
let MAX_POLL_TIME_MS = 10 * 60 * 1000
let SESSION_CONTINUATION_STABILITY_MS = 5000
export const DEFAULT_SYNC_POLL_TIMEOUT_MS = 600_000
export function getTimingConfig() {
return {
POLL_INTERVAL_MS,

View File

@@ -1357,29 +1357,58 @@ describe("sisyphus-task", () => {
return { data: {} }
})
const baseTime = Date.now()
const initialMessages = [
{
info: {
id: "msg_001",
role: "user",
agent: "sisyphus-junior",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
variant: "max",
time: { created: baseTime },
},
parts: [{ type: "text", text: "previous message" }],
},
{
info: { id: "msg_002", role: "assistant", time: { created: baseTime + 1 }, finish: "end_turn" },
parts: [{ type: "text", text: "Completed." }],
},
]
const messagesCallCounts: Record<string, number> = {}
const mockClient = {
session: {
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [
{
info: {
id: "msg_001",
role: "user",
agent: "sisyphus-junior",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
variant: "max",
time: { created: Date.now() },
messages: async (input: any) => {
const sessionID = input?.path?.id
if (typeof sessionID !== "string") {
return { data: [] }
}
const callCount = (messagesCallCounts[sessionID] ?? 0) + 1
messagesCallCounts[sessionID] = callCount
if (sessionID !== "ses_var_test") {
return { data: [] }
}
if (callCount === 1) {
return { data: initialMessages }
}
return {
data: [
...initialMessages,
{
info: { id: "msg_003", role: "assistant", time: { created: baseTime + 2 }, finish: "end_turn" },
parts: [{ type: "text", text: "Continued." }],
},
parts: [{ type: "text", text: "previous message" }],
},
{
info: { id: "msg_002", role: "assistant", time: { created: Date.now() + 1 }, finish: "end_turn" },
parts: [{ type: "text", text: "Completed." }],
},
],
}),
],
}
},
status: async () => ({ data: { "ses_var_test": { type: "idle" } } }),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },

View File

@@ -68,6 +68,7 @@ export interface DelegateTaskToolOptions {
availableSkills?: AvailableSkill[]
agentOverrides?: AgentOverrides
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
syncPollTimeoutMs?: number
}
export interface BuildSystemContentInput {

View File

@@ -1,6 +1,6 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing"
import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
@@ -17,7 +17,7 @@ export async function executeUnstableAgentTask(
systemContent: string | undefined,
actualModel: string | undefined
): Promise<string> {
const { manager, client } = executorCtx
const { manager, client, syncPollTimeoutMs } = executorCtx
try {
const task = await manager.launch({
@@ -80,7 +80,7 @@ export async function executeUnstableAgentTask(
let stablePolls = 0
let terminalStatus: { status: string; error?: string } | undefined
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
while (Date.now() - pollStart < (syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS)) {
if (ctx.abort?.aborted) {
return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}`
}