fix: detect interrupted/error/cancelled status in unstable-agent-task polling loop
The polling loop in executeUnstableAgentTask only checked session status and message stability, never checking if the background task itself had been interrupted. This caused the tool call to hang until MAX_POLL_TIME_MS (10 minutes) when a task was interrupted by prompt errors. Add manager.getTask() check at each poll iteration to break immediately on terminal statuses (interrupt, error, cancelled), returning a clear failure message instead of hanging.
This commit is contained in:
parent
dd11d5df1b
commit
0ef682965f
@ -1714,17 +1714,19 @@ describe("sisyphus-task", () => {
|
|||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
let launchCalled = false
|
let launchCalled = false
|
||||||
|
|
||||||
|
const launchedTask = {
|
||||||
|
id: "task-unstable",
|
||||||
|
sessionID: "ses_unstable_gemini",
|
||||||
|
description: "Unstable gemini task",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
status: "running",
|
||||||
|
}
|
||||||
const mockManager = {
|
const mockManager = {
|
||||||
launch: async () => {
|
launch: async () => {
|
||||||
launchCalled = true
|
launchCalled = true
|
||||||
return {
|
return launchedTask
|
||||||
id: "task-unstable",
|
|
||||||
sessionID: "ses_unstable_gemini",
|
|
||||||
description: "Unstable gemini task",
|
|
||||||
agent: "sisyphus-junior",
|
|
||||||
status: "running",
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
getTask: () => launchedTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
@ -1839,17 +1841,19 @@ describe("sisyphus-task", () => {
|
|||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
let launchCalled = false
|
let launchCalled = false
|
||||||
|
|
||||||
|
const launchedTask = {
|
||||||
|
id: "task-unstable-minimax",
|
||||||
|
sessionID: "ses_unstable_minimax",
|
||||||
|
description: "Unstable minimax task",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
status: "running",
|
||||||
|
}
|
||||||
const mockManager = {
|
const mockManager = {
|
||||||
launch: async () => {
|
launch: async () => {
|
||||||
launchCalled = true
|
launchCalled = true
|
||||||
return {
|
return launchedTask
|
||||||
id: "task-unstable-minimax",
|
|
||||||
sessionID: "ses_unstable_minimax",
|
|
||||||
description: "Unstable minimax task",
|
|
||||||
agent: "sisyphus-junior",
|
|
||||||
status: "running",
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
getTask: () => launchedTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
@ -1973,17 +1977,19 @@ describe("sisyphus-task", () => {
|
|||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
let launchCalled = false
|
let launchCalled = false
|
||||||
|
|
||||||
|
const launchedTask = {
|
||||||
|
id: "task-artistry",
|
||||||
|
sessionID: "ses_artistry_gemini",
|
||||||
|
description: "Artistry gemini task",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
status: "running",
|
||||||
|
}
|
||||||
const mockManager = {
|
const mockManager = {
|
||||||
launch: async () => {
|
launch: async () => {
|
||||||
launchCalled = true
|
launchCalled = true
|
||||||
return {
|
return launchedTask
|
||||||
id: "task-artistry",
|
|
||||||
sessionID: "ses_artistry_gemini",
|
|
||||||
description: "Artistry gemini task",
|
|
||||||
agent: "sisyphus-junior",
|
|
||||||
status: "running",
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
getTask: () => launchedTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
@ -2039,17 +2045,19 @@ describe("sisyphus-task", () => {
|
|||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
let launchCalled = false
|
let launchCalled = false
|
||||||
|
|
||||||
|
const launchedTask = {
|
||||||
|
id: "task-writing",
|
||||||
|
sessionID: "ses_writing_gemini",
|
||||||
|
description: "Writing gemini task",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
status: "running",
|
||||||
|
}
|
||||||
const mockManager = {
|
const mockManager = {
|
||||||
launch: async () => {
|
launch: async () => {
|
||||||
launchCalled = true
|
launchCalled = true
|
||||||
return {
|
return launchedTask
|
||||||
id: "task-writing",
|
|
||||||
sessionID: "ses_writing_gemini",
|
|
||||||
description: "Writing gemini task",
|
|
||||||
agent: "sisyphus-junior",
|
|
||||||
status: "running",
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
getTask: () => launchedTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
@ -2105,17 +2113,19 @@ describe("sisyphus-task", () => {
|
|||||||
const { createDelegateTask } = require("./tools")
|
const { createDelegateTask } = require("./tools")
|
||||||
let launchCalled = false
|
let launchCalled = false
|
||||||
|
|
||||||
|
const launchedTask = {
|
||||||
|
id: "task-custom-unstable",
|
||||||
|
sessionID: "ses_custom_unstable",
|
||||||
|
description: "Custom unstable task",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
status: "running",
|
||||||
|
}
|
||||||
const mockManager = {
|
const mockManager = {
|
||||||
launch: async () => {
|
launch: async () => {
|
||||||
launchCalled = true
|
launchCalled = true
|
||||||
return {
|
return launchedTask
|
||||||
id: "task-custom-unstable",
|
|
||||||
sessionID: "ses_custom_unstable",
|
|
||||||
description: "Custom unstable task",
|
|
||||||
agent: "sisyphus-junior",
|
|
||||||
status: "running",
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
getTask: () => launchedTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
|||||||
224
src/tools/delegate-task/unstable-agent-task.test.ts
Normal file
224
src/tools/delegate-task/unstable-agent-task.test.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
const { describe, test, expect, beforeEach, afterEach, mock } = require("bun:test")
|
||||||
|
|
||||||
|
describe("executeUnstableAgentTask - interrupt detection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
//#given - configure fast timing for all tests
|
||||||
|
const { __setTimingConfig } = require("./timing")
|
||||||
|
__setTimingConfig({
|
||||||
|
POLL_INTERVAL_MS: 10,
|
||||||
|
MIN_STABILITY_TIME_MS: 0,
|
||||||
|
STABILITY_POLLS_REQUIRED: 1,
|
||||||
|
MAX_POLL_TIME_MS: 500,
|
||||||
|
WAIT_FOR_SESSION_TIMEOUT_MS: 100,
|
||||||
|
WAIT_FOR_SESSION_INTERVAL_MS: 10,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
//#given - reset timing after each test
|
||||||
|
const { __resetTimingConfig } = require("./timing")
|
||||||
|
__resetTimingConfig()
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should return error immediately when background task becomes interrupted during polling", async () => {
|
||||||
|
//#given - a background task that gets interrupted on first poll check
|
||||||
|
const taskState = {
|
||||||
|
id: "bg_test_interrupt",
|
||||||
|
sessionID: "ses_test_interrupt",
|
||||||
|
status: "interrupt" as string,
|
||||||
|
description: "test interrupted task",
|
||||||
|
prompt: "test prompt",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
error: "Agent not found" as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
|
||||||
|
|
||||||
|
const mockManager = {
|
||||||
|
launch: async () => launchState,
|
||||||
|
getTask: () => taskState,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { executeUnstableAgentTask } = require("./unstable-agent-task")
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
prompt: "test prompt",
|
||||||
|
description: "test task",
|
||||||
|
category: "test",
|
||||||
|
load_skills: [],
|
||||||
|
run_in_background: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
callID: "call-123",
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockExecutorCtx = {
|
||||||
|
manager: mockManager,
|
||||||
|
client: mockClient,
|
||||||
|
directory: "/tmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentContext = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
messageID: "msg-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when - executeUnstableAgentTask encounters an interrupted task
|
||||||
|
const startTime = Date.now()
|
||||||
|
const result = await executeUnstableAgentTask(
|
||||||
|
args, mockCtx, mockExecutorCtx, parentContext,
|
||||||
|
"test-agent", undefined, undefined, "test-model"
|
||||||
|
)
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
|
||||||
|
//#then - should return quickly with interrupt error, not hang until MAX_POLL_TIME_MS
|
||||||
|
expect(result).toContain("interrupt")
|
||||||
|
expect(result.toLowerCase()).toContain("agent not found")
|
||||||
|
expect(elapsed).toBeLessThan(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should return error immediately when background task becomes errored during polling", async () => {
|
||||||
|
//#given - a background task that is already errored when poll checks
|
||||||
|
const taskState = {
|
||||||
|
id: "bg_test_error",
|
||||||
|
sessionID: "ses_test_error",
|
||||||
|
status: "error" as string,
|
||||||
|
description: "test error task",
|
||||||
|
prompt: "test prompt",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
error: "Rate limit exceeded" as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
|
||||||
|
|
||||||
|
const mockManager = {
|
||||||
|
launch: async () => launchState,
|
||||||
|
getTask: () => taskState,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { executeUnstableAgentTask } = require("./unstable-agent-task")
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
prompt: "test prompt",
|
||||||
|
description: "test task",
|
||||||
|
category: "test",
|
||||||
|
load_skills: [],
|
||||||
|
run_in_background: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
callID: "call-123",
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockExecutorCtx = {
|
||||||
|
manager: mockManager,
|
||||||
|
client: mockClient,
|
||||||
|
directory: "/tmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentContext = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
messageID: "msg-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when - executeUnstableAgentTask encounters an errored task
|
||||||
|
const startTime = Date.now()
|
||||||
|
const result = await executeUnstableAgentTask(
|
||||||
|
args, mockCtx, mockExecutorCtx, parentContext,
|
||||||
|
"test-agent", undefined, undefined, "test-model"
|
||||||
|
)
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
|
||||||
|
//#then - should return quickly with error, not hang until MAX_POLL_TIME_MS
|
||||||
|
expect(result).toContain("error")
|
||||||
|
expect(result.toLowerCase()).toContain("rate limit exceeded")
|
||||||
|
expect(elapsed).toBeLessThan(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should return error immediately when background task becomes cancelled during polling", async () => {
|
||||||
|
//#given - a background task that is already cancelled when poll checks
|
||||||
|
const taskState = {
|
||||||
|
id: "bg_test_cancel",
|
||||||
|
sessionID: "ses_test_cancel",
|
||||||
|
status: "cancelled" as string,
|
||||||
|
description: "test cancelled task",
|
||||||
|
prompt: "test prompt",
|
||||||
|
agent: "sisyphus-junior",
|
||||||
|
error: "Stale timeout" as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchState = { ...taskState, status: "running" as string, error: undefined as string | undefined }
|
||||||
|
|
||||||
|
const mockManager = {
|
||||||
|
launch: async () => launchState,
|
||||||
|
getTask: () => taskState,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { [taskState.sessionID!]: { type: "idle" } } }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { executeUnstableAgentTask } = require("./unstable-agent-task")
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
prompt: "test prompt",
|
||||||
|
description: "test task",
|
||||||
|
category: "test",
|
||||||
|
load_skills: [],
|
||||||
|
run_in_background: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
callID: "call-123",
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockExecutorCtx = {
|
||||||
|
manager: mockManager,
|
||||||
|
client: mockClient,
|
||||||
|
directory: "/tmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentContext = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
messageID: "msg-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when - executeUnstableAgentTask encounters a cancelled task
|
||||||
|
const startTime = Date.now()
|
||||||
|
const result = await executeUnstableAgentTask(
|
||||||
|
args, mockCtx, mockExecutorCtx, parentContext,
|
||||||
|
"test-agent", undefined, undefined, "test-model"
|
||||||
|
)
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
|
||||||
|
//#then - should return quickly with cancel info, not hang until MAX_POLL_TIME_MS
|
||||||
|
expect(result).toContain("cancel")
|
||||||
|
expect(result.toLowerCase()).toContain("stale timeout")
|
||||||
|
expect(elapsed).toBeLessThan(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -77,6 +77,7 @@ export async function executeUnstableAgentTask(
|
|||||||
const pollStart = Date.now()
|
const pollStart = Date.now()
|
||||||
let lastMsgCount = 0
|
let lastMsgCount = 0
|
||||||
let stablePolls = 0
|
let stablePolls = 0
|
||||||
|
let terminalStatus: { status: string; error?: string } | undefined
|
||||||
|
|
||||||
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
|
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
|
||||||
if (ctx.abort?.aborted) {
|
if (ctx.abort?.aborted) {
|
||||||
@ -85,6 +86,12 @@ export async function executeUnstableAgentTask(
|
|||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS))
|
await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS))
|
||||||
|
|
||||||
|
const currentTask = manager.getTask(task.id)
|
||||||
|
if (currentTask && (currentTask.status === "interrupt" || currentTask.status === "error" || currentTask.status === "cancelled")) {
|
||||||
|
terminalStatus = { status: currentTask.status, error: currentTask.error }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const statusResult = await client.session.status()
|
const statusResult = await client.session.status()
|
||||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||||
const sessionStatus = allStatuses[sessionID]
|
const sessionStatus = allStatuses[sessionID]
|
||||||
@ -110,6 +117,24 @@ export async function executeUnstableAgentTask(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (terminalStatus) {
|
||||||
|
const duration = formatDuration(startTime)
|
||||||
|
return `SUPERVISED TASK FAILED (${terminalStatus.status})
|
||||||
|
|
||||||
|
Task was interrupted/failed while running in monitored background mode.
|
||||||
|
${terminalStatus.error ? `Error: ${terminalStatus.error}` : ""}
|
||||||
|
|
||||||
|
Duration: ${duration}
|
||||||
|
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
|
||||||
|
Model: ${actualModel}
|
||||||
|
|
||||||
|
The task session may contain partial results.
|
||||||
|
|
||||||
|
<task_metadata>
|
||||||
|
session_id: ${sessionID}
|
||||||
|
</task_metadata>`
|
||||||
|
}
|
||||||
|
|
||||||
const messagesResult = await client.session.messages({ path: { id: sessionID } })
|
const messagesResult = await client.session.messages({ path: { id: sessionID } })
|
||||||
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
|
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user