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:
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user