test(delegate-task): cover aborted concurrent background launches
This commit is contained in:
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof getTimingConfig>
|
||||
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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user