fix(background-agent): guard stale launch errors and retry links
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -603,6 +603,80 @@ describe("BackgroundManager retry observability", () => {
|
||||
expect(retryReadyNotification).toContain("genai-proxy-openai/gpt-5.4-mini")
|
||||
expect(retryReadyNotification).toContain("Forbidden: Selected provider is forbidden")
|
||||
})
|
||||
|
||||
test("builds retry-ready links from the parent session directory when it differs from the manager directory", async () => {
|
||||
//#given
|
||||
const queuePendingNotification = mock(() => {})
|
||||
const managerDirectory = "/manager/dir"
|
||||
const parentDirectory = "/parent/dir"
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: parentDirectory } }),
|
||||
create: async () => ({ data: { id: "ses_retry_created_parent_dir" } }),
|
||||
promptAsync: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: managerDirectory } as unknown as PluginInput)
|
||||
;(manager as unknown as {
|
||||
queuePendingNotification: (sessionID: string | undefined, notification: string) => void
|
||||
}).queuePendingNotification = queuePendingNotification
|
||||
const task = createMockTask({
|
||||
id: "bg_retry_ready_parent_dir",
|
||||
parentSessionID: "parent-session",
|
||||
status: "pending",
|
||||
attemptCount: 1,
|
||||
queuedAt: new Date(),
|
||||
model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
|
||||
retryNotification: {
|
||||
nextModel: "anthropic/claude-haiku-4.5",
|
||||
},
|
||||
attempts: [
|
||||
{
|
||||
attemptID: "att_retry_failed_parent_dir",
|
||||
attemptNumber: 1,
|
||||
sessionID: "ses_retry_failed_parent_dir",
|
||||
providerID: "genai-proxy-openai",
|
||||
modelID: "gpt-5.4-mini",
|
||||
status: "error",
|
||||
error: "Forbidden: Selected provider is forbidden",
|
||||
},
|
||||
{
|
||||
attemptID: "att_retry_ready_parent_dir",
|
||||
attemptNumber: 2,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-haiku-4.5",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
currentAttemptID: "att_retry_ready_parent_dir",
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
const taskInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: task.model,
|
||||
fallbackChain: task.fallbackChain,
|
||||
category: task.category,
|
||||
}
|
||||
|
||||
//#when
|
||||
await (manager as unknown as {
|
||||
startTask: (queueItem: { task: BackgroundTask; input: typeof taskInput; attemptID: string }) => Promise<void>
|
||||
}).startTask({ task, input: taskInput, attemptID: "att_retry_ready_parent_dir" })
|
||||
|
||||
//#then
|
||||
const retryReadyNotification = queuePendingNotification.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((notification) => notification.includes("[BACKGROUND TASK RETRY SESSION READY]"))
|
||||
const expectedRetryLink = `http://127.0.0.1:4096/${Buffer.from(parentDirectory).toString("base64url")}/session/ses_retry_created_parent_dir`
|
||||
expect(retryReadyNotification).toBeDefined()
|
||||
expect(retryReadyNotification).toContain(expectedRetryLink)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
|
||||
@@ -627,8 +701,6 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
})
|
||||
|
||||
test("should return empty array when no tasks exist", () => {
|
||||
// given - empty manager
|
||||
|
||||
// when
|
||||
const result = manager.getAllDescendantTasks("session-a")
|
||||
|
||||
@@ -838,7 +910,7 @@ describe("BackgroundManager.notifyParentSession - release ordering", () => {
|
||||
})
|
||||
|
||||
test("should keep queue blocked if release is after prompt (demonstrates the bug)", async () => {
|
||||
// given - same setup
|
||||
// given
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
@@ -991,9 +1063,7 @@ describe("BackgroundManager.resume", () => {
|
||||
})
|
||||
|
||||
test("should throw error when task not found", () => {
|
||||
// given - empty manager
|
||||
|
||||
// when / #then
|
||||
// when / then
|
||||
expect(() => manager.resume({
|
||||
sessionId: "non-existent",
|
||||
prompt: "continue",
|
||||
@@ -1147,7 +1217,7 @@ describe("LaunchInput.skillContent", () => {
|
||||
parentMessageID: "parent-msg",
|
||||
}
|
||||
|
||||
// when / #then - should compile without skillContent
|
||||
// when / then
|
||||
expect(input.skillContent).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1230,7 +1300,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
})
|
||||
|
||||
test("should use currentMessage model/agent when available", async () => {
|
||||
// given - currentMessage has model and agent
|
||||
// given
|
||||
const task: BackgroundTask = {
|
||||
id: "task-1",
|
||||
sessionID: "session-child",
|
||||
@@ -1253,7 +1323,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
// when
|
||||
const promptBody = buildNotificationPromptBody(task, currentMessage)
|
||||
|
||||
// then - uses currentMessage values, not task.parentModel/parentAgent
|
||||
// then
|
||||
expect(promptBody.agent).toBe("sisyphus")
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
|
||||
})
|
||||
@@ -1279,7 +1349,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
// when
|
||||
const promptBody = buildNotificationPromptBody(task, currentMessage)
|
||||
|
||||
// then - falls back to task.parentAgent
|
||||
// then
|
||||
expect(promptBody.agent).toBe("FallbackAgent")
|
||||
expect("model" in promptBody).toBe(false)
|
||||
})
|
||||
@@ -1308,7 +1378,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
// when
|
||||
const promptBody = buildNotificationPromptBody(task, currentMessage)
|
||||
|
||||
// then - model not passed due to incomplete data
|
||||
// then
|
||||
expect(promptBody.agent).toBe("sisyphus")
|
||||
expect("model" in promptBody).toBe(false)
|
||||
})
|
||||
@@ -1333,7 +1403,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
// when
|
||||
const promptBody = buildNotificationPromptBody(task, null)
|
||||
|
||||
// then - falls back to task.parentAgent, no model
|
||||
// then
|
||||
expect(promptBody.agent).toBe("sisyphus")
|
||||
expect("model" in promptBody).toBe(false)
|
||||
})
|
||||
@@ -2173,7 +2243,7 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
parentMessageID: "msg-2",
|
||||
})
|
||||
|
||||
// then - model should be passed in prompt body
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-20250514" })
|
||||
expect(promptCalls[0].body.agent).toBe("explore")
|
||||
@@ -2258,7 +2328,7 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
parentMessageID: "msg-2",
|
||||
})
|
||||
|
||||
// then - model should NOT be in prompt body
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect("model" in promptCalls[0].body).toBe(false)
|
||||
expect(promptCalls[0].body.agent).toBe("explore")
|
||||
@@ -2411,8 +2481,8 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
test("should return immediately even with concurrency limit", async () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ client: mockClient, directory: tmpdir() } as unknown as PluginInput, config)
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -2429,7 +2499,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const endTime = Date.now()
|
||||
|
||||
// then
|
||||
expect(endTime - startTime).toBeLessThan(100) // Should be instant
|
||||
expect(endTime - startTime).toBeLessThan(100)
|
||||
expect(task1.status).toBe("pending")
|
||||
expect(task2.status).toBe("pending")
|
||||
})
|
||||
@@ -2595,8 +2665,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
// when
|
||||
const task = await manager.launch(input)
|
||||
|
||||
// Give processKey time to run
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// then
|
||||
@@ -2625,7 +2693,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const task = await manager.launch(input)
|
||||
const queuedAt = task.queuedAt
|
||||
|
||||
// Wait for transition
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// then
|
||||
@@ -3398,8 +3465,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
const task1 = await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
|
||||
// Wait for first task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// when
|
||||
@@ -3427,8 +3492,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
}
|
||||
|
||||
const task = await manager.launch(input)
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// when
|
||||
@@ -3457,8 +3520,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const task1 = await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
const task3 = await manager.launch(input)
|
||||
|
||||
// Wait for first task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// when - cancel middle task
|
||||
@@ -3565,8 +3626,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// when
|
||||
const task1 = await manager.launch(input1)
|
||||
const task2 = await manager.launch(input2)
|
||||
|
||||
// Wait for both to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// then - both should be running despite limit of 1 (different keys)
|
||||
@@ -3594,8 +3653,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// when
|
||||
const task1 = await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// then - same key should respect limit
|
||||
@@ -3633,8 +3690,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// when
|
||||
const task1 = await manager.launch(input1)
|
||||
const task2 = await manager.launch(input2)
|
||||
|
||||
// Wait for both to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// then - different models should run in parallel
|
||||
@@ -3661,11 +3716,8 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
// Launch two tasks (second will be pending)
|
||||
await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
|
||||
// Wait for first to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// when
|
||||
@@ -3676,7 +3728,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(pendingTask?.queuedAt).toBeInstanceOf(Date)
|
||||
expect(pendingTask?.startedAt).toBeUndefined()
|
||||
|
||||
// Verify TTL would use queuedAt (implementation detail check)
|
||||
const now = Date.now()
|
||||
const age = now - pendingTask!.queuedAt!.getTime()
|
||||
expect(age).toBeGreaterThanOrEqual(0)
|
||||
@@ -3698,8 +3749,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
// when
|
||||
const task = await manager.launch(input)
|
||||
|
||||
// Wait for task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// then
|
||||
@@ -3707,7 +3756,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
expect(runningTask?.status).toBe("running")
|
||||
expect(runningTask?.startedAt).toBeInstanceOf(Date)
|
||||
|
||||
// Verify TTL would use startedAt (implementation detail check)
|
||||
const now = Date.now()
|
||||
const age = now - runningTask!.startedAt!.getTime()
|
||||
expect(age).toBeGreaterThanOrEqual(0)
|
||||
@@ -3727,16 +3775,13 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
// Launch task that will queue
|
||||
await manager.launch(input)
|
||||
const task2 = await manager.launch(input)
|
||||
|
||||
const queuedAt = task2.queuedAt!
|
||||
|
||||
// Wait for first task to complete and second to start
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Simulate first task completion
|
||||
const tasks = Array.from(getTaskMap(manager).values())
|
||||
const runningTask = tasks.find(t => t.status === "running" && t.id !== task2.id)
|
||||
if (runningTask?.concurrencyKey) {
|
||||
@@ -3744,7 +3789,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
getConcurrencyManager(manager).release(runningTask.concurrencyKey)
|
||||
}
|
||||
|
||||
// Wait for second task to start
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// then
|
||||
@@ -3779,17 +3823,15 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const endTime = Date.now()
|
||||
|
||||
// then
|
||||
expect(endTime - startTime).toBeLessThan(200) // Should be very fast
|
||||
expect(endTime - startTime).toBeLessThan(200)
|
||||
expect(tasks).toHaveLength(10)
|
||||
tasks.forEach(task => {
|
||||
expect(task.status).toBe("pending")
|
||||
expect(task.id).toMatch(/^bg_/)
|
||||
})
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Verify 5 running, 5 pending
|
||||
const updatedTasks = tasks.map(t => manager.getTask(t.id))
|
||||
const runningCount = updatedTasks.filter(t => t?.status === "running").length
|
||||
const pendingCount = updatedTasks.filter(t => t?.status === "pending").length
|
||||
@@ -6118,4 +6160,112 @@ describe("BackgroundManager attempt lifecycle bindings", () => {
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("late launch prompt errors from a historical attempt do not interrupt the current retry attempt", async () => {
|
||||
//#given
|
||||
let rejectPrompt: ((error: unknown) => void) | undefined
|
||||
const abortCalls: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||
create: async () => ({ data: { id: "session-attempt-1" } }),
|
||||
promptAsync: async () => new Promise((_, reject) => {
|
||||
rejectPrompt = reject
|
||||
}),
|
||||
abort: async ({ path }: { path: { id: string } }) => {
|
||||
abortCalls.push(path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
stubNotifyParentSession(manager)
|
||||
;(manager as unknown as {
|
||||
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
|
||||
}).tryFallbackRetry = async () => false
|
||||
const task: BackgroundTask = {
|
||||
id: "task-stale-prompt-error",
|
||||
status: "pending",
|
||||
queuedAt: new Date("2026-04-27T00:00:00.000Z"),
|
||||
description: "ignore stale prompt errors",
|
||||
prompt: "continue",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
model: { providerID: "openai", modelID: "gpt-5.4-mini" },
|
||||
attempts: [
|
||||
{
|
||||
attemptID: "attempt-1",
|
||||
attemptNumber: 1,
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-mini",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
currentAttemptID: "attempt-1",
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
model: task.model,
|
||||
}
|
||||
|
||||
await (manager as unknown as {
|
||||
startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise<void>
|
||||
}).startTask({ task, input, attemptID: "attempt-1" })
|
||||
|
||||
task.attempts = [
|
||||
{
|
||||
attemptID: "attempt-1",
|
||||
attemptNumber: 1,
|
||||
sessionID: "session-attempt-1",
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-mini",
|
||||
status: "error",
|
||||
error: "first attempt failed",
|
||||
startedAt: new Date("2026-04-27T00:00:00.000Z"),
|
||||
completedAt: new Date("2026-04-27T00:00:05.000Z"),
|
||||
},
|
||||
{
|
||||
attemptID: "attempt-2",
|
||||
attemptNumber: 2,
|
||||
sessionID: "session-attempt-2",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-haiku-4.5",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-04-27T00:00:10.000Z"),
|
||||
},
|
||||
]
|
||||
task.currentAttemptID = "attempt-2"
|
||||
task.sessionID = "session-attempt-2"
|
||||
task.status = "running"
|
||||
task.error = undefined
|
||||
|
||||
//#when
|
||||
rejectPrompt?.({ name: "APIError", data: { message: "Forbidden: Selected provider is forbidden" } })
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(task.currentAttemptID).toBe("attempt-2")
|
||||
expect(task.sessionID).toBe("session-attempt-2")
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.error).toBeUndefined()
|
||||
expect(task.attempts?.[0]).toMatchObject({
|
||||
attemptID: "attempt-1",
|
||||
status: "error",
|
||||
error: "first attempt failed",
|
||||
})
|
||||
expect(task.attempts?.[1]).toMatchObject({
|
||||
attemptID: "attempt-2",
|
||||
status: "running",
|
||||
sessionID: "session-attempt-2",
|
||||
})
|
||||
expect(abortCalls).toEqual([])
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
|
||||
interface MessagePartInfo {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
@@ -581,7 +580,7 @@ export class BackgroundManager {
|
||||
|
||||
if (task.retryNotification) {
|
||||
const attemptNumber = boundAttempt.attemptNumber
|
||||
const retrySessionUrl = buildLocalSessionUrl(this.directory, sessionID)
|
||||
const retrySessionUrl = buildLocalSessionUrl(parentDirectory, sessionID)
|
||||
const previousAttempt = getPreviousAttempt(task, boundAttempt.attemptID)
|
||||
const failedSessionID = previousAttempt?.sessionID ?? task.retryNotification.previousSessionID
|
||||
const failedSessionLine = failedSessionID
|
||||
@@ -689,7 +688,16 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
const existingTask = this.findBySession(sessionID)
|
||||
const resolvedTask = this.resolveTaskAttemptBySession(sessionID)
|
||||
const existingTask = resolvedTask?.task
|
||||
if (resolvedTask && !resolvedTask.isCurrent) {
|
||||
log("[background-agent] Ignoring prompt error from stale attempt session", {
|
||||
sessionID,
|
||||
currentAttemptID: resolvedTask.task.currentAttemptID,
|
||||
attemptID: resolvedTask.attemptID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (existingTask) {
|
||||
const errorInfo = {
|
||||
name: extractErrorName(error),
|
||||
@@ -1188,10 +1196,10 @@ The fallback retry session is now created and can be inspected directly.
|
||||
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = partInfo.tool
|
||||
const circuitBreaker = this.cachedCircuitBreakerSettings ?? resolveCircuitBreakerSettings(this.config)
|
||||
this.cachedCircuitBreakerSettings = circuitBreaker
|
||||
if (partInfo.tool) {
|
||||
task.progress.toolCallWindow = recordToolCall(
|
||||
const circuitBreaker = this.cachedCircuitBreakerSettings ?? resolveCircuitBreakerSettings(this.config)
|
||||
this.cachedCircuitBreakerSettings = circuitBreaker
|
||||
if (partInfo.tool) {
|
||||
task.progress.toolCallWindow = recordToolCall(
|
||||
task.progress.toolCallWindow,
|
||||
partInfo.tool,
|
||||
circuitBreaker,
|
||||
@@ -1813,7 +1821,6 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
unregisterManagerForCleanup(this)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all running tasks (for compaction hook)
|
||||
*/
|
||||
@@ -2183,98 +2190,98 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
if (this.pollingInFlight) return
|
||||
this.pollingInFlight = true
|
||||
try {
|
||||
this.pruneStaleTasksAndNotifications()
|
||||
this.pruneStaleTasksAndNotifications()
|
||||
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||
|
||||
await this.checkAndInterruptStaleTasks(allStatuses)
|
||||
await this.checkAndInterruptStaleTasks(allStatuses)
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const sessionID = task.sessionID
|
||||
if (!sessionID) continue
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const sessionID = task.sessionID
|
||||
if (!sessionID) continue
|
||||
|
||||
try {
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
// Handle retry before checking running state
|
||||
if (sessionStatus?.type === "retry") {
|
||||
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
|
||||
? (sessionStatus as { message?: string }).message
|
||||
: undefined
|
||||
const errorInfo = { name: "SessionRetry", message: retryMessage }
|
||||
if (await this.tryFallbackRetry(task, errorInfo, "polling:session.status")) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Only skip completion when session status is actively running.
|
||||
// Unknown or terminal statuses (like "interrupted") fall through to completion.
|
||||
if (sessionStatus && isActiveSessionStatus(sessionStatus.type)) {
|
||||
log("[background-agent] Session still running, relying on event-based progress:", {
|
||||
taskId: task.id,
|
||||
sessionID,
|
||||
sessionStatus: sessionStatus.type,
|
||||
toolCalls: task.progress?.toolCalls ?? 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus && isTerminalSessionStatus(sessionStatus.type)) {
|
||||
await this.tryCompleteTask(task, `polling (terminal session status: ${sessionStatus.type})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
log("[background-agent] Unknown session status, treating as potentially idle:", {
|
||||
taskId: task.id,
|
||||
sessionID,
|
||||
sessionStatus: sessionStatus.type,
|
||||
})
|
||||
}
|
||||
|
||||
// Session is idle or no longer in status response (completed/disappeared)
|
||||
const sessionGoneFromStatus = !sessionStatus
|
||||
const sessionGoneThresholdReached = sessionGoneFromStatus
|
||||
&& (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
|
||||
const completionSource = sessionStatus?.type === "idle"
|
||||
? "polling (idle status)"
|
||||
: "polling (session gone from status)"
|
||||
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
if (sessionGoneThresholdReached) {
|
||||
const sessionExists = await this.verifySessionExists(sessionID)
|
||||
if (!sessionExists) {
|
||||
log("[background-agent] Session no longer exists (crashed), marking task as error:", task.id)
|
||||
await this.failCrashedTask(task, "Subagent session no longer exists (process likely crashed). The session disappeared without producing any output.")
|
||||
try {
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
// Handle retry before checking running state
|
||||
if (sessionStatus?.type === "retry") {
|
||||
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
|
||||
? (sessionStatus as { message?: string }).message
|
||||
: undefined
|
||||
const errorInfo = { name: "SessionRetry", message: retryMessage }
|
||||
if (await this.tryFallbackRetry(task, errorInfo, "polling:session.status")) {
|
||||
continue
|
||||
}
|
||||
|
||||
task.consecutiveMissedPolls = 0
|
||||
}
|
||||
log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
|
||||
continue
|
||||
|
||||
// Only skip completion when session status is actively running.
|
||||
// Unknown or terminal statuses (like "interrupted") fall through to completion.
|
||||
if (sessionStatus && isActiveSessionStatus(sessionStatus.type)) {
|
||||
log("[background-agent] Session still running, relying on event-based progress:", {
|
||||
taskId: task.id,
|
||||
sessionID,
|
||||
sessionStatus: sessionStatus.type,
|
||||
toolCalls: task.progress?.toolCalls ?? 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus && isTerminalSessionStatus(sessionStatus.type)) {
|
||||
await this.tryCompleteTask(task, `polling (terminal session status: ${sessionStatus.type})`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
log("[background-agent] Unknown session status, treating as potentially idle:", {
|
||||
taskId: task.id,
|
||||
sessionID,
|
||||
sessionStatus: sessionStatus.type,
|
||||
})
|
||||
}
|
||||
|
||||
// Session is idle or no longer in status response (completed/disappeared)
|
||||
const sessionGoneFromStatus = !sessionStatus
|
||||
const sessionGoneThresholdReached = sessionGoneFromStatus
|
||||
&& (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
|
||||
const completionSource = sessionStatus?.type === "idle"
|
||||
? "polling (idle status)"
|
||||
: "polling (session gone from status)"
|
||||
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
|
||||
if (!hasValidOutput) {
|
||||
if (sessionGoneThresholdReached) {
|
||||
const sessionExists = await this.verifySessionExists(sessionID)
|
||||
if (!sessionExists) {
|
||||
log("[background-agent] Session no longer exists (crashed), marking task as error:", task.id)
|
||||
await this.failCrashedTask(task, "Subagent session no longer exists (process likely crashed). The session disappeared without producing any output.")
|
||||
continue
|
||||
}
|
||||
|
||||
task.consecutiveMissedPolls = 0
|
||||
}
|
||||
log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-check status after async operation
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await this.tryCompleteTask(task, completionSource)
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
}
|
||||
|
||||
// Re-check status after async operation
|
||||
if (task.status !== "running") continue
|
||||
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await this.tryCompleteTask(task, completionSource)
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.hasRunningTasks()) {
|
||||
this.stopPolling()
|
||||
}
|
||||
if (!this.hasRunningTasks()) {
|
||||
this.stopPolling()
|
||||
}
|
||||
} finally {
|
||||
this.pollingInFlight = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user