diff --git a/src/features/background-agent/parent-wake-notifier.ts b/src/features/background-agent/parent-wake-notifier.ts index ce91350c2..871db26ae 100644 --- a/src/features/background-agent/parent-wake-notifier.ts +++ b/src/features/background-agent/parent-wake-notifier.ts @@ -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 { 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, }) diff --git a/src/features/background-agent/parent-wake-user-message-race.test.ts b/src/features/background-agent/parent-wake-user-message-race.test.ts index 476873dec..206d247fb 100644 --- a/src/features/background-agent/parent-wake-user-message-race.test.ts +++ b/src/features/background-agent/parent-wake-user-message-race.test.ts @@ -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[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", + "\n[ALL BACKGROUND TASKS COMPLETE]\n", + { 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() + } + }) }) diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 3d361ed5c..feafab54b 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -187,6 +187,11 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back return notifyParentSession.call(manager, task) } +async function flushPendingParentWakeForTest(manager: BackgroundManager, sessionID: string): Promise { + const flushPendingParentWake = Reflect.get(manager, "flushPendingParentWake") as (sessionID: string) => Promise + return flushPendingParentWake.call(manager, sessionID) +} + async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { 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 = { + "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)