fix(background-agent): preserve parent agent on retry wakes

Background fallback retry notifications were queued as bare internal user messages, so OpenCode could treat the notification as a new default-agent turn. Reuse the same parent prompt context resolver used by completion notifications for retrying and retry-ready wakes, and pin regression coverage for Hephaestus parent sessions plus missing-context fallbacks.
This commit is contained in:
YeonGyu-Kim
2026-05-18 14:05:01 +09:00
parent 3b50b7c8ba
commit d44cd1c1a0
3 changed files with 287 additions and 77 deletions
+158 -4
View File
@@ -697,6 +697,19 @@ describe("BackgroundManager retry observability", () => {
//#given //#given
const client = { const client = {
session: { session: {
messages: async () => [
{
info: {
agent: "hephaestus",
model: {
providerID: "openai",
modelID: "gpt-5",
variant: "xhigh",
},
tools: { bash: "allow", edit: "deny" },
},
},
],
abort: async () => ({}), abort: async () => ({}),
}, },
} }
@@ -749,7 +762,12 @@ describe("BackgroundManager retry observability", () => {
} }
const [sessionID, notification, promptContext, shouldReply] = retryingCall const [sessionID, notification, promptContext, shouldReply] = retryingCall
expect(sessionID).toBe("parent-session") expect(sessionID).toBe("parent-session")
expect(promptContext).toEqual({}) expect(promptContext).toEqual({
agent: "hephaestus",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "xhigh",
tools: { bash: true, edit: false },
})
expect(shouldReply).toBe(false) expect(shouldReply).toBe(false)
expect(notification).toContain("[BACKGROUND TASK RETRYING]") expect(notification).toContain("[BACKGROUND TASK RETRYING]")
expect(notification).toContain("ses_retry_visibility") expect(notification).toContain("ses_retry_visibility")
@@ -757,6 +775,123 @@ describe("BackgroundManager retry observability", () => {
expect(notification).toContain("anthropic/claude-haiku-4.5") expect(notification).toContain("anthropic/claude-haiku-4.5")
}) })
test("falls back to task parent agent when retrying wake cannot load parent messages", async () => {
//#given
const client = {
session: {
messages: async () => {
throw new Error("parent messages unavailable")
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
const task = createMockTask({
id: "bg_retry_parent_agent_fallback",
parentSessionId: "parent-session-agent-fallback",
parentAgent: "hephaestus",
parentTools: { bash: true },
fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
attemptCount: 0,
status: "running",
attempts: [
{
attemptId: "att_retry_parent_agent_fallback",
attemptNumber: 1,
sessionId: "ses_retry_parent_agent_fallback",
providerId: "genai-proxy-openai",
modelId: "gpt-5.4-mini",
status: "running",
},
],
currentAttemptID: "att_retry_parent_agent_fallback",
})
getTaskMap(manager).set(task.id, task)
const queuePendingParentWake = mock(() => {})
;(cast<{
queuePendingParentWake: (
sessionId: string,
notification: string,
promptContext: Record<string, unknown>,
shouldReply: boolean,
delayMs?: number,
) => void
}>(manager)).queuePendingParentWake = queuePendingParentWake
//#when
await (cast<{
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
}>(manager)).tryFallbackRetry(task, {
name: "APIError",
message: "Forbidden: Selected provider is forbidden",
}, "promptAsync.launch")
//#then
const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
queuePendingParentWake.mock.calls,
)[0]
expect(retryingCall?.[2]).toEqual({
agent: "hephaestus",
tools: { bash: true },
})
})
test("does not invent a parent agent when retrying wake has no context source", async () => {
//#given
const client = {
session: {
messages: async () => {
throw new Error("parent messages unavailable")
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
const task = createMockTask({
id: "bg_retry_no_parent_context",
parentSessionId: "parent-session-no-context",
fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
attemptCount: 0,
status: "running",
attempts: [
{
attemptId: "att_retry_no_parent_context",
attemptNumber: 1,
sessionId: "ses_retry_no_parent_context",
providerId: "genai-proxy-openai",
modelId: "gpt-5.4-mini",
status: "running",
},
],
currentAttemptID: "att_retry_no_parent_context",
})
getTaskMap(manager).set(task.id, task)
const queuePendingParentWake = mock(() => {})
;(cast<{
queuePendingParentWake: (
sessionId: string,
notification: string,
promptContext: Record<string, unknown>,
shouldReply: boolean,
delayMs?: number,
) => void
}>(manager)).queuePendingParentWake = queuePendingParentWake
//#when
await (cast<{
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
}>(manager)).tryFallbackRetry(task, {
name: "APIError",
message: "Forbidden: Selected provider is forbidden",
}, "promptAsync.launch")
//#then
const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
queuePendingParentWake.mock.calls,
)[0]
expect(retryingCall?.[2]).toEqual({})
})
test("queues a second parent-visible notification once the retry session ID is created", async () => { test("queues a second parent-visible notification once the retry session ID is created", async () => {
//#given //#given
const queuePendingParentWake = mock(() => {}) const queuePendingParentWake = mock(() => {})
@@ -764,6 +899,19 @@ describe("BackgroundManager retry observability", () => {
session: { session: {
get: async () => ({ data: { directory: tmpdir() } }), get: async () => ({ data: { directory: tmpdir() } }),
create: async () => ({ data: { id: "ses_retry_created" } }), create: async () => ({ data: { id: "ses_retry_created" } }),
messages: async () => [
{
info: {
agent: "hephaestus",
model: {
providerID: "openai",
modelID: "gpt-5",
variant: "xhigh",
},
tools: { bash: "allow", edit: "deny" },
},
},
],
promptAsync: async () => ({}), promptAsync: async () => ({}),
}, },
} }
@@ -837,12 +985,18 @@ describe("BackgroundManager retry observability", () => {
}>(manager)).startTask(item) }>(manager)).startTask(item)
//#then //#then
const notifications = cast<Array<[string, string, Record<string, unknown>, boolean, number | undefined]>>( const retryReadyCall = cast<Array<[string, string, Record<string, unknown>, boolean, number | undefined]>>(
queuePendingParentWake.mock.calls, queuePendingParentWake.mock.calls,
).map((call) => call[1]) ).find((call) => call[1].includes("[BACKGROUND TASK RETRY SESSION READY]"))
const retryReadyNotification = notifications.find((notification) => notification.includes("[BACKGROUND TASK RETRY SESSION READY]")) const retryReadyNotification = retryReadyCall?.[1]
const expectedRetryLink = `http://127.0.0.1:4096/${Buffer.from(tmpdir()).toString("base64url")}/session/ses_retry_created` const expectedRetryLink = `http://127.0.0.1:4096/${Buffer.from(tmpdir()).toString("base64url")}/session/ses_retry_created`
expect(retryReadyNotification).toBeDefined() expect(retryReadyNotification).toBeDefined()
expect(retryReadyCall?.[2]).toEqual({
agent: "hephaestus",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "xhigh",
tools: { bash: true, edit: false },
})
expect(retryReadyNotification).toContain("**Retry attempt:** 2") expect(retryReadyNotification).toContain("**Retry attempt:** 2")
expect(retryReadyNotification).toContain("ses_retry_created") expect(retryReadyNotification).toContain("ses_retry_created")
expect(retryReadyNotification).toContain(expectedRetryLink) expect(retryReadyNotification).toContain(expectedRetryLink)
+81 -70
View File
@@ -818,6 +818,7 @@ export class BackgroundManager {
? `\n- Error: ${failedError}` ? `\n- Error: ${failedError}`
: "" : ""
const retryModel = formatAttemptModelSummary(boundAttempt) ?? task.retryNotification.nextModel const retryModel = formatAttemptModelSummary(boundAttempt) ?? task.retryNotification.nextModel
const parentPromptContext = await this.resolveParentWakePromptContext(task)
this.queuePendingParentWake( this.queuePendingParentWake(
task.parentSessionId, task.parentSessionId,
`<system-reminder> `<system-reminder>
@@ -830,7 +831,7 @@ export class BackgroundManager {
The fallback retry session is now created and can be inspected directly. The fallback retry session is now created and can be inspected directly.
</system-reminder>`, </system-reminder>`,
{}, parentPromptContext,
false, false,
PENDING_PARENT_WAKE_DEBOUNCE_MS, PENDING_PARENT_WAKE_DEBOUNCE_MS,
) )
@@ -1920,6 +1921,7 @@ The fallback retry session is now created and can be inspected directly.
source: string, source: string,
): Promise<boolean> { ): Promise<boolean> {
const previousSessionID = task.sessionId const previousSessionID = task.sessionId
let retryingNotification: string | undefined
const result = tryFallbackRetry({ const result = tryFallbackRetry({
task, task,
errorInfo, errorInfo,
@@ -1938,22 +1940,26 @@ The fallback retry session is now created and can be inspected directly.
const failedModelLine = failedModel ? `\n- Failed model: \`${failedModel}\`` : "" const failedModelLine = failedModel ? `\n- Failed model: \`${failedModel}\`` : ""
const failedErrorLine = previousAttempt?.error ? `\n- Error: ${previousAttempt.error}` : "" const failedErrorLine = previousAttempt?.error ? `\n- Error: ${previousAttempt.error}` : ""
const nextModel = formatAttemptModelSummary(currentAttempt) const nextModel = formatAttemptModelSummary(currentAttempt)
this.queuePendingParentWake( retryingNotification = `<system-reminder>
task.parentSessionId,
`<system-reminder>
[BACKGROUND TASK RETRYING] [BACKGROUND TASK RETRYING]
**ID:** \`${task.id}\` **ID:** \`${task.id}\`
**Description:** ${task.description}${sourceText}${failedSessionLine}${failedModelLine}${failedErrorLine}${nextModel ? `\n- Next model: \`${nextModel}\`` : ""} **Description:** ${task.description}${sourceText}${failedSessionLine}${failedModelLine}${failedErrorLine}${nextModel ? `\n- Next model: \`${nextModel}\`` : ""}
The task was re-queued on a fallback model after a retryable failure. The task was re-queued on a fallback model after a retryable failure.
</system-reminder>`, </system-reminder>`
{},
false,
PENDING_PARENT_WAKE_DEBOUNCE_MS,
)
}, },
}) })
const retried = await result const retried = await result
if (retried && retryingNotification) {
const parentPromptContext = await this.resolveParentWakePromptContext(task)
this.queuePendingParentWake(
task.parentSessionId,
retryingNotification,
parentPromptContext,
false,
PENDING_PARENT_WAKE_DEBOUNCE_MS,
)
}
if (retried && previousSessionID) { if (retried && previousSessionID) {
this.clearSessionOutputObserved(previousSessionID) this.clearSessionOutputObserved(previousSessionID)
this.clearSessionTodoObservation(previousSessionID) this.clearSessionTodoObservation(previousSessionID)
@@ -2412,76 +2418,18 @@ The task was re-queued on a fallback model after a retryable failure.
completedTasks, completedTasks,
}) })
let agent: string | undefined = task.parentAgent
let model: { providerID: string; modelID: string } | undefined
let tools: Record<string, boolean> | undefined = task.parentTools
let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
if (this.enableParentSessionNotifications) { if (this.enableParentSessionNotifications) {
try { const parentPromptContext = await this.resolveParentWakePromptContext(task)
const messagesResp = await messagesInDirectory(this.client, {
path: { id: task.parentSessionId },
}, this.directory)
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: {
agent?: string
model?: { providerID: string; modelID: string }
modelID?: string
providerID?: string
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}>)
promptContext = resolvePromptContextFromSessionMessages(
messages,
task.parentSessionId,
)
const normalizedTools = isRecord(promptContext?.tools)
? normalizePromptTools(promptContext.tools)
: undefined
if (promptContext?.agent || promptContext?.model || normalizedTools) {
agent = promptContext?.agent ?? task.parentAgent
model = promptContext?.model?.providerID && promptContext.model.modelID
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
: undefined
tools = normalizedTools ?? tools
}
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
})
}
const messageDir = join(MESSAGE_STORAGE, task.parentSessionId)
const currentMessage = messageDir
? findNearestMessageExcludingCompaction(messageDir, task.parentSessionId)
: null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
tools = normalizePromptTools(currentMessage?.tools) ?? tools
}
const resolvedTools = resolveInheritedPromptTools(task.parentSessionId, tools)
log("[background-agent] notifyParentSession context:", { log("[background-agent] notifyParentSession context:", {
taskId: task.id, taskId: task.id,
resolvedAgent: agent, resolvedAgent: parentPromptContext.agent,
resolvedModel: model, resolvedModel: parentPromptContext.model,
}) })
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt" const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
const shouldReply = allComplete || isTaskFailure const shouldReply = allComplete || isTaskFailure
const variant = promptContext?.model?.variant
const parentPromptContext: ParentWakePromptContext = {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
}
const shouldDeferNotification = await this.isSessionActive(task.parentSessionId) const shouldDeferNotification = await this.isSessionActive(task.parentSessionId)
if (shouldDeferNotification) { if (shouldDeferNotification) {
@@ -2519,6 +2467,69 @@ The task was re-queued on a fallback model after a retryable failure.
} }
} }
private async resolveParentWakePromptContext(task: BackgroundTask): Promise<ParentWakePromptContext> {
let agent: string | undefined = task.parentAgent
let model: { providerID: string; modelID: string } | undefined
let tools: Record<string, boolean> | undefined = task.parentTools
let variant: string | undefined
try {
const messagesResp = await messagesInDirectory(this.client, {
path: { id: task.parentSessionId },
}, this.directory)
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: {
agent?: string
model?: { providerID: string; modelID: string; variant?: string }
modelID?: string
providerID?: string
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}>)
const promptContext = resolvePromptContextFromSessionMessages(
messages,
task.parentSessionId,
)
const normalizedTools = isRecord(promptContext?.tools)
? normalizePromptTools(promptContext.tools)
: undefined
if (promptContext?.agent || promptContext?.model || normalizedTools) {
agent = promptContext?.agent ?? task.parentAgent
model = promptContext?.model?.providerID && promptContext.model.modelID
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
: undefined
variant = promptContext?.model?.variant
tools = normalizedTools ?? tools
}
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
})
}
const messageDir = join(MESSAGE_STORAGE, task.parentSessionId)
const currentMessage = messageDir
? findNearestMessageExcludingCompaction(messageDir, task.parentSessionId)
: null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
variant = currentMessage?.model?.variant
tools = normalizePromptTools(currentMessage?.tools) ?? tools
}
const resolvedTools = resolveInheritedPromptTools(task.parentSessionId, tools)
return {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
}
}
private async isSessionActive(sessionID: string): Promise<boolean> { private async isSessionActive(sessionID: string): Promise<boolean> {
return isOpenCodeSessionActive(this.client, sessionID) return isOpenCodeSessionActive(this.client, sessionID)
} }
@@ -6,6 +6,10 @@ type PromptAsyncCall = {
path: { id: string } path: { id: string }
body: { body: {
noReply?: boolean noReply?: boolean
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
parts?: unknown[] parts?: unknown[]
} }
query?: { query?: {
@@ -40,9 +44,7 @@ function createNotifier(args: {
}, },
abort: async () => ({ data: {} }), abort: async () => ({ data: {} }),
}, },
} as unknown as Parameters<typeof ParentWakeNotifier>[0] extends never } as unknown as ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
? never
: ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
const notifier = new ParentWakeNotifier( const notifier = new ParentWakeNotifier(
{ {
@@ -139,6 +141,49 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
releaseAllPromptAsyncReservationsForTesting() releaseAllPromptAsyncReservationsForTesting()
}) })
test("#given pending wake has parent prompt context #when flushing #then promptAsync receives the context", async () => {
// given
const { notifier, promptAsyncCalls } = createNotifier({
sessionMessages: [
{
info: {
role: "assistant",
finish: "stop",
time: { created: Date.now() - 100 },
},
},
],
})
notifier.queuePendingParentWake(
"parent-context",
"task retrying",
{
agent: "hephaestus",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "xhigh",
tools: { bash: true, edit: false },
},
false,
)
// when
await notifier.flushPendingParentWake("parent-context")
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body).toMatchObject({
noReply: true,
agent: "hephaestus",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "xhigh",
tools: { bash: true, edit: false },
})
expect(promptAsyncCalls[0]?.body.parts).toHaveLength(1)
notifier.shutdown()
releaseAllPromptAsyncReservationsForTesting()
})
test("#given user message is older than the race window #when flushing pending wake #then dispatch proceeds", async () => { test("#given user message is older than the race window #when flushing pending wake #then dispatch proceeds", async () => {
// given // given
const { notifier, promptAsyncCalls } = createNotifier({ const { notifier, promptAsyncCalls } = createNotifier({