diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 8c4c5735..858bf0ab 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1714,17 +1714,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-unstable", + sessionID: "ses_unstable_gemini", + description: "Unstable gemini task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-unstable", - sessionID: "ses_unstable_gemini", - description: "Unstable gemini task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -1839,17 +1841,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-unstable-minimax", + sessionID: "ses_unstable_minimax", + description: "Unstable minimax task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-unstable-minimax", - sessionID: "ses_unstable_minimax", - description: "Unstable minimax task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -1973,17 +1977,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-artistry", + sessionID: "ses_artistry_gemini", + description: "Artistry gemini task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-artistry", - sessionID: "ses_artistry_gemini", - description: "Artistry gemini task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -2039,17 +2045,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-writing", + sessionID: "ses_writing_gemini", + description: "Writing gemini task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-writing", - sessionID: "ses_writing_gemini", - description: "Writing gemini task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { @@ -2105,17 +2113,19 @@ describe("sisyphus-task", () => { const { createDelegateTask } = require("./tools") let launchCalled = false + const launchedTask = { + id: "task-custom-unstable", + sessionID: "ses_custom_unstable", + description: "Custom unstable task", + agent: "sisyphus-junior", + status: "running", + } const mockManager = { launch: async () => { launchCalled = true - return { - id: "task-custom-unstable", - sessionID: "ses_custom_unstable", - description: "Custom unstable task", - agent: "sisyphus-junior", - status: "running", - } + return launchedTask }, + getTask: () => launchedTask, } const mockClient = { diff --git a/src/tools/delegate-task/unstable-agent-task.test.ts b/src/tools/delegate-task/unstable-agent-task.test.ts new file mode 100644 index 00000000..de5de840 --- /dev/null +++ b/src/tools/delegate-task/unstable-agent-task.test.ts @@ -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) + }) +}) diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index 9e0bf853..cc6e7cd8 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -77,6 +77,7 @@ export async function executeUnstableAgentTask( const pollStart = Date.now() let lastMsgCount = 0 let stablePolls = 0 + let terminalStatus: { status: string; error?: string } | undefined while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) { if (ctx.abort?.aborted) { @@ -85,6 +86,12 @@ export async function executeUnstableAgentTask( 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 allStatuses = (statusResult.data ?? {}) as Record 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. + + +session_id: ${sessionID} +` + } + const messagesResult = await client.session.messages({ path: { id: sessionID } }) const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]