fix(prompt): treat post-dispatch failures as accepted
This commit is contained in:
@@ -630,6 +630,63 @@ describe("BackgroundManager prompt rejection fallback routing", () => {
|
||||
expect(storedTask?.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("keeps launch running when promptAsync returns ambiguous EOF after dispatch", async () => {
|
||||
//#given
|
||||
let abortCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: tmpdir() } }),
|
||||
create: async () => ({ data: { id: "ses_launch_ambiguous" } }),
|
||||
promptAsync: async () => {
|
||||
throw new Error("JSON Parse error: Unexpected EOF")
|
||||
},
|
||||
abort: async () => {
|
||||
abortCalls += 1
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
;(cast<{
|
||||
reserveSubagentSpawn: () => Promise<{
|
||||
spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
|
||||
descendantCount: number
|
||||
commit: () => number
|
||||
rollback: () => void
|
||||
}>
|
||||
}>(manager)).reserveSubagentSpawn = async () => ({
|
||||
spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
|
||||
descendantCount: 1,
|
||||
commit: () => 1,
|
||||
rollback: () => {},
|
||||
})
|
||||
const retried: string[] = []
|
||||
;(cast<{
|
||||
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
|
||||
}>(manager)).tryFallbackRetry = async (_task, _errorInfo, source) => {
|
||||
retried.push(source)
|
||||
return true
|
||||
}
|
||||
|
||||
//#when
|
||||
const launchedTask = await manager.launch({
|
||||
description: "ambiguous launch",
|
||||
prompt: "say hi",
|
||||
agent: "sisyphus-junior",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
const storedTask = getTaskMap(manager).get(launchedTask.id)
|
||||
expect(retried).toEqual([])
|
||||
expect(abortCalls).toBe(0)
|
||||
expect(storedTask?.status).toBe("running")
|
||||
})
|
||||
|
||||
test("routes resume-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
|
||||
//#given
|
||||
const promptError = {
|
||||
@@ -691,6 +748,61 @@ describe("BackgroundManager prompt rejection fallback routing", () => {
|
||||
})
|
||||
expect(storedTask?.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("keeps resumed task running when promptAsync returns ambiguous EOF after dispatch", async () => {
|
||||
//#given
|
||||
let abortCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
throw new Error("JSON Parse error: Unexpected EOF")
|
||||
},
|
||||
abort: async () => {
|
||||
abortCalls += 1
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
const task: BackgroundTask = {
|
||||
id: "bg_resume_ambiguous",
|
||||
sessionId: "ses_resume_ambiguous",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
description: "resume ambiguous test",
|
||||
prompt: "say hi",
|
||||
agent: "sisyphus-junior",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
|
||||
concurrencyGroup: "anthropic/claude-haiku-4-5",
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
const retried: string[] = []
|
||||
;(cast<{
|
||||
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
|
||||
}>(manager)).tryFallbackRetry = async (_retryTask, _errorInfo, source) => {
|
||||
retried.push(source)
|
||||
return true
|
||||
}
|
||||
|
||||
//#when
|
||||
await manager.resume({
|
||||
sessionId: "ses_resume_ambiguous",
|
||||
prompt: "continue",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message-2",
|
||||
})
|
||||
await flushBackgroundNotifications()
|
||||
|
||||
//#then
|
||||
expect(retried).toEqual([])
|
||||
expect(abortCalls).toBe(0)
|
||||
expect(task.status).toBe("running")
|
||||
expect(task.completedAt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager retry observability", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/s
|
||||
import {
|
||||
createInternalAgentTextPart,
|
||||
getAgentToolRestrictions,
|
||||
isAmbiguousPostDispatchPromptFailure,
|
||||
log,
|
||||
messagesInDirectory,
|
||||
normalizePromptTools,
|
||||
@@ -1334,6 +1335,14 @@ The fallback retry session is now created and can be inspected directly.
|
||||
},
|
||||
}).then((promptResult) => {
|
||||
if (promptResult.status === "failed") {
|
||||
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
|
||||
log("[background-agent] resume prompt may have been accepted before ambiguous failure; continuing to poll", {
|
||||
taskId: existingTask.id,
|
||||
sessionID: existingTask.sessionId,
|
||||
error: promptResult.error instanceof Error ? promptResult.error.message : String(promptResult.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status === "queued") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveRegisteredAgentName } from "../claude-code-session-state"
|
||||
import {
|
||||
createInternalAgentTextPart,
|
||||
isAmbiguousPromptDispatchFailure,
|
||||
isAmbiguousPostDispatchPromptFailure,
|
||||
isSyntheticOrInternalUserMessage,
|
||||
log,
|
||||
messagesInDirectory,
|
||||
@@ -209,6 +209,18 @@ export class ParentWakeNotifier {
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
|
||||
const dispatchedWake = this.cloneParentWake(latestWake)
|
||||
dispatchedWake.dispatchedAt = dispatchStartedAt
|
||||
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, dispatchedWake)) {
|
||||
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
|
||||
log("[background-agent] Treated failed parent wake prompt as accepted after observing session history:", {
|
||||
sessionID,
|
||||
error: promptResult.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status === "reserved" && promptResult.reservedBy === "background-agent-parent-wake") {
|
||||
@@ -229,18 +241,6 @@ export class ParentWakeNotifier {
|
||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
|
||||
} catch (error) {
|
||||
if (isAmbiguousPromptDispatchFailure(error)) {
|
||||
const dispatchedWake = this.cloneParentWake(latestWake)
|
||||
dispatchedWake.dispatchedAt = dispatchStartedAt
|
||||
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, dispatchedWake)) {
|
||||
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
|
||||
log("[background-agent] Treated failed parent wake prompt as accepted after observing session history:", {
|
||||
sessionID,
|
||||
error,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
this.requeueWake(sessionID, latestWake)
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||
|
||||
@@ -41,6 +41,33 @@ describe("background-agent session routing", () => {
|
||||
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
|
||||
})
|
||||
|
||||
test("#given routed promptAsync reports ambiguous EOF after dispatch #when the background route handles it #then it treats the prompt as accepted", async () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => {
|
||||
throw new Error("JSON Parse error: Unexpected EOF")
|
||||
})
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync,
|
||||
},
|
||||
}
|
||||
const args = {
|
||||
path: { id: "ses_background_route_ambiguous_eof" },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncInDirectory(
|
||||
unsafeTestValue(client),
|
||||
unsafeTestValue(args),
|
||||
"/workspace/project",
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result).toBeUndefined()
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("#given a background retry prompt just dispatched #when the same child session is prompted again immediately #then retry routing defers instead of enqueueing", async () => {
|
||||
// given
|
||||
const promptAsync = mock(async () => undefined)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { isAmbiguousPostDispatchPromptFailure, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../shared/prompt-async-gate"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
@@ -45,6 +45,9 @@ export function promptAsyncInDirectory(
|
||||
queueBehavior: "defer",
|
||||
}).then((result) => {
|
||||
if (result.status === "failed") {
|
||||
if (isAmbiguousPostDispatchPromptFailure(result)) {
|
||||
return undefined
|
||||
}
|
||||
throw result.error
|
||||
}
|
||||
if (!isInternalPromptDispatchAccepted(result)) {
|
||||
|
||||
Reference in New Issue
Block a user