3294128271
When a background subagent emits [ALL BACKGROUND TASKS COMPLETE], the plugin queues a parent-wake that ultimately calls dispatchInternalPrompt against the parent session. If the user submits a new prompt inside the ~250 ms post-dispatch hold window, both writes land on the same OpenCode session-storage file at the same instant. OpenCode's @parcel/watcher (which the plugin itself does not depend on, but does indirectly trigger) batches those events into a TSFN callback and dispatches them into a JS env that the renderer has just torn down because the session view re-mounted around the user's new message -> napi_fatal_error / SIGABRT on macOS arm64. Removing the plugin removes the parent-wake, which is why removing OmO eliminates the crash. Mitigation: - Before flushPendingParentWake calls dispatchInternalPrompt, inspect the parent session's message tail. If the most recent message is a user message added inside PARENT_WAKE_USER_MESSAGE_IN_PROGRESS_WINDOW_MS (default 2_000 ms), reschedule instead of dispatching. The user's own prompt will drive the model; queued notifications will be re-flushed on the next idle. - Best-effort unref() of the long-lived pending-retry and dispatched- wake bookkeeping setTimeouts. They previously pinned the host event loop and prolonged the teardown window during which the watcher race can fire. The new option userMessageInProgressWindowMs is wired through BackgroundManager via a module-level constant and is independently testable. Regression test parent-wake-user-message-race.test.ts covers: - fresh user message -> dispatch deferred - latest message is assistant -> dispatch proceeds - user message older than window -> dispatch proceeds - window=0 disables the guard This is a surface-level mitigation of the most-likely root cause from the audit; a deeper fix (singleton guard against plugin double-instantiation under @opencode-ai/plugin@local reload, dispose lifecycle for OpenCode plugin reload) is out of scope here.
202 lines
5.1 KiB
TypeScript
202 lines
5.1 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { ParentWakeNotifier } from "./parent-wake-notifier"
|
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
|
|
|
|
type PromptAsyncCall = {
|
|
path: { id: string }
|
|
body: {
|
|
noReply?: boolean
|
|
parts?: unknown[]
|
|
}
|
|
query?: {
|
|
directory: string
|
|
}
|
|
}
|
|
|
|
type SessionMessageStub = {
|
|
info?: {
|
|
role?: string
|
|
finish?: string
|
|
time?: { created?: number }
|
|
}
|
|
}
|
|
|
|
function createNotifier(args: {
|
|
sessionStatuses?: Record<string, { type: string }>
|
|
sessionMessages: SessionMessageStub[]
|
|
userMessageInProgressWindowMs?: number
|
|
}): {
|
|
notifier: ParentWakeNotifier
|
|
promptAsyncCalls: PromptAsyncCall[]
|
|
} {
|
|
const promptAsyncCalls: PromptAsyncCall[] = []
|
|
const client = {
|
|
session: {
|
|
messages: async () => ({ data: args.sessionMessages }),
|
|
status: async () => ({ data: args.sessionStatuses ?? {} }),
|
|
promptAsync: async (call: PromptAsyncCall) => {
|
|
promptAsyncCalls.push(call)
|
|
return { data: {} }
|
|
},
|
|
abort: async () => ({ data: {} }),
|
|
},
|
|
} as unknown as Parameters<typeof ParentWakeNotifier>[0] extends never
|
|
? never
|
|
: 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: args.userMessageInProgressWindowMs ?? 2_000,
|
|
},
|
|
)
|
|
|
|
return { notifier, promptAsyncCalls }
|
|
}
|
|
|
|
describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
|
|
test("#given latest message is a user message just added #when flushing pending wake #then dispatch is deferred (no promptAsync)", async () => {
|
|
// given
|
|
const { notifier, promptAsyncCalls } = createNotifier({
|
|
sessionMessages: [
|
|
{
|
|
info: {
|
|
role: "assistant",
|
|
finish: "stop",
|
|
time: { created: Date.now() - 10_000 },
|
|
},
|
|
},
|
|
{
|
|
info: {
|
|
role: "user",
|
|
time: { created: Date.now() - 100 },
|
|
},
|
|
},
|
|
],
|
|
})
|
|
notifier.queuePendingParentWake(
|
|
"parent-1",
|
|
"task complete",
|
|
{ agent: "sisyphus" },
|
|
true,
|
|
)
|
|
|
|
// when
|
|
await notifier.flushPendingParentWake("parent-1")
|
|
|
|
// then
|
|
expect(promptAsyncCalls).toHaveLength(0)
|
|
expect(notifier.getPendingParentWakes().has("parent-1")).toBe(true)
|
|
|
|
notifier.shutdown()
|
|
releaseAllPromptAsyncReservationsForTesting()
|
|
})
|
|
|
|
test("#given latest message is an assistant message #when flushing pending wake #then dispatch proceeds", async () => {
|
|
// given
|
|
const { notifier, promptAsyncCalls } = createNotifier({
|
|
sessionMessages: [
|
|
{
|
|
info: {
|
|
role: "user",
|
|
time: { created: Date.now() - 60_000 },
|
|
},
|
|
},
|
|
{
|
|
info: {
|
|
role: "assistant",
|
|
finish: "stop",
|
|
time: { created: Date.now() - 100 },
|
|
},
|
|
},
|
|
],
|
|
})
|
|
notifier.queuePendingParentWake(
|
|
"parent-2",
|
|
"task complete",
|
|
{ agent: "sisyphus" },
|
|
true,
|
|
)
|
|
|
|
// when
|
|
await notifier.flushPendingParentWake("parent-2")
|
|
|
|
// then
|
|
expect(promptAsyncCalls).toHaveLength(1)
|
|
expect(promptAsyncCalls[0]?.path.id).toBe("parent-2")
|
|
|
|
notifier.shutdown()
|
|
releaseAllPromptAsyncReservationsForTesting()
|
|
})
|
|
|
|
test("#given user message is older than the race window #when flushing pending wake #then dispatch proceeds", async () => {
|
|
// given
|
|
const { notifier, promptAsyncCalls } = createNotifier({
|
|
sessionMessages: [
|
|
{
|
|
info: {
|
|
role: "user",
|
|
time: { created: Date.now() - 5_000 },
|
|
},
|
|
},
|
|
],
|
|
userMessageInProgressWindowMs: 2_000,
|
|
})
|
|
notifier.queuePendingParentWake(
|
|
"parent-3",
|
|
"task complete",
|
|
{ agent: "sisyphus" },
|
|
true,
|
|
)
|
|
|
|
// when
|
|
await notifier.flushPendingParentWake("parent-3")
|
|
|
|
// then
|
|
expect(promptAsyncCalls).toHaveLength(1)
|
|
|
|
notifier.shutdown()
|
|
releaseAllPromptAsyncReservationsForTesting()
|
|
})
|
|
|
|
test("#given race window is disabled (0 ms) #when flushing #then guard is skipped even for fresh user message", async () => {
|
|
// given
|
|
const { notifier, promptAsyncCalls } = createNotifier({
|
|
sessionMessages: [
|
|
{
|
|
info: {
|
|
role: "user",
|
|
time: { created: Date.now() - 10 },
|
|
},
|
|
},
|
|
],
|
|
userMessageInProgressWindowMs: 0,
|
|
})
|
|
notifier.queuePendingParentWake(
|
|
"parent-4",
|
|
"task complete",
|
|
{ agent: "sisyphus" },
|
|
true,
|
|
)
|
|
|
|
// when
|
|
await notifier.flushPendingParentWake("parent-4")
|
|
|
|
// then
|
|
expect(promptAsyncCalls).toHaveLength(1)
|
|
|
|
notifier.shutdown()
|
|
releaseAllPromptAsyncReservationsForTesting()
|
|
})
|
|
})
|