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
const client = {
session: {
messages: async () => [
{
info: {
agent: "hephaestus",
model: {
providerID: "openai",
modelID: "gpt-5",
variant: "xhigh",
},
tools: { bash: "allow", edit: "deny" },
},
},
],
abort: async () => ({}),
},
}
@@ -749,7 +762,12 @@ describe("BackgroundManager retry observability", () => {
}
const [sessionID, notification, promptContext, shouldReply] = retryingCall
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(notification).toContain("[BACKGROUND TASK RETRYING]")
expect(notification).toContain("ses_retry_visibility")
@@ -757,6 +775,123 @@ describe("BackgroundManager retry observability", () => {
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 () => {
//#given
const queuePendingParentWake = mock(() => {})
@@ -764,6 +899,19 @@ describe("BackgroundManager retry observability", () => {
session: {
get: async () => ({ data: { directory: tmpdir() } }),
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 () => ({}),
},
}
@@ -837,12 +985,18 @@ describe("BackgroundManager retry observability", () => {
}>(manager)).startTask(item)
//#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,
).map((call) => call[1])
const retryReadyNotification = notifications.find((notification) => notification.includes("[BACKGROUND TASK RETRY SESSION READY]"))
).find((call) => call[1].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`
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("ses_retry_created")
expect(retryReadyNotification).toContain(expectedRetryLink)
+81 -70
View File
@@ -818,6 +818,7 @@ export class BackgroundManager {
? `\n- Error: ${failedError}`
: ""
const retryModel = formatAttemptModelSummary(boundAttempt) ?? task.retryNotification.nextModel
const parentPromptContext = await this.resolveParentWakePromptContext(task)
this.queuePendingParentWake(
task.parentSessionId,
`<system-reminder>
@@ -830,7 +831,7 @@ export class BackgroundManager {
The fallback retry session is now created and can be inspected directly.
</system-reminder>`,
{},
parentPromptContext,
false,
PENDING_PARENT_WAKE_DEBOUNCE_MS,
)
@@ -1920,6 +1921,7 @@ The fallback retry session is now created and can be inspected directly.
source: string,
): Promise<boolean> {
const previousSessionID = task.sessionId
let retryingNotification: string | undefined
const result = tryFallbackRetry({
task,
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 failedErrorLine = previousAttempt?.error ? `\n- Error: ${previousAttempt.error}` : ""
const nextModel = formatAttemptModelSummary(currentAttempt)
this.queuePendingParentWake(
task.parentSessionId,
`<system-reminder>
retryingNotification = `<system-reminder>
[BACKGROUND TASK RETRYING]
**ID:** \`${task.id}\`
**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.
</system-reminder>`,
{},
false,
PENDING_PARENT_WAKE_DEBOUNCE_MS,
)
</system-reminder>`
},
})
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) {
this.clearSessionOutputObserved(previousSessionID)
this.clearSessionTodoObservation(previousSessionID)
@@ -2412,76 +2418,18 @@ The task was re-queued on a fallback model after a retryable failure.
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) {
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 }
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)
const parentPromptContext = await this.resolveParentWakePromptContext(task)
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedModel: model,
resolvedAgent: parentPromptContext.agent,
resolvedModel: parentPromptContext.model,
})
const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
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)
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> {
return isOpenCodeSessionActive(this.client, sessionID)
}
@@ -6,6 +6,10 @@ type PromptAsyncCall = {
path: { id: string }
body: {
noReply?: boolean
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
parts?: unknown[]
}
query?: {
@@ -40,9 +44,7 @@ function createNotifier(args: {
},
abort: async () => ({ data: {} }),
},
} as unknown as Parameters<typeof ParentWakeNotifier>[0] extends never
? never
: ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
} as unknown as ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
const notifier = new ParentWakeNotifier(
{
@@ -139,6 +141,49 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
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 () => {
// given
const { notifier, promptAsyncCalls } = createNotifier({