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:
YeonGyu-Kim
2026-05-15 13:19:10 +09:00
parent 05189700fb
commit c2aa180e7e
26 changed files with 893 additions and 48 deletions
@@ -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()
+210 -7
View File
@@ -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()