fix(background-agent): defer live tool-turn wakes
Require the latest assistant tool-wait turn itself to be stale before a shouldReply parent wake can bypass tool-call deferral. This prevents an all-complete background wake from forking a second parent prompt loop when OpenCode has repaired the tail to a synthetic user message. Tests: - bun test src/features/background-agent/parent-wake-user-message-race.test.ts src/features/background-agent/task-completion-cleanup.test.ts src/hooks/shared/prompt-async-gate.test.ts src/shared/prompt-async-route-audit.test.ts --bail - bun run typecheck - bun test
This commit is contained in:
@@ -386,7 +386,10 @@ export class ParentWakeNotifier {
|
||||
return status === "pending" || status === "running"
|
||||
}
|
||||
|
||||
private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean {
|
||||
private latestAssistantToolWaitState(messages: ParentWakeSessionMessage[]): {
|
||||
waiting: boolean
|
||||
createdAt?: number
|
||||
} {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
@@ -394,14 +397,17 @@ export class ParentWakeNotifier {
|
||||
}
|
||||
const role = this.getParentWakeMessageRole(message)
|
||||
if (role === "assistant") {
|
||||
return this.getParentWakeMessageFinish(message) === "tool-calls"
|
||||
const waiting = this.getParentWakeMessageFinish(message) === "tool-calls"
|
||||
|| message.parts?.some((part) => this.parentWakePartIsWaitingOnTool(part)) === true
|
||||
return waiting
|
||||
? { waiting: true, createdAt: this.getParentWakeMessageCreatedAt(message) }
|
||||
: { waiting: false }
|
||||
}
|
||||
if (role === "user") {
|
||||
return false
|
||||
return { waiting: false }
|
||||
}
|
||||
}
|
||||
return false
|
||||
return { waiting: false }
|
||||
}
|
||||
|
||||
private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean {
|
||||
@@ -470,13 +476,21 @@ export class ParentWakeNotifier {
|
||||
|
||||
private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise<boolean> {
|
||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||
if (!this.latestAssistantTurnIsWaitingOnTools(messages)) {
|
||||
const toolWaitState = this.latestAssistantToolWaitState(messages)
|
||||
if (!toolWaitState.waiting) {
|
||||
delete wake.toolCallDeferralStartedAt
|
||||
return false
|
||||
}
|
||||
const now = Date.now()
|
||||
wake.toolCallDeferralStartedAt ??= now
|
||||
if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs) {
|
||||
const latestToolWaitAgeMs = toolWaitState.createdAt === undefined
|
||||
? 0
|
||||
: now - toolWaitState.createdAt
|
||||
if (
|
||||
wake.shouldReply
|
||||
&& now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs
|
||||
&& latestToolWaitAgeMs >= this.options.toolCallDeferMaxMs
|
||||
) {
|
||||
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
|
||||
sessionID,
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ type SessionMessageStub = {
|
||||
finish?: string
|
||||
time?: { created?: number }
|
||||
}
|
||||
parts?: Array<{ type?: string; state?: { status?: string } }>
|
||||
}
|
||||
|
||||
function createNotifier(args: {
|
||||
@@ -392,4 +393,90 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
|
||||
notifier.shutdown()
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
})
|
||||
|
||||
test("#given stale all-complete wake and gate sees a repaired user tail #when latest assistant is still waiting on tools #then no parent reply is forked", async () => {
|
||||
// given
|
||||
const originalDateNow = Date.now
|
||||
Date.now = () => 100_000
|
||||
const promptAsyncCalls: PromptAsyncCall[] = []
|
||||
let messageReads = 0
|
||||
const waitingToolMessages: SessionMessageStub[] = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
time: { created: 80_000 },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
finish: "tool-calls",
|
||||
time: { created: 99_500 },
|
||||
},
|
||||
parts: [{ type: "tool", state: { status: "running" } }],
|
||||
},
|
||||
]
|
||||
const repairedTailMessages: SessionMessageStub[] = [
|
||||
...waitingToolMessages,
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
]
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => ({ data: { "parent-repaired-tail": { type: "idle" } } }),
|
||||
messages: async () => {
|
||||
messageReads += 1
|
||||
return { data: messageReads === 1 ? waitingToolMessages : repairedTailMessages }
|
||||
},
|
||||
promptAsync: async (call: PromptAsyncCall) => {
|
||||
promptAsyncCalls.push(call)
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
||||
const notifier = new ParentWakeNotifier(
|
||||
{
|
||||
client,
|
||||
directory: "/tmp/test-omo",
|
||||
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||
await operation()
|
||||
},
|
||||
},
|
||||
{
|
||||
pendingRetryMs: 1_000,
|
||||
acceptedMessageSkewMs: 5_000,
|
||||
toolCallDeferMaxMs: 5_000,
|
||||
failureRequeueWindowMs: 5_000,
|
||||
userMessageInProgressWindowMs: 2_000,
|
||||
},
|
||||
)
|
||||
notifier.queuePendingParentWake(
|
||||
"parent-repaired-tail",
|
||||
"<system-reminder>\n[ALL BACKGROUND TASKS COMPLETE]\n</system-reminder>",
|
||||
{ agent: "sisyphus" },
|
||||
true,
|
||||
)
|
||||
const pendingWake = notifier.getPendingParentWakes().get("parent-repaired-tail")
|
||||
expect(pendingWake).toBeDefined()
|
||||
if (!pendingWake) {
|
||||
throw new Error("Missing pending parent wake")
|
||||
}
|
||||
pendingWake.toolCallDeferralStartedAt = 90_000
|
||||
|
||||
try {
|
||||
// when
|
||||
await notifier.flushPendingParentWake("parent-repaired-tail")
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(0)
|
||||
expect(notifier.getPendingParentWakes().has("parent-repaired-tail")).toBe(true)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
notifier.shutdown()
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,6 +187,11 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back
|
||||
return notifyParentSession.call(manager, task)
|
||||
}
|
||||
|
||||
async function flushPendingParentWakeForTest(manager: BackgroundManager, sessionID: string): Promise<void> {
|
||||
const flushPendingParentWake = Reflect.get(manager, "flushPendingParentWake") as (sessionID: string) => Promise<void>
|
||||
return flushPendingParentWake.call(manager, sessionID)
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
while (!predicate()) {
|
||||
@@ -592,6 +597,56 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
})
|
||||
|
||||
test("#when stale deferral age is exceeded but latest tool turn is recent #then all-complete wake still waits", async () => {
|
||||
// given
|
||||
const originalDateNow = Date.now
|
||||
Date.now = () => 100_000
|
||||
const sessionStatuses: Record<string, { type: string }> = {
|
||||
"parent-1": { type: "idle" },
|
||||
}
|
||||
const sessionMessages: SessionMessageForTest[] = [
|
||||
{
|
||||
info: { role: "user", time: { created: 90_000 } },
|
||||
parts: [{ type: "text" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant", finish: "tool-calls", time: { created: 99_500 } },
|
||||
parts: [{ type: "tool", state: { status: "running" } }],
|
||||
},
|
||||
]
|
||||
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, undefined, sessionMessages)
|
||||
managerUnderTest = manager
|
||||
const task = createTask({
|
||||
id: "task-a",
|
||||
parentSessionId: "parent-1",
|
||||
description: "task A",
|
||||
status: "completed",
|
||||
completedAt: new Date("2026-05-19T00:09:55.089Z"),
|
||||
})
|
||||
getTasks(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
|
||||
|
||||
try {
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
await waitForCoalescedFlush()
|
||||
const pendingWake = getPendingParentWakes(manager).get("parent-1")
|
||||
expect(pendingWake).toBeDefined()
|
||||
if (!pendingWake) {
|
||||
throw new Error("Missing pending parent wake")
|
||||
}
|
||||
pendingWake.toolCallDeferralStartedAt = 90_000
|
||||
|
||||
// when
|
||||
await flushPendingParentWakeForTest(manager, "parent-1")
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(0)
|
||||
expect(getPendingParentWakes(manager).has("parent-1")).toBe(true)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("#when all-complete notification wakes parent #then prompt stays in the same OpenCode directory instance", async () => {
|
||||
// given
|
||||
const { manager, promptAsyncCalls } = createManager(true)
|
||||
|
||||
Reference in New Issue
Block a user