0ad4d974f1
Cubic caught that clearing recent parent activity on session.idle could reopen the stale-idle race. Keep the fresh activity marker until its normal expiry so background completions still defer while the parent turn is actively reasoning. Red: parent idle after fresh reasoning delta dispatched an unexpected parent wake. Green: focused parent-wake regression suite passes.
619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
import { resolveRegisteredAgentName } from "../claude-code-session-state"
|
|
import {
|
|
createInternalAgentTextPart,
|
|
isAmbiguousPostDispatchPromptFailure,
|
|
isSyntheticOrInternalUserMessage,
|
|
log,
|
|
messagesInDirectory,
|
|
normalizeSDKResponse,
|
|
} from "../../shared"
|
|
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } 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<string, boolean>
|
|
}
|
|
|
|
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
|
|
synthetic?: boolean
|
|
content?: unknown
|
|
state?: {
|
|
status?: unknown
|
|
}
|
|
}>
|
|
}
|
|
|
|
type ParentWakeNotifierDeps = {
|
|
client: OpencodeClient
|
|
directory: string
|
|
enqueueNotificationForParent: (parentSessionID: string | undefined, operation: () => Promise<void>) => Promise<void>
|
|
}
|
|
|
|
type ParentWakeNotifierOptions = {
|
|
pendingRetryMs: number
|
|
acceptedMessageSkewMs: number
|
|
toolCallDeferMaxMs: number
|
|
failureRequeueWindowMs: number
|
|
/**
|
|
* If the latest message in the parent session is a `user` message added
|
|
* within this window, the parent-wake injection is deferred. Prevents the
|
|
* race where a parent-wake `dispatchInternalPrompt` collides with a fresh
|
|
* user prompt, which on macOS/Electron has triggered native SIGABRT crashes
|
|
* inside OpenCode's `@parcel/watcher` TSFN callback path. See issue #4120.
|
|
*/
|
|
userMessageInProgressWindowMs: number
|
|
parentSessionActivityInProgressWindowMs?: number
|
|
}
|
|
|
|
type ToolWaitDeferralDecision = {
|
|
defer: boolean
|
|
skipPromptGateToolStateCheck: boolean
|
|
}
|
|
|
|
type Unrefable = ReturnType<typeof setTimeout> & { unref?: () => unknown }
|
|
|
|
function unrefTimerHandle(handle: ReturnType<typeof setTimeout>): void {
|
|
const maybeUnref = (handle as Unrefable).unref
|
|
if (typeof maybeUnref === "function") {
|
|
try {
|
|
maybeUnref.call(handle)
|
|
} catch {
|
|
// unref is best-effort; some runtimes (e.g. browser-like shims) don't
|
|
// expose it. Failing here would only make the host event loop pinned —
|
|
// not a hard error.
|
|
}
|
|
}
|
|
}
|
|
|
|
export class ParentWakeNotifier {
|
|
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
|
|
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
|
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
|
|
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
|
private recentParentSessionActivity: Map<string, number> = new Map()
|
|
|
|
constructor(
|
|
private readonly deps: ParentWakeNotifierDeps,
|
|
private readonly options: ParentWakeNotifierOptions,
|
|
) {}
|
|
|
|
getPendingParentWakes(): Map<string, PendingParentWake> {
|
|
return this.pendingParentWakes
|
|
}
|
|
|
|
getPendingParentWakeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
return this.pendingParentWakeTimers
|
|
}
|
|
|
|
getDispatchedParentWakes(): Map<string, PendingParentWake> {
|
|
return this.dispatchedParentWakes
|
|
}
|
|
|
|
getDispatchedParentWakeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
return this.dispatchedParentWakeTimers
|
|
}
|
|
|
|
recordParentSessionActivity(sessionID: string): void {
|
|
this.recentParentSessionActivity.set(sessionID, Date.now())
|
|
}
|
|
|
|
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<void> {
|
|
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 (this.hasRecentParentSessionActivity(sessionID)) {
|
|
this.schedulePendingParentWakeFlush(sessionID)
|
|
log("[background-agent] Deferred parent wake because parent session activity is still fresh:", {
|
|
sessionID,
|
|
})
|
|
return
|
|
}
|
|
|
|
const toolWaitDecision = await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)
|
|
if (toolWaitDecision.defer) {
|
|
this.schedulePendingParentWakeFlush(sessionID)
|
|
return
|
|
}
|
|
|
|
if (await this.isUserMessageInProgress(sessionID)) {
|
|
// The user just sent a new message into the parent session. Dispatching
|
|
// a parent-wake right now would race their prompt and, on Electron-hosted
|
|
// OpenCode (macOS arm64), has been observed to crash the sidecar via
|
|
// @parcel/watcher TSFN callbacks firing into a torn-down JS env.
|
|
// The user's own message will drive the model; the queued notifications
|
|
// will be re-flushed on the next idle. See issue #4120.
|
|
this.schedulePendingParentWakeFlush(sessionID)
|
|
log("[background-agent] Deferred parent wake because user message just arrived:", {
|
|
sessionID,
|
|
})
|
|
return
|
|
}
|
|
|
|
this.pendingParentWakes.delete(sessionID)
|
|
|
|
const notificationContent = latestWake.notifications.join("\n\n")
|
|
|
|
let dispatchStartedAt = Date.now()
|
|
try {
|
|
dispatchStartedAt = Date.now()
|
|
const promptResult = await dispatchInternalPrompt({
|
|
mode: "async",
|
|
client: this.deps.client,
|
|
sessionID,
|
|
source: "background-agent-parent-wake",
|
|
settleMs: 0,
|
|
queueBehavior: "defer",
|
|
checkToolState: !toolWaitDecision.skipPromptGateToolStateCheck,
|
|
input: {
|
|
path: { id: sessionID },
|
|
body: {
|
|
noReply: !latestWake.shouldReply,
|
|
...latestWake.promptContext,
|
|
parts: [createInternalAgentTextPart(notificationContent)],
|
|
},
|
|
query: { directory: this.deps.directory },
|
|
},
|
|
})
|
|
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") {
|
|
this.requeueWake(sessionID, latestWake)
|
|
this.schedulePendingParentWakeFlush(sessionID, 2_000)
|
|
log("[background-agent] Requeued parent wake flush reserved by promptAsync gate hold:", { sessionID })
|
|
return
|
|
}
|
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
|
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, dispatchStartedAt)
|
|
} 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<boolean> {
|
|
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)
|
|
// Don't pin the host event loop with retry timers; the sidecar should be
|
|
// free to exit cleanly during teardown even if a wake is still pending.
|
|
// See issue #4120.
|
|
unrefTimerHandle(timer)
|
|
|
|
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()
|
|
this.recentParentSessionActivity.clear()
|
|
}
|
|
|
|
private async isSessionActive(sessionID: string): Promise<boolean> {
|
|
return isOpenCodeSessionActive(this.deps.client, sessionID)
|
|
}
|
|
|
|
private hasRecentParentSessionActivity(sessionID: string): boolean {
|
|
const windowMs = this.options.parentSessionActivityInProgressWindowMs ?? 0
|
|
if (windowMs <= 0) {
|
|
return false
|
|
}
|
|
const lastActivityAt = this.recentParentSessionActivity.get(sessionID)
|
|
if (lastActivityAt === undefined) {
|
|
return false
|
|
}
|
|
if (Date.now() - lastActivityAt <= windowMs) {
|
|
return true
|
|
}
|
|
this.recentParentSessionActivity.delete(sessionID)
|
|
return false
|
|
}
|
|
|
|
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, dispatchedAt: number): void {
|
|
this.clearDispatchedParentWake(sessionID)
|
|
const dispatchedWake = this.cloneParentWake(wake)
|
|
dispatchedWake.dispatchedAt = dispatchedAt
|
|
this.dispatchedParentWakes.set(sessionID, dispatchedWake)
|
|
const timer = setTimeout(() => {
|
|
this.dispatchedParentWakeTimers.delete(sessionID)
|
|
this.dispatchedParentWakes.delete(sessionID)
|
|
}, this.options.failureRequeueWindowMs)
|
|
// Best-effort unref so the dispatched-wake bookkeeping doesn't keep the
|
|
// event loop alive past the natural teardown window (issue #4120).
|
|
unrefTimerHandle(timer)
|
|
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
|
}
|
|
|
|
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
|
|
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 parentWakePartIsWaitingOnTool(part: NonNullable<ParentWakeSessionMessage["parts"]>[number]): boolean {
|
|
if (
|
|
part.type !== "tool"
|
|
&& part.type !== "tool_use"
|
|
&& part.type !== "tool-call"
|
|
&& part.type !== "tool-invocation"
|
|
) {
|
|
return false
|
|
}
|
|
|
|
const status = part.state?.status
|
|
return status === "pending" || status === "running"
|
|
}
|
|
|
|
private latestAssistantToolWaitState(messages: ParentWakeSessionMessage[]): {
|
|
waiting: boolean
|
|
createdAt?: number
|
|
} {
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
const message = messages[index]
|
|
if (!message) {
|
|
continue
|
|
}
|
|
const role = this.getParentWakeMessageRole(message)
|
|
if (role === "assistant") {
|
|
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") {
|
|
if (isSyntheticOrInternalUserMessage(message)) {
|
|
continue
|
|
}
|
|
return { waiting: false }
|
|
}
|
|
}
|
|
return { waiting: 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_use"
|
|
|| part.type === "tool-call"
|
|
|| part.type === "tool-invocation"
|
|
|| part.type === "tool_result"
|
|
|| 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 isUserMessageInProgress(sessionID: string): Promise<boolean> {
|
|
if (this.options.userMessageInProgressWindowMs <= 0) {
|
|
return false
|
|
}
|
|
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
const message = messages[index]
|
|
if (!message) {
|
|
continue
|
|
}
|
|
const role = this.getParentWakeMessageRole(message)
|
|
if (role === "user") {
|
|
if (isSyntheticOrInternalUserMessage(message)) {
|
|
continue
|
|
}
|
|
const createdAt = this.getParentWakeMessageCreatedAt(message)
|
|
if (createdAt === undefined) {
|
|
return false
|
|
}
|
|
return Date.now() - createdAt <= this.options.userMessageInProgressWindowMs
|
|
}
|
|
if (role === "assistant" || role === "tool") {
|
|
// An assistant/tool message is more recent than the last user message,
|
|
// so the user is not actively prompting right now.
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private async shouldDeferParentWakeForSessionHistory(
|
|
sessionID: string,
|
|
wake: PendingParentWake,
|
|
): Promise<ToolWaitDeferralDecision> {
|
|
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
|
const toolWaitState = this.latestAssistantToolWaitState(messages)
|
|
if (!toolWaitState.waiting) {
|
|
delete wake.toolCallDeferralStartedAt
|
|
return { defer: false, skipPromptGateToolStateCheck: false }
|
|
}
|
|
const now = Date.now()
|
|
wake.toolCallDeferralStartedAt ??= now
|
|
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,
|
|
})
|
|
return { defer: false, skipPromptGateToolStateCheck: true }
|
|
}
|
|
log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", {
|
|
sessionID,
|
|
})
|
|
return { defer: true, skipPromptGateToolStateCheck: false }
|
|
}
|
|
|
|
private async hasAcceptedMessageAfterDispatchedParentWake(sessionID: string, wake: PendingParentWake): Promise<boolean> {
|
|
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))
|
|
}
|
|
}
|