Skip to content

fix(background-agent): defer parent-wake when a user message just arrived (fixes #4120) - #4122

Merged
code-yeongyu merged 1 commit into
code-yeongyu:devfrom
mguttmann:fix-4120
May 18, 2026
Merged

code-yeongyu merged 1 commit into
code-yeongyu:devfrom
mguttmann:fix-4120

Conversation

@mguttmann

@mguttmann mguttmann commented May 17, 2026 •

Copy link
Copy Markdown

Fixes #4120 (related: #3997, #4061).

Problem

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 doesn't depend on directly but does indirectly trigger via session writes) 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 (clean A/B in #4120).

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.

Tests

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

Honest disclaimer on confidence

During live testing on macOS arm64 with 6 parallel background tasks, the sidecar stayed stable across multiple completion cycles — but the new Deferred parent wake because user message just arrived log line did not fire (grep -c = 0). The existing first-layer defer (isSessionActive(parent) check) and settleAfterSessionIdle(150ms) appear to absorb most realistic timing windows on this hardware. I cannot prove this specific guard fired on the original crash path.

Submitting this anyway because:

  1. The original crash pattern (post-completion + immediate user input → napi_fatal_error) is no longer reproducible with this build loaded.
  2. The defer-on-fresh-user-message is a defensible safety net regardless of which layer catches it first.
  3. The setTimeout.unref() change is unambiguously correct — those timers were preventing clean teardown.

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 and called out in the commit message.

Happy to gather more evidence with snapshot: true and tighter timing if requested before merge.


Summary by cubic

Defers parent-wake dispatch when a fresh user message just arrived to prevent the macOS Electron sidecar crash caused by a prompt race. Fixes #4120.

  • Bug Fixes
    • Before dispatching a parent-wake, check the parent's latest message; if it's a recent user message (default 2000 ms), defer instead of dispatching.
    • Adds userMessageInProgressWindowMs (wired through BackgroundManager) to tune or disable this guard.
    • Unrefs retry and bookkeeping timers so they don’t pin the event loop during teardown.
    • Adds tests covering defer/proceed behavior and window=0.

Written for commit 3294128. Summary will update on new commits. Review in cubic

…ived (fixes #4120)

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Auto-approved: The change adds a minimal, well-tested guard that defers parent-wake dispatch when a fresh user message is detected, without altering existing logic for the common case, and the timer .unref() improvements are unambiguously safe; the overall regression risk is near zero.
Re-trigger cubic

@mguttmann

Copy link
Copy Markdown
Author

For context, also see the discussion threads:

@code-yeongyu
code-yeongyu merged commit 6037b91 into code-yeongyu:dev May 18, 2026
8 checks passed
@mguttmann

Copy link
Copy Markdown
Author

Closing — superseded by upstream fixes

Closing this PR. Issue #4120 was closed by the maintainer's own series of parent-wake fixes shipped between this PR's submission and v4.2.2:

  • b53a8b5 fix(parent-wake): close duplicate wake races — adds isSyntheticOrInternalUserMessage filter so synthetic / internal user tails no longer count as "real user" interrupts, and tightens trackDispatchedParentWake to use the dispatchStartedAt timestamp instead of post-await Date.now() (closes a separate race the audit didn't catch)
  • 33b6637 fix(background-agent): defer live tool-turn wakes — requires the latest assistant tool-wait turn itself to be stale before a shouldReply parent wake can bypass tool-call deferral, preventing all-complete background wakes from forking a second parent prompt loop when OpenCode has repaired the tail to a synthetic user message
  • 5f733f4 fix(parent-wake): recognize sdk tool progress
  • d3d2d49 fix(parent-wake): preserve stale tool-call wake escape
  • bb75751 fix(babysitter): avoid double prompt gate

The maintainer's approach is content-classification-based (filter synthetic/internal user messages, fix dispatch timestamping). My PR's approach was timing-based (2s "user message just arrived" window). Both close the same race, but the upstream version is semantically cleaner — it distinguishes real user input from OmO-injected synthetic tails by content, not by clock, and therefore can't be defeated by a slow-arriving real user message.

Live-test honesty

During post-PR validation on macOS arm64 (v4.2.0 local build with this fix applied), my new defer-guard log line fired 0 times across multiple parallel background-task completions plus immediate follow-up user messages. The pre-existing isSessionActive check was always strong enough to defer the wake before it reached my new guard. That's exactly what the PR description said upfront ("surface-level mitigation of the most-likely root cause") — but with upstream's fixes in place, even the narrow edge-case slot that my guard targeted is now closed by isSyntheticOrInternalUserMessage + the dispatch-timestamp fix.

Defense-in-depth — not worth keeping

I considered re-positioning this PR as an additive belt-and-suspenders layer above b53a8b5+33b66376, but the 2s timing window can in principle defer real fast user prompts that legitimately arrive right after a background completion. That's a false-positive surface area I don't want to add when the upstream solution is already correct.

Thanks @code-yeongyu for the much cleaner fix. PR #4121 (#4119) is still in flight; the Oracle review blockers on that one are now addressed.

@mguttmann
mguttmann deleted the fix-4120 branch May 19, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Desktop sidecar crash with napi_fatal_error (SIGABRT) after agent completion — macOS arm64, v4.1.2

2 participants