From 7dbb34cd4f219b016d11fe95206e72ceaa14f17c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 01:43:16 +0900 Subject: [PATCH] refactor(background-agent): wire ParentWakeNotifier into BackgroundManager Replace the inlined parent-wake coalescing logic in manager.ts with delegation to the ParentWakeNotifier extracted in c1ccf8d09. The four timer Maps and the related methods now live in their own module with a narrow public API, while BackgroundManager retains the wiring point and the enqueue-callback bridge. Closes HIGH-9 (step 2: integration) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 8 +- src/features/background-agent/manager.ts | 408 +----------------- .../task-completion-cleanup.test.ts | 9 +- 3 files changed, 33 insertions(+), 392 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index e04d5976c..b9bf00abe 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -233,11 +233,15 @@ function getPendingNotifications(manager: BackgroundManager): Map { - return (cast<{ pendingParentWakes: Map }>(manager)).pendingParentWakes + return (cast<{ + parentWakeNotifier: { getPendingParentWakes: () => Map } + }>(manager)).parentWakeNotifier.getPendingParentWakes() } function getDispatchedParentWakes(manager: BackgroundManager): Map { - return (cast<{ dispatchedParentWakes: Map }>(manager)).dispatchedParentWakes + return (cast<{ + parentWakeNotifier: { getDispatchedParentWakes: () => Map } + }>(manager)).parentWakeNotifier.getDispatchedParentWakes() } function getCompletionTimers(manager: BackgroundManager): Map> { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 84611c578..b6cb1c9c5 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -37,7 +37,7 @@ import { type QueueItem, } from "./constants" -import { resolveRegisteredAgentName, subagentSessions } from "../claude-code-session-state" +import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" import { formatDuration } from "./duration-formatter" import { @@ -63,10 +63,7 @@ import { } from "./attempt-lifecycle" import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" import { setContinuationMarkerSource } from "../../features/run-continuation-state" -import { - isSessionActive as isOpenCodeSessionActive, - settleAfterSessionIdle, -} from "../../hooks/shared/session-idle-settle" +import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle" import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate" import { findNearestMessageExcludingCompaction, @@ -96,39 +93,9 @@ import { resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" +import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier" type OpencodeClient = PluginInput["client"] -type ParentWakePromptContext = { - agent?: string - model?: { providerID: string; modelID: string } - variant?: string - tools?: Record -} - -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 ResumeTaskSnapshot = { status: BackgroundTask["status"] completedAt?: Date @@ -272,10 +239,7 @@ export class BackgroundManager { private completedTaskSummaries: Map = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() - private pendingParentWakes: Map = new Map() - private pendingParentWakeTimers: Map> = new Map() - private dispatchedParentWakes: Map = new Map() - private dispatchedParentWakeTimers: Map> = new Map() + private readonly parentWakeNotifier: ParentWakeNotifier private observedOutputSessions: Set = new Set() private observedIncompleteTodosBySession: Map = new Map() private rootDescendantCounts: Map @@ -306,6 +270,19 @@ export class BackgroundManager { this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true this.modelFallbackControllerAccessor = options?.modelFallbackControllerAccessor this.logger = options?.log ?? log + this.parentWakeNotifier = new ParentWakeNotifier( + { + client: this.client, + directory: this.directory, + enqueueNotificationForParent: this.enqueueNotificationForParent.bind(this), + }, + { + pendingRetryMs: PENDING_PARENT_WAKE_RETRY_MS, + acceptedMessageSkewMs: PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS, + toolCallDeferMaxMs: PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS, + failureRequeueWindowMs: PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS, + }, + ) this.registerProcessCleanup() } @@ -1385,222 +1362,12 @@ The fallback retry session is now created and can be inspected directly. this.observedOutputSessions.add(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 clearDispatchedParentWake(sessionID: string): void { - const timer = this.dispatchedParentWakeTimers.get(sessionID) - if (timer) { - clearTimeout(timer) - this.dispatchedParentWakeTimers.delete(sessionID) - } - this.dispatchedParentWakes.delete(sessionID) - } - - 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) - }, PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS) - this.dispatchedParentWakeTimers.set(sessionID, timer) + this.parentWakeNotifier.clearDispatchedParentWake(sessionID) } private 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) - const pendingWake = this.pendingParentWakes.get(sessionID) - if (pendingWake) { - pendingWake.notifications.unshift(...wake.notifications) - pendingWake.shouldReply = pendingWake.shouldReply || wake.shouldReply - pendingWake.promptContext = wake.promptContext - pendingWake.toolCallDeferralStartedAt ??= wake.toolCallDeferralStartedAt - } else { - this.pendingParentWakes.set(sessionID, this.cloneParentWake(wake)) - } - this.schedulePendingParentWakeFlush(sessionID) - log("[background-agent] Requeued dispatched parent wake after prompt failure:", { - sessionID, - reason, - }) - return true - } - - private async loadParentWakeSessionMessages(sessionID: string): Promise { - try { - const messagesResp = await messagesInDirectory(this.client, { - path: { id: sessionID }, - }, this.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 >= PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS) { - 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 - PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS - && this.parentWakeMessageContainsNotification(message, wake) - ) { - return true - } - return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message) - }) + return this.parentWakeNotifier.requeueDispatchedParentWake(sessionID, reason) } private clearSessionOutputObserved(sessionID: string): void { @@ -2699,132 +2466,11 @@ The task was re-queued on a fallback model after a retryable failure. 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) + this.parentWakeNotifier.queuePendingParentWake(sessionID, notification, promptContext, shouldReply, delayMs) } private 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.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.directory }, - }, - }) - if (promptResult.status === "failed") { - throw promptResult.error - } - if (promptResult.status !== "dispatched") { - 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 - } else { - this.pendingParentWakes.set(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) { - 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 - } else { - this.pendingParentWakes.set(sessionID, latestWake) - } - this.schedulePendingParentWakeFlush(sessionID) - log("[background-agent] Failed to send deferred parent wake:", { sessionID, error }) - } - } - - private schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void { - if (this.pendingParentWakeTimers.has(sessionID)) { - return - } - - const timer = setTimeout(() => { - this.pendingParentWakeTimers.delete(sessionID) - void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { - log("[background-agent] Failed to retry pending parent wake:", { sessionID, error }) - }) - }, delayMs ?? PENDING_PARENT_WAKE_RETRY_MS) - - this.pendingParentWakeTimers.set(sessionID, timer) - } - - private clearPendingParentWakeTimer(sessionID: string): void { - const timer = this.pendingParentWakeTimers.get(sessionID) - if (!timer) { - return - } - - clearTimeout(timer) - this.pendingParentWakeTimers.delete(sessionID) + await this.parentWakeNotifier.flushPendingParentWake(sessionID) } private hasRunningTasks(): boolean { @@ -3147,15 +2793,7 @@ The task was re-queued on a fallback model after a retryable failure. } this.idleDeferralTimers.clear() - 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.parentWakeNotifier.shutdown() for (const sessionID of trackedSessionIDs) { subagentSessions.delete(sessionID) @@ -3168,8 +2806,6 @@ The task was re-queued on a fallback model after a retryable failure. this.notifications.clear() this.pendingNotifications.clear() this.pendingByParent.clear() - this.pendingParentWakes.clear() - this.dispatchedParentWakes.clear() this.notificationQueueByParent.clear() this.rootDescendantCounts.clear() this.queuesByKey.clear() diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 39bc4bae7..1f9224d73 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -98,9 +98,8 @@ function createManager( abort: async () => ({}), }, } - const placeholderClient = {} as PluginInput["client"] const ctx: PluginInput = { - client: placeholderClient, + client: client as PluginInput["client"], project: {} as PluginInput["project"], directory: tmpdir(), worktree: tmpdir(), @@ -111,7 +110,6 @@ function createManager( const manager = new BackgroundManager( { pluginContext: ctx, config: undefined, enableParentSessionNotifications } ) - Reflect.set(manager, "client", client) return { manager, promptAsyncCalls } } @@ -174,7 +172,10 @@ function getPendingNotifications(manager: BackgroundManager): Map { - return Reflect.get(manager, "pendingParentWakes") as Map + const parentWakeNotifier = Reflect.get(manager, "parentWakeNotifier") as { + getPendingParentWakes: () => Map + } + return parentWakeNotifier.getPendingParentWakes() } function getCompletionTimers(manager: BackgroundManager): Map> {