From 9a0f2ff9a756d48546a1f3df542e95287ecaf257 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 31 Mar 2026 15:11:12 -0700 Subject: [PATCH] test(delegate-task): cover aborted concurrent background launches --- .../delegate-task/background-task.test.ts | 224 ++++++++++++++++++ src/tools/delegate-task/background-task.ts | 56 ++++- src/tools/delegate-task/tools.test.ts | 86 +++++++ 3 files changed, 363 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/background-task.test.ts b/src/tools/delegate-task/background-task.test.ts index 7b631f659..4655ec976 100644 --- a/src/tools/delegate-task/background-task.test.ts +++ b/src/tools/delegate-task/background-task.test.ts @@ -7,6 +7,7 @@ const afterEachFn = bunTest.afterEach const { executeBackgroundTask } = require("./background-task") const { __setTimingConfig, __resetTimingConfig } = require("./timing") +const { SessionCategoryRegistry } = require("../../shared/session-category-registry") describeFn("executeBackgroundTask output/session metadata compatibility", () => { beforeEachFn(() => { @@ -19,6 +20,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => afterEachFn(() => { __resetTimingConfig() + SessionCategoryRegistry.clear() }) testFn("does not emit synthetic pending session metadata when session id is unresolved", async () => { @@ -201,4 +203,226 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => { permission: "question", action: "deny", pattern: "*" }, ]) }) + + testFn("keeps launched background task alive when parent aborts before session id resolves", async () => { + //#given - parallel tool execution can abort the parent call after launch succeeds + const metadataCalls: any[] = [] + const abortController = new AbortController() + const manager = { + launch: async () => ({ + id: "bg_abort_after_launch", + sessionID: undefined, + description: "Abort after launch", + agent: "explore", + status: "pending", + }), + getTask: () => { + abortController.abort() + return { sessionID: undefined, status: "pending" } + }, + } + + //#when + const result = await executeBackgroundTask( + { + description: "Abort after launch", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_abort_after_launch", + metadata: async (value: any) => metadataCalls.push(value), + abort: abortController.signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_abort_after_launch" }, + "explore", + undefined, + undefined, + undefined, + ) + + //#then - background launch should still succeed without fake abort failure + expectFn(result).toContain("Background task launched") + expectFn(result).toContain("Background Task ID: bg_abort_after_launch") + expectFn(result).not.toContain("Task aborted while waiting for session to start") + expectFn(metadataCalls).toHaveLength(1) + expectFn("sessionId" in metadataCalls[0].metadata).toBe(false) + }) + + testFn("registers late session category even when parent aborts before session id resolves", async () => { + //#given - session wiring should continue after returning early on parent abort + const abortController = new AbortController() + abortController.abort() + let reads = 0 + const manager = { + launch: async () => ({ + id: "bg_abort_category", + sessionID: undefined, + description: "Abort category", + agent: "explore", + status: "pending", + }), + getTask: () => { + reads += 1 + return reads >= 2 + ? { sessionID: "ses_abort_category", status: "running" } + : { sessionID: undefined, status: "pending" } + }, + } + + //#when + const result = await executeBackgroundTask( + { + description: "Abort category", + prompt: "check", + run_in_background: true, + load_skills: [], + category: "quick", + }, + { + sessionID: "ses_parent", + callID: "call_abort_category", + metadata: async () => {}, + abort: abortController.signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_abort_category" }, + "explore", + undefined, + undefined, + [{ providers: ["openai"], model: "gpt-5.4" }], + ) + + await new Promise(resolve => setTimeout(resolve, 5)) + + //#then - late session setup should still register category for runtime fallback + expectFn(result).toContain("Background task launched") + expectFn(SessionCategoryRegistry.get("ses_abort_category")).toBe("quick") + }) + + testFn("prefers child terminal status over parent abort while waiting for session id", async () => { + //#given - failed child launch should not be misreported as a successful background launch + const abortController = new AbortController() + abortController.abort() + const manager = { + launch: async () => ({ + id: "bg_abort_terminal", + sessionID: undefined, + description: "Abort terminal", + agent: "explore", + status: "pending", + }), + getTask: () => ({ sessionID: undefined, status: "interrupt" }), + } + + //#when + const result = await executeBackgroundTask( + { + description: "Abort terminal", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_abort_terminal", + metadata: async () => {}, + abort: abortController.signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_abort_terminal" }, + "explore", + undefined, + undefined, + undefined, + ) + + //#then - terminal child status should win over abort and surface the failure + expectFn(result).toContain("Task failed to start") + expectFn(result).toContain("interrupt") + }) + + testFn("keeps sibling background launch alive when two tasks start concurrently", async () => { + //#given - one aborted parent call should not interrupt a sibling launch from the same parent session + const firstAbortController = new AbortController() + const secondAbortController = new AbortController() + const states = new Map([ + ["bg_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_first" }], + ["bg_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_second" }], + ]) + let launchCount = 0 + const manager = { + launch: async () => { + launchCount += 1 + return launchCount === 1 + ? { id: "bg_first", sessionID: undefined, description: "First", agent: "explore", status: "pending" } + : { id: "bg_second", sessionID: undefined, description: "Second", agent: "explore", status: "pending" } + }, + getTask: (taskID: string) => { + const state = states.get(taskID) + if (!state) return undefined + state.reads += 1 + if (state.abortOnFirstRead && state.reads === 1) { + firstAbortController.abort() + } + return state.reads >= 2 + ? { sessionID: state.sessionID, status: "running" } + : { sessionID: undefined, status: "pending" } + }, + } + + //#when + const [firstResult, secondResult] = await Promise.all([ + executeBackgroundTask( + { + description: "First", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_first", + metadata: async () => {}, + abort: firstAbortController.signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_first" }, + "explore", + undefined, + undefined, + undefined, + ), + executeBackgroundTask( + { + description: "Second", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_second", + metadata: async () => {}, + abort: secondAbortController.signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_second" }, + "explore", + undefined, + undefined, + undefined, + ), + ]) + + //#then - both tasks still launch and the sibling is not reported as interrupted + expectFn(firstResult).toContain("Background task launched") + expectFn(firstResult).not.toContain("Task failed to start") + expectFn(secondResult).toContain("Background task launched") + expectFn(secondResult).toContain("session_id: ses_second") + expectFn(secondResult).not.toContain("interrupt") + }) }) diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index be9b1f5b3..9b4842cb4 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -10,6 +10,43 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" import { setSessionFallbackChain } from "../../hooks/model-fallback/hook" +function continueSessionSetup(args: { + taskID: string + manager: ExecutorContext["manager"] + timing: ReturnType + fallbackChain?: FallbackEntry[] + category?: string +}): void { + if (!args.fallbackChain && !args.category) { + return + } + + void (async () => { + const waitStart = Date.now() + while (Date.now() - waitStart < args.timing.WAIT_FOR_SESSION_TIMEOUT_MS) { + await new Promise(resolve => setTimeout(resolve, args.timing.WAIT_FOR_SESSION_INTERVAL_MS)) + const updated = args.manager.getTask(args.taskID) + if (!updated) { + return + } + if (updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") { + return + } + + const sessionId = updated.sessionID + if (!sessionId) { + continue + } + + setSessionFallbackChain(sessionId, args.fallbackChain) + if (args.category) { + SessionCategoryRegistry.register(sessionId, args.category) + } + return + } + })() +} + export async function executeBackgroundTask( args: DelegateTaskArgs, ctx: ToolContextWithMetadata, @@ -50,12 +87,25 @@ export async function executeBackgroundTask( const waitStart = Date.now() let sessionId = task.sessionID while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) { + const updated = manager.getTask(task.id) + if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") { + return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}` + } + sessionId = updated?.sessionID + if (sessionId) { + break + } if (ctx.abort?.aborted) { - return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}` + continueSessionSetup({ + taskID: task.id, + manager, + timing, + fallbackChain, + category: args.category, + }) + break } await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS)) - const updated = manager.getTask(task.id) - sessionId = updated?.sessionID } if (sessionId) { diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 2a18b0085..1c2677f97 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1453,6 +1453,92 @@ describe("sisyphus-task", () => { expect(launchCalled).toBe(true) expect(result).toContain("Background task launched") }, { timeout: 10000 }) + + test("#given concurrent background launches from the same parent #when one parent call aborts during session wait #then sibling launch is not interrupted", async () => { + // given + const { createDelegateTask } = require("./tools") + const firstAbortController = new AbortController() + const secondAbortController = new AbortController() + const taskStates = new Map([ + ["bg_tool_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_tool_first" }], + ["bg_tool_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_tool_second" }], + ]) + let launchCount = 0 + const mockManager = { + launch: async () => { + launchCount += 1 + return launchCount === 1 + ? { + id: "bg_tool_first", + sessionID: undefined, + description: "Tool first", + agent: "Sisyphus-Junior", + status: "running", + } + : { + id: "bg_tool_second", + sessionID: undefined, + description: "Tool second", + agent: "Sisyphus-Junior", + status: "running", + } + }, + getTask: (taskID: string) => { + const state = taskStates.get(taskID) + if (!state) return undefined + state.reads += 1 + if (state.abortOnFirstRead && state.reads === 1) { + firstAbortController.abort() + } + return state.reads >= 2 + ? { sessionID: state.sessionID, status: "running" } + : { sessionID: undefined, status: "pending" } + }, + } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, + model: { list: async () => [] }, + session: { + create: async () => ({ data: { id: "ses_bg_explicit_true" } }), + prompt: async () => ({ data: {} }), + promptAsync: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + const tool = createDelegateTask({ manager: mockManager, client: mockClient }) + + // when + const [firstResult, secondResult] = await Promise.all([ + tool.execute( + { + description: "Tool first", + prompt: "Run background", + category: "quick", + run_in_background: true, + load_skills: [], + }, + { sessionID: "parent-session", messageID: "parent-message-1", agent: "sisyphus", abort: firstAbortController.signal } + ), + tool.execute( + { + description: "Tool second", + prompt: "Run background", + category: "quick", + run_in_background: true, + load_skills: [], + }, + { sessionID: "parent-session", messageID: "parent-message-2", agent: "sisyphus", abort: secondAbortController.signal } + ), + ]) + + // then + expect(firstResult).toContain("Background task launched") + expect(firstResult).not.toContain("Task failed to start") + expect(secondResult).toContain("Background task launched") + expect(secondResult).toContain("session_id: ses_tool_second") + expect(secondResult).not.toContain("interrupt") + }, { timeout: 10000 }) }) describe("session_id with background parameter", () => {