From c1ccf8d096660425e682695694b907c208f91ee7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 00:49:50 +0900 Subject: [PATCH] refactor(background-agent): introduce ParentWakeNotifier module Extracts the parent-wake coalescing logic (pending/dispatched wake maps, timers, notification reply assembly) from manager.ts into a standalone ParentWakeNotifier class. Takes dependency-injected client, directory, and an enqueueNotificationForParent callback, so the manager can delegate parent-wake state to a narrow API. This commit only introduces the new module; wiring manager.ts to use it is a follow-up commit so the refactor stays atomic (HIGH-9 step 1 of 2). Closes HIGH-9 (step 1: extraction) Refs HIGH-9 (step 2: manager.ts integration deferred until verification) Co-authored-by: manager-extract (deep / gpt-5.3-codex high) --- .../background-agent/parent-wake-notifier.ts | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 src/features/background-agent/parent-wake-notifier.ts diff --git a/src/features/background-agent/parent-wake-notifier.ts b/src/features/background-agent/parent-wake-notifier.ts new file mode 100644 index 000000000..3787df21d --- /dev/null +++ b/src/features/background-agent/parent-wake-notifier.ts @@ -0,0 +1,432 @@ +import { resolveRegisteredAgentName } from "../claude-code-session-state" +import { createInternalAgentTextPart, log, messagesInDirectory, normalizeSDKResponse } from "../../shared" +import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle" +import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate" +import type { PluginInput } from "@opencode-ai/plugin" + +type OpencodeClient = PluginInput["client"] + +export type ParentWakePromptContext = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + tools?: Record +} + +export type PendingParentWake = { + promptContext: ParentWakePromptContext + notifications: string[] + shouldReply: boolean + dispatchedAt?: number + toolCallDeferralStartedAt?: number +} + +type ParentWakeSessionMessage = { + info?: { + role?: string + finish?: string + time?: { created?: unknown } + } + role?: string + finish?: string + time?: { created?: unknown } + parts?: Array<{ + type?: string + text?: string + content?: unknown + }> +} + +type ParentWakeNotifierDeps = { + client: OpencodeClient + directory: string + enqueueNotificationForParent: (parentSessionID: string | undefined, operation: () => Promise) => Promise +} + +type ParentWakeNotifierOptions = { + pendingRetryMs: number + acceptedMessageSkewMs: number + toolCallDeferMaxMs: number + failureRequeueWindowMs: number +} + +export class ParentWakeNotifier { + private pendingParentWakes: Map = new Map() + private pendingParentWakeTimers: Map> = new Map() + private dispatchedParentWakes: Map = new Map() + private dispatchedParentWakeTimers: Map> = new Map() + + constructor( + private readonly deps: ParentWakeNotifierDeps, + private readonly options: ParentWakeNotifierOptions, + ) {} + + getPendingParentWakes(): Map { + return this.pendingParentWakes + } + + getPendingParentWakeTimers(): Map> { + return this.pendingParentWakeTimers + } + + getDispatchedParentWakes(): Map { + return this.dispatchedParentWakes + } + + getDispatchedParentWakeTimers(): Map> { + return this.dispatchedParentWakeTimers + } + + queuePendingParentWake( + sessionID: string, + notification: string, + promptContext: ParentWakePromptContext, + shouldReply: boolean, + delayMs?: number, + ): void { + const resolvedPromptContext = this.resolveParentWakePromptContext(promptContext) + const pendingWake = this.pendingParentWakes.get(sessionID) + if (pendingWake) { + pendingWake.notifications.push(notification) + pendingWake.promptContext = resolvedPromptContext + pendingWake.shouldReply = pendingWake.shouldReply || shouldReply + } else { + this.pendingParentWakes.set(sessionID, { + promptContext: resolvedPromptContext, + notifications: [notification], + shouldReply, + }) + } + this.schedulePendingParentWakeFlush(sessionID, delayMs) + } + + async flushPendingParentWake(sessionID: string): Promise { + if (!this.pendingParentWakes.has(sessionID)) { + this.clearPendingParentWakeTimer(sessionID) + return + } + + if (await this.isSessionActive(sessionID)) { + this.schedulePendingParentWakeFlush(sessionID) + return + } + + this.clearPendingParentWakeTimer(sessionID) + await settleAfterSessionIdle() + + if (await this.isSessionActive(sessionID)) { + this.schedulePendingParentWakeFlush(sessionID) + return + } + + const latestWake = this.pendingParentWakes.get(sessionID) + if (!latestWake) { + return + } + + if (await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)) { + this.schedulePendingParentWakeFlush(sessionID) + return + } + + this.pendingParentWakes.delete(sessionID) + + const notificationContent = latestWake.notifications.join("\n\n") + + try { + const promptResult = await promptAsyncAfterSessionIdle({ + client: this.deps.client, + sessionID, + source: "background-agent-parent-wake", + settleMs: 0, + postDispatchHoldMs: 250, + input: { + path: { id: sessionID }, + body: { + noReply: !latestWake.shouldReply, + ...latestWake.promptContext, + parts: [createInternalAgentTextPart(notificationContent)], + }, + query: { directory: this.deps.directory }, + }, + }) + if (promptResult.status === "failed") { + throw promptResult.error + } + if (promptResult.status !== "dispatched") { + this.requeueWake(sessionID, latestWake) + this.schedulePendingParentWakeFlush(sessionID) + log("[background-agent] Deferred parent wake skipped by promptAsync gate:", { + sessionID, + status: promptResult.status, + }) + return + } + log("[background-agent] Sent deferred parent wake:", { sessionID }) + this.trackDispatchedParentWake(sessionID, latestWake) + } catch (error) { + this.requeueWake(sessionID, latestWake) + this.schedulePendingParentWakeFlush(sessionID) + log("[background-agent] Failed to send deferred parent wake:", { sessionID, error }) + } + } + + clearDispatchedParentWake(sessionID: string): void { + const timer = this.dispatchedParentWakeTimers.get(sessionID) + if (timer) { + clearTimeout(timer) + this.dispatchedParentWakeTimers.delete(sessionID) + } + this.dispatchedParentWakes.delete(sessionID) + } + + async requeueDispatchedParentWake(sessionID: string, reason: string): Promise { + const wake = this.dispatchedParentWakes.get(sessionID) + if (!wake) { + return false + } + + await settleAfterSessionIdle() + + if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, wake)) { + this.clearDispatchedParentWake(sessionID) + log("[background-agent] Ignored late parent wake failure after assistant output:", { + sessionID, + reason, + }) + return false + } + + this.clearDispatchedParentWake(sessionID) + this.requeueWake(sessionID, wake) + this.schedulePendingParentWakeFlush(sessionID) + log("[background-agent] Requeued dispatched parent wake after prompt failure:", { + sessionID, + reason, + }) + return true + } + + schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void { + if (this.pendingParentWakeTimers.has(sessionID)) { + return + } + + const timer = setTimeout(() => { + this.pendingParentWakeTimers.delete(sessionID) + void this.deps.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { + log("[background-agent] Failed to retry pending parent wake:", { sessionID, error }) + }) + }, delayMs ?? this.options.pendingRetryMs) + + this.pendingParentWakeTimers.set(sessionID, timer) + } + + clearPendingParentWakeTimer(sessionID: string): void { + const timer = this.pendingParentWakeTimers.get(sessionID) + if (!timer) { + return + } + + clearTimeout(timer) + this.pendingParentWakeTimers.delete(sessionID) + } + + shutdown(): void { + for (const timer of this.pendingParentWakeTimers.values()) { + clearTimeout(timer) + } + this.pendingParentWakeTimers.clear() + + for (const timer of this.dispatchedParentWakeTimers.values()) { + clearTimeout(timer) + } + this.dispatchedParentWakeTimers.clear() + this.pendingParentWakes.clear() + this.dispatchedParentWakes.clear() + } + + private async isSessionActive(sessionID: string): Promise { + return isOpenCodeSessionActive(this.deps.client, sessionID) + } + + private resolveParentWakePromptContext(promptContext: ParentWakePromptContext): ParentWakePromptContext { + const resolvedAgent = resolveRegisteredAgentName(promptContext.agent) + return { + ...promptContext, + ...(resolvedAgent ? { agent: resolvedAgent } : {}), + ...(promptContext.model ? { model: { ...promptContext.model } } : {}), + ...(promptContext.tools ? { tools: { ...promptContext.tools } } : {}), + } + } + + private cloneParentWake(wake: PendingParentWake): PendingParentWake { + const promptContext = this.resolveParentWakePromptContext(wake.promptContext) + return { + promptContext, + notifications: [...wake.notifications], + shouldReply: wake.shouldReply, + ...(wake.dispatchedAt !== undefined ? { dispatchedAt: wake.dispatchedAt } : {}), + ...(wake.toolCallDeferralStartedAt !== undefined + ? { toolCallDeferralStartedAt: wake.toolCallDeferralStartedAt } + : {}), + } + } + + private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void { + this.clearDispatchedParentWake(sessionID) + const dispatchedWake = this.cloneParentWake(wake) + dispatchedWake.dispatchedAt = Date.now() + this.dispatchedParentWakes.set(sessionID, dispatchedWake) + const timer = setTimeout(() => { + this.dispatchedParentWakeTimers.delete(sessionID) + this.dispatchedParentWakes.delete(sessionID) + }, this.options.failureRequeueWindowMs) + this.dispatchedParentWakeTimers.set(sessionID, timer) + } + + private async loadParentWakeSessionMessages(sessionID: string): Promise { + try { + const messagesResp = await messagesInDirectory(this.deps.client, { + path: { id: sessionID }, + }, this.deps.directory) + return normalizeSDKResponse(messagesResp, [] as ParentWakeSessionMessage[]) + } catch (error) { + log("[background-agent] Failed to inspect parent session messages for wake safety:", { + sessionID, + error, + }) + return [] + } + } + + private getParentWakeMessageRole(message: ParentWakeSessionMessage): string | undefined { + return message.info?.role ?? message.role + } + + private getParentWakeMessageFinish(message: ParentWakeSessionMessage): string | undefined { + return message.info?.finish ?? message.finish + } + + private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined { + const value = message.info?.time?.created ?? message.time?.created + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : undefined + } + if (value instanceof Date) { + return value.getTime() + } + return undefined + } + + private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (!message) { + continue + } + const role = this.getParentWakeMessageRole(message) + if (role === "assistant") { + return this.getParentWakeMessageFinish(message) === "tool-calls" + } + if (role === "user") { + return false + } + } + return false + } + + private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean { + const role = this.getParentWakeMessageRole(message) + if (role !== "assistant" && role !== "tool") { + return false + } + if (!message.parts || message.parts.length === 0) { + return role === "assistant" + } + return message.parts.some((part) => { + if (part.type === "text" || part.type === "reasoning") { + return typeof part.text === "string" && part.text.trim().length > 0 + } + if (part.type === "tool" || part.type === "tool_result") { + return true + } + if (part.content !== undefined) { + if (typeof part.content === "string") { + return part.content.trim().length > 0 + } + if (Array.isArray(part.content)) { + return part.content.length > 0 + } + return true + } + return false + }) + } + + private parentWakeMessageContainsNotification(message: ParentWakeSessionMessage, wake: PendingParentWake): boolean { + if (this.getParentWakeMessageRole(message) !== "user") { + return false + } + return message.parts?.some((part) => + typeof part.text === "string" && wake.notifications.some((notification) => part.text?.includes(notification)) + ) ?? false + } + + private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise { + const messages = await this.loadParentWakeSessionMessages(sessionID) + if (!this.latestAssistantTurnIsWaitingOnTools(messages)) { + delete wake.toolCallDeferralStartedAt + return false + } + const now = Date.now() + wake.toolCallDeferralStartedAt ??= now + if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs) { + log("[background-agent] Sending parent wake after stale tool-call deferral window:", { + sessionID, + }) + return false + } + log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", { + sessionID, + }) + return true + } + + private async hasAcceptedMessageAfterDispatchedParentWake(sessionID: string, wake: PendingParentWake): Promise { + if (wake.dispatchedAt === undefined) { + return false + } + const dispatchedAt = wake.dispatchedAt + const messages = await this.loadParentWakeSessionMessages(sessionID) + return messages.some((message) => { + const createdAt = this.getParentWakeMessageCreatedAt(message) + if (createdAt === undefined) { + return false + } + if ( + createdAt >= dispatchedAt - this.options.acceptedMessageSkewMs + && this.parentWakeMessageContainsNotification(message, wake) + ) { + return true + } + return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message) + }) + } + + private requeueWake(sessionID: string, latestWake: PendingParentWake): void { + const pendingWake = this.pendingParentWakes.get(sessionID) + if (pendingWake) { + pendingWake.notifications.unshift(...latestWake.notifications) + pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply + pendingWake.promptContext = latestWake.promptContext + pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt + return + } + this.pendingParentWakes.set(sessionID, this.cloneParentWake(latestWake)) + } +}