fix(prompt-gate): pin duplicate prompt dispatches
Keep prompt reservations briefly after successful dispatch so rapid idle/message/error transitions cannot inject the same follow-up twice. Route all production session prompt calls through the shared gate, restore skipped background resume state, release holds after abort/recovery paths, and preserve Ralph/ULW loop state when a dispatch is deferred. Add regression coverage for session routing, static prompt route auditing, team-mode live messaging, model suggestion retries, call-omo-agent reuse, background parent wakes, runtime fallback, compaction recovery, Atlas, and Ralph/ULW loops.
This commit is contained in:
@@ -12,6 +12,7 @@ import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
|
||||
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
|
||||
|
||||
@@ -27,6 +28,11 @@ mock.restore()
|
||||
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
type PendingParentWakeForTest = {
|
||||
promptContext: Record<string, unknown>
|
||||
notifications: string[]
|
||||
shouldReply: boolean
|
||||
}
|
||||
|
||||
class MockBackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask> = new Map()
|
||||
@@ -235,6 +241,14 @@ function getPendingNotifications(manager: BackgroundManager): Map<string, string
|
||||
return (cast<{ pendingNotifications: Map<string, string[]> }>(manager)).pendingNotifications
|
||||
}
|
||||
|
||||
function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return (cast<{ pendingParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).pendingParentWakes
|
||||
}
|
||||
|
||||
function getDispatchedParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return (cast<{ dispatchedParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).dispatchedParentWakes
|
||||
}
|
||||
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return (cast<{ completionTimers: Map<string, ReturnType<typeof setTimeout>> }>(manager)).completionTimers
|
||||
}
|
||||
@@ -2208,6 +2222,123 @@ describe("BackgroundManager.resume concurrency key", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.resume promptAsync gate state", () => {
|
||||
test("restores completed task state when resume prompt is skipped because the session is active", async () => {
|
||||
//#given
|
||||
let promptCallCount = 0
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "session-active-resume": { type: "busy" } } }),
|
||||
promptAsync: async () => {
|
||||
promptCallCount += 1
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-active-resume-skip",
|
||||
sessionId: "session-active-resume",
|
||||
parentSessionId: "parent-session-original",
|
||||
parentMessageId: "msg-original",
|
||||
description: "completed task",
|
||||
prompt: "original prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(Date.now() - 1000),
|
||||
completedAt: new Date(),
|
||||
error: "previous terminal note",
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
const originalCompletedAt = task.completedAt
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-active-resume",
|
||||
prompt: "continue",
|
||||
parentSessionId: "parent-session-new",
|
||||
parentMessageId: "msg-new",
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(promptCallCount).toBe(0)
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.completedAt).toBe(originalCompletedAt)
|
||||
expect(task.error).toBe("previous terminal note")
|
||||
expect(task.parentSessionId).toBe("parent-session-original")
|
||||
expect(task.parentMessageId).toBe("msg-original")
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
expect(getConcurrencyManager(manager).getCount("explore")).toBe(0)
|
||||
expect(getPendingByParent(manager).get("parent-session-new")).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("restores completed task state when resume prompt is skipped by an existing reservation", async () => {
|
||||
//#given
|
||||
let promptCallCount = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCallCount += 1
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "session-reserved-resume",
|
||||
source: "test-existing-reservation",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 1000,
|
||||
input: {
|
||||
path: { id: "session-reserved-resume" },
|
||||
body: { parts: [] },
|
||||
},
|
||||
})
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-reserved-resume-skip",
|
||||
sessionId: "session-reserved-resume",
|
||||
parentSessionId: "parent-session-original",
|
||||
parentMessageId: "msg-original",
|
||||
description: "completed task",
|
||||
prompt: "original prompt",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(Date.now() - 1000),
|
||||
completedAt: new Date(),
|
||||
concurrencyGroup: "explore",
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "session-reserved-resume",
|
||||
prompt: "continue",
|
||||
parentSessionId: "parent-session-new",
|
||||
parentMessageId: "msg-new",
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(promptCallCount).toBe(1)
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.parentSessionId).toBe("parent-session-original")
|
||||
expect(task.parentMessageId).toBe("msg-original")
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
expect(getConcurrencyManager(manager).getCount("explore")).toBe(0)
|
||||
expect(getPendingByParent(manager).get("parent-session-new")).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.resume model persistence", () => {
|
||||
let manager: BackgroundManager
|
||||
let promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }>
|
||||
@@ -4938,6 +5069,105 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("terminates task when agent-not-found arrives as async session.error after promptAsync accept", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
mockVerifySessionExists(manager, true)
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
const concurrencyKey = "missing-agent"
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
const task = createMockTask({
|
||||
id: "task-session-error-agent-not-found",
|
||||
sessionId: "ses-agent-not-found",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "msg-agent-not-found",
|
||||
description: "task with missing agent",
|
||||
agent: "missing-agent",
|
||||
status: "running",
|
||||
concurrencyKey,
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: task.sessionId,
|
||||
error: {
|
||||
name: "AgentNotFoundError",
|
||||
message: "Agent not found: missing-agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("interrupt")
|
||||
expect(task.error).toBe("Agent \"missing-agent\" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.")
|
||||
expect(task.completedAt).toBeInstanceOf(Date)
|
||||
expect(task.concurrencyKey).toBeUndefined()
|
||||
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
|
||||
expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined()
|
||||
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("requeues dispatched parent wake when the wake prompt fails through session.error", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "parent-session-wake": { type: "idle" } } }),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const managerInternals = cast<{
|
||||
queuePendingParentWake: (
|
||||
sessionID: string,
|
||||
notification: string,
|
||||
promptContext: Record<string, unknown>,
|
||||
shouldReply: boolean,
|
||||
delayMs?: number,
|
||||
) => void
|
||||
flushPendingParentWake: (sessionID: string) => Promise<void>
|
||||
}>(manager)
|
||||
managerInternals.queuePendingParentWake(
|
||||
"parent-session-wake",
|
||||
"<system-reminder>done</system-reminder>",
|
||||
{ agent: "sisyphus" },
|
||||
true,
|
||||
0,
|
||||
)
|
||||
|
||||
//#when
|
||||
await managerInternals.flushPendingParentWake("parent-session-wake")
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "parent-session-wake",
|
||||
error: { name: "UnknownError", message: "wake prompt failed" },
|
||||
},
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(getDispatchedParentWakes(manager).has("parent-session-wake")).toBe(false)
|
||||
expect(getPendingParentWakes(manager).get("parent-session-wake")?.notifications).toEqual([
|
||||
"<system-reminder>done</system-reminder>",
|
||||
])
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("terminates task on session.error when session is gone", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
isSessionActive as isOpenCodeSessionActive,
|
||||
settleAfterSessionIdle,
|
||||
} from "../../hooks/shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||
import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
|
||||
import {
|
||||
findNearestMessageExcludingCompaction,
|
||||
resolvePromptContextFromSessionMessages,
|
||||
@@ -110,6 +110,21 @@ type PendingParentWake = {
|
||||
shouldReply: boolean
|
||||
}
|
||||
|
||||
type ResumeTaskSnapshot = {
|
||||
status: BackgroundTask["status"]
|
||||
completedAt?: Date
|
||||
error?: string
|
||||
startedAt?: Date
|
||||
progress?: BackgroundTask["progress"]
|
||||
parentSessionId: string
|
||||
parentMessageId: string
|
||||
parentModel?: BackgroundTask["parentModel"]
|
||||
parentAgent?: string
|
||||
parentTools?: Record<string, boolean>
|
||||
concurrencyKey?: string
|
||||
concurrencyGroup?: string
|
||||
}
|
||||
|
||||
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
|
||||
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
|
||||
|
||||
@@ -196,6 +211,7 @@ export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => P
|
||||
|
||||
const MAX_TASK_REMOVAL_RESCHEDULES = 6
|
||||
const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100
|
||||
const PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS = 5_000
|
||||
|
||||
export interface BackgroundManagerConfig {
|
||||
pluginContext: PluginInput
|
||||
@@ -236,6 +252,8 @@ export class BackgroundManager {
|
||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private observedOutputSessions: Set<string> = new Set()
|
||||
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
|
||||
private rootDescendantCounts: Map<string, number>
|
||||
@@ -422,6 +440,60 @@ export class BackgroundManager {
|
||||
this.tasksByParentSession.set(parentSessionID, taskIDs)
|
||||
}
|
||||
|
||||
private captureResumeTaskSnapshot(task: BackgroundTask): ResumeTaskSnapshot {
|
||||
return {
|
||||
status: task.status,
|
||||
completedAt: task.completedAt,
|
||||
error: task.error,
|
||||
startedAt: task.startedAt,
|
||||
progress: task.progress,
|
||||
parentSessionId: task.parentSessionId,
|
||||
parentMessageId: task.parentMessageId,
|
||||
parentModel: task.parentModel,
|
||||
parentAgent: task.parentAgent,
|
||||
parentTools: task.parentTools,
|
||||
concurrencyKey: task.concurrencyKey,
|
||||
concurrencyGroup: task.concurrencyGroup,
|
||||
}
|
||||
}
|
||||
|
||||
private restoreTaskAfterSkippedResume(
|
||||
task: BackgroundTask,
|
||||
snapshot: ResumeTaskSnapshot,
|
||||
skippedStatus: Exclude<PromptAsyncGateResult["status"], "dispatched" | "failed">,
|
||||
): void {
|
||||
log("[background-agent] Restoring task after skipped resume prompt:", {
|
||||
taskId: task.id,
|
||||
sessionID: task.sessionId,
|
||||
skippedStatus,
|
||||
})
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
}
|
||||
|
||||
task.status = snapshot.status
|
||||
task.completedAt = snapshot.completedAt
|
||||
task.error = snapshot.error
|
||||
task.startedAt = snapshot.startedAt
|
||||
task.progress = snapshot.progress
|
||||
task.parentMessageId = snapshot.parentMessageId
|
||||
task.parentModel = snapshot.parentModel
|
||||
task.parentAgent = snapshot.parentAgent
|
||||
task.parentTools = snapshot.parentTools
|
||||
task.concurrencyKey = snapshot.concurrencyKey
|
||||
task.concurrencyGroup = snapshot.concurrencyGroup
|
||||
this.updateTaskParent(task, snapshot.parentSessionId)
|
||||
|
||||
removeTaskToastTracking(task.id)
|
||||
if (task.status !== "running" && task.status !== "pending") {
|
||||
this.scheduleTaskRemoval(task.id)
|
||||
}
|
||||
this.updateBackgroundTaskMarker(task.parentSessionId)
|
||||
}
|
||||
|
||||
private removeTaskFromParentIndex(taskID: string, parentSessionID: string | undefined): void {
|
||||
if (!parentSessionID) {
|
||||
return
|
||||
@@ -1083,6 +1155,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const resumeSnapshot = this.captureResumeTaskSnapshot(existingTask)
|
||||
const completionTimer = this.completionTimers.get(existingTask.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
@@ -1166,7 +1239,6 @@ The fallback retry session is now created and can be inspected directly.
|
||||
sessionID: existingTask.sessionId,
|
||||
source: "background-agent-resume",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
input: {
|
||||
path: { id: existingTask.sessionId },
|
||||
body: {
|
||||
@@ -1199,6 +1271,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
sessionID: existingTask.sessionId,
|
||||
status: promptResult.status,
|
||||
})
|
||||
this.restoreTaskAfterSkippedResume(existingTask, resumeSnapshot, promptResult.status)
|
||||
}
|
||||
}).catch(async (error) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
@@ -1276,6 +1349,60 @@ The fallback retry session is now created and can be inspected directly.
|
||||
this.observedOutputSessions.add(sessionID)
|
||||
}
|
||||
|
||||
private cloneParentWake(wake: PendingParentWake): PendingParentWake {
|
||||
return {
|
||||
promptContext: {
|
||||
...wake.promptContext,
|
||||
...(wake.promptContext.model ? { model: { ...wake.promptContext.model } } : {}),
|
||||
...(wake.promptContext.tools ? { tools: { ...wake.promptContext.tools } } : {}),
|
||||
},
|
||||
notifications: [...wake.notifications],
|
||||
shouldReply: wake.shouldReply,
|
||||
}
|
||||
}
|
||||
|
||||
private clearDispatchedParentWake(sessionID: string): void {
|
||||
const timer = this.dispatchedParentWakeTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
}
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}
|
||||
|
||||
private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void {
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
this.dispatchedParentWakes.set(sessionID, this.cloneParentWake(wake))
|
||||
const timer = setTimeout(() => {
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}, PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS)
|
||||
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
private requeueDispatchedParentWake(sessionID: string, reason: string): boolean {
|
||||
const wake = this.dispatchedParentWakes.get(sessionID)
|
||||
if (!wake) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...wake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || wake.shouldReply
|
||||
pendingWake.promptContext = wake.promptContext
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, this.cloneParentWake(wake))
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Requeued dispatched parent wake after prompt failure:", {
|
||||
sessionID,
|
||||
reason,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private clearSessionOutputObserved(sessionID: string): void {
|
||||
this.observedOutputSessions.delete(sessionID)
|
||||
}
|
||||
@@ -1307,6 +1434,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = (info as Record<string, unknown>)["role"]
|
||||
if (!sessionID) return
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
|
||||
if (role === "tool") {
|
||||
this.markSessionOutputObserved(sessionID)
|
||||
@@ -1339,6 +1467,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const partInfo = resolveMessagePartInfo(props)
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID) return
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
|
||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||
if (!resolved?.isCurrent) return
|
||||
@@ -1469,7 +1598,10 @@ The fallback retry session is now created and can be inspected directly.
|
||||
if (!sessionID) return
|
||||
|
||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||
if (!resolved?.isCurrent) return
|
||||
if (!resolved?.isCurrent) {
|
||||
this.requeueDispatchedParentWake(sessionID, "session.error")
|
||||
return
|
||||
}
|
||||
|
||||
const { task } = resolved
|
||||
if (task.status !== "running") return
|
||||
@@ -1581,6 +1713,67 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
}
|
||||
|
||||
private async interruptTaskFromAsyncPromptFailure(
|
||||
task: BackgroundTask,
|
||||
errorMessage: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (task.currentAttemptID) {
|
||||
finalizeAttempt(task, task.currentAttemptID, "interrupt", errorMessage)
|
||||
} else {
|
||||
task.status = "interrupt"
|
||||
task.error = errorMessage
|
||||
task.completedAt = new Date()
|
||||
}
|
||||
|
||||
if (task.rootSessionId) {
|
||||
this.unregisterRootDescendant(task.rootSessionId)
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionId, {
|
||||
id: task.id,
|
||||
sessionID: task.sessionId,
|
||||
agent: task.agent,
|
||||
description: task.description,
|
||||
status: "interrupt",
|
||||
category: task.category,
|
||||
startedAt: task.startedAt,
|
||||
completedAt: task.completedAt,
|
||||
})
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
const completionTimer = this.completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
this.completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
removeTaskToastTracking(task.id)
|
||||
this.scheduleTaskRemoval(task.id)
|
||||
|
||||
if (task.sessionId) {
|
||||
SessionCategoryRegistry.remove(task.sessionId)
|
||||
await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`)
|
||||
}
|
||||
|
||||
this.updateBackgroundTaskMarker(task.parentSessionId)
|
||||
this.markForNotification(task)
|
||||
this.enqueueNotificationForParent(task.parentSessionId, () => this.notifyParentSession(task)).catch(err => {
|
||||
log("[background-agent] Failed to notify on async prompt failure:", { taskId: task.id, error: err })
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSessionErrorEvent(args: {
|
||||
task: BackgroundTask
|
||||
errorInfo: { name?: string; message?: string }
|
||||
@@ -1596,13 +1789,16 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
}
|
||||
|
||||
// Agent-not-found errors are handled by the prompt catch block with agent fallback.
|
||||
// Do not also trigger model fallback retry — that would race with the agent retry.
|
||||
if (isAgentNotFoundError({ message: errorInfo.message } as Error)) {
|
||||
log("[background-agent] Skipping session.error fallback for agent-not-found (handled by prompt catch)", {
|
||||
if (isAgentNotFoundError({ message: errorInfo.message ?? "" })) {
|
||||
log("[background-agent] Handling async agent-not-found session.error:", {
|
||||
taskId: task.id,
|
||||
errorMessage: errorInfo.message?.slice(0, 100),
|
||||
})
|
||||
await this.interruptTaskFromAsyncPromptFailure(
|
||||
task,
|
||||
`Agent "${task.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`,
|
||||
"agent-not-found session.error",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2383,6 +2579,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
return
|
||||
}
|
||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||
this.trackDispatchedParentWake(sessionID, latestWake)
|
||||
} catch (error) {
|
||||
this.queuePendingNotification(sessionID, notificationContent)
|
||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||
@@ -2739,6 +2936,11 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
this.pendingParentWakeTimers.clear()
|
||||
|
||||
for (const timer of this.dispatchedParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.dispatchedParentWakeTimers.clear()
|
||||
|
||||
for (const sessionID of trackedSessionIDs) {
|
||||
subagentSessions.delete(sessionID)
|
||||
SessionCategoryRegistry.remove(sessionID)
|
||||
@@ -2751,6 +2953,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.pendingParentWakes.clear()
|
||||
this.dispatchedParentWakes.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.rootDescendantCounts.clear()
|
||||
this.queuesByKey.clear()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createRuntimeState, saveRuntimeState } from "../team-state-store/store"
|
||||
import { clearTeamSessionRegistry, registerTeamSession } from "../team-session-registry"
|
||||
import type { Message } from "../types"
|
||||
import { MessageSchema } from "../types"
|
||||
import { createTeamIdleWakeHint } from "../../../hooks/team-session-events/team-idle-wake-hint"
|
||||
import { createTeamSendMessageTool } from "./messaging"
|
||||
|
||||
type PromptAsyncCall = {
|
||||
@@ -310,6 +311,70 @@ describe("createTeamSendMessageTool", () => {
|
||||
expect(unread[0]?.body).toBe("ping while busy")
|
||||
})
|
||||
|
||||
test("#given rapid live deliveries to one recipient #when the first prompt just dispatched #then the next message stays unread instead of starting another reply", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
const { client, calls } = createRecordingClient()
|
||||
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
||||
|
||||
// when
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "first ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "second ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
|
||||
// then
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0]?.body).toBe("second ping")
|
||||
})
|
||||
|
||||
test("#given live delivery left a rapid message unread #when recipient idle wake fires immediately #then the wake hint does not start a second reply", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
const { client, calls } = createRecordingClient()
|
||||
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
||||
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "first ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
await liveTool.execute({
|
||||
teamRunId: fixture.teamRunId,
|
||||
to: "m2",
|
||||
body: "second ping",
|
||||
}, fixture.toolContext(fixture.memberOneSessionId))
|
||||
|
||||
const wakeHint = createTeamIdleWakeHint({
|
||||
directory: resolveBaseDir(fixture.config),
|
||||
client,
|
||||
}, fixture.config, { idleSettleMs: 0 })
|
||||
|
||||
// when
|
||||
await wakeHint({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: fixture.memberTwoSessionId },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||
expect(unread).toHaveLength(1)
|
||||
expect(unread[0]?.body).toBe("second ping")
|
||||
})
|
||||
|
||||
test("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => {
|
||||
// given
|
||||
const fixture = await createTeamFixture()
|
||||
|
||||
Reference in New Issue
Block a user