Skip to content

Post one banner and one sound for blocking agent prompts - #5743

Open
danmirman wants to merge 2 commits into
manaflow-ai:mainfrom
danmirman:fix/duplicate-prompt-notification-sound
Open

danmirman wants to merge 2 commits into
manaflow-ai:mainfrom
danmirman:fix/duplicate-prompt-notification-sound

Conversation

@danmirman

@danmirman danmirman commented Jun 10, 2026 •

Copy link
Copy Markdown

Problem

When a Claude Code session hits a blocking decision (tool permission request, ExitPlanMode, AskUserQuestion) while cmux is in the background, the user gets two desktop banners and two sounds for one prompt. This is one concrete, reproducible slice of the notification flakiness tracked in #2322.

Root cause

A single blocking prompt fires two independent hooks, and each posts its own desktop banner with its own sound:

  1. The agent's Notification hook (cmux hooks claude notification) classifies the permission message as .needsInput (classifyAgentHookNotification, CLI/cmux.swift) and posts through TerminalNotificationStore.scheduleUserNotification.
  2. The PermissionRequest hook (cmux hooks feed --source claude) routes through FeedCoordinator.ingestBlocking → postNotificationIfStillAwaiting → deliverFeedNotificationIfStillAwaiting, which posts its own actionable banner (feed.<requestId>) directly to UNUserNotificationCenter.

There is no shared state between the paths: the store's cooldownKey is per-call-site opt-in and the CLI's notificationFingerprint dedup is per-hook-handler only. The feed path also runs regardless of whether the feed sidebar beta is enabled, so every user with Claude hooks gets the duplicate on blocking prompts.

Fix — one event, one banner, one ding (deterministically)

TerminalNotificationDeliveryGate makes the feed's actionable banner the canonical desktop alert for blocking decisions — not "whichever banner wins the race":

  • Claim: FeedCoordinator.ingestBlocking claims the surface for the decision's lifetime (beginBlockingDecision / endBlockingDecision, keyed by surfaceId ?? workspaceId). While claimed, the store keeps recording sidebar notifications (history/unread untouched) but stands down from desktop banners, sounds, and notification commands for that surface.
  • Withdraw: if the agent-hook banner raced ahead of the claim, the feed withdraws it from Notification Center when posting its own (withdrawRecentDeliveredDesktopNotifications, 5s lookback, sidebar record untouched) — exactly one banner remains visible.
  • Sound window: claimSound guarantees at most one sound per surface within 2s regardless of arrival order, covering the withdraw case where the raced banner already dinged.
  • Time-boxed claims: a decision the user answers in the terminal stays pending app-side until the hook timeout (the app only observes a timeout), so an unbounded claim would swallow legitimate later alerts — e.g. the completion banner right after an approval. Claims therefore only suppress within 5s of registration; refcounted and window-extended for overlapping decisions.

Net behavior: blocking prompt while backgrounded → one actionable banner + one ding. Blocking prompt while focused → in-app attention only (existing #5313 path). Completions and idle "waiting for input" notifications are unchanged: one banner, one ding, as before.

Degenerate case: if the decision's target can't be resolved from the hook-session store (nil key), nothing is claimed and behavior falls back to today's.

Tests

TerminalNotificationDeliveryGateTests (Swift Testing, appended to the already-wired NotificationSoundSettingsTests.swift to avoid project.pbxproj edits per the CLAUDE.md test-wiring pitfall): sound-window grant/silence/expiry, denied attempts not extending the window, per-key independence, nil-key passthrough, claim begin/end/refcount/time-box/extension, unbalanced-end no-ops, and the withdrawal id-selection logic (recent + matching surface only, workspace-key fallback).

Note on the two-commit red/green policy: these are unit tests of a new type, so a failing-first commit cannot compile; an end-to-end repro would need a UNUserNotificationCenter seam that doesn't exist today.

Verification

  • swiftc -parse clean on all three touched files.
  • Authored on a machine without the full Xcode toolchain, so I could not run reload.sh / xcodebuild test locally — relying on CI for build + test. Happy to iterate on any failures.
  • Localization audit: no user-facing strings added or changed.

Fixes one source of #2322 (related: #942, #5286).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Notifications now coordinate to ensure only a single banner remains visible for the same prompt and to pause banner side-effects while a blocking decision is pending.
  • Bug Fixes

    • Eliminated duplicate notification sounds across delivery paths and ensured suppressed notifications honor sound deduplication.
  • Tests

    • Added comprehensive tests covering sound-suppression windows, blocking-decision gating, and banner withdrawal behavior.

… banners

A blocking agent decision (PermissionRequest / ExitPlanMode /
AskUserQuestion) fires two independent hooks: the agent's Notification
hook posts a banner through TerminalNotificationStore while the feed
decision bridge posts its own actionable banner directly through
UNUserNotificationCenter (FeedCoordinator.postNotificationIfStillAwaiting).
Both banners request a sound, so a single prompt dings twice when the
app is in the background (manaflow-ai#2322).

The banners carry different content and inline actions, so they cannot
be deduplicated by identity. Instead, TerminalNotificationSoundGate
grants the sound to the first banner per surface within a 2s window and
silences follow-ups: banner visibility is unchanged, denied attempts do
not extend the window, and unresolvable targets are never silenced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 10, 2026

Copy link
Copy Markdown

@danmirman is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds TerminalNotificationDeliveryGate to coordinate per-workspace/surface blocking-decision claims and per-key sound deduplication; integrates it into TerminalNotificationStore scheduling and FeedCoordinator notification delivery paths; and adds tests covering sound suppression, blocking claims, and withdrawal selection.

Changes

Terminal notification delivery gate and integrations

Layer / File(s) Summary
Delivery gate implementation
Sources/TerminalNotificationStore.swift
Adds TerminalNotificationDeliveryGate with shared, key(workspaceId:surfaceId:), claimSound(forKey:), and blocking-decision methods/time windows.
Store: gate integration and withdrawal
Sources/TerminalNotificationStore.swift
scheduleUserNotification early-exits when a blocking decision is active for the notification's gate key; adds deliveryGateKey(for:) and effectsAfterSoundGate(_:for:) to clear effects.sound when claimSound denies playback; wires helper into suppressed-feedback and withdraws recent desktop banners for a gate key when delivering new banners.
FeedCoordinator: compute and thread gate key
Sources/Feed/FeedCoordinator.swift
ingestBlocking derives an optional deliveryGateKey, begins/ends the gate blocking claim around the waiter lifecycle, threads the optional key through postNotificationIfStillAwaiting and deliverFeedNotificationIfStillAwaiting, and uses the gate to suppress sound and trigger banner withdrawal.
Unit tests for gate behavior and withdrawals
cmuxTests/NotificationSoundSettingsTests.swift
Adds TerminalNotificationDeliveryGateTests validating sound-window grant/silence/expiration, denied-attempt semantics, independent keys, nil-key behavior, surface-vs-workspace key derivation, blocking-decision refcounting/timeboxing/overlap, and withdrawable-banner selection with workspace fallback.

Sequence Diagram(s)

sequenceDiagram
  participant FeedCoordinator
  participant TerminalNotificationDeliveryGate
  participant TerminalNotificationStore
  FeedCoordinator->>TerminalNotificationDeliveryGate: beginBlockingDecision(forKey:)
  FeedCoordinator->>FeedCoordinator: compute deliveryGateKey, call postNotificationIfStillAwaiting(deliveryGateKey)
  FeedCoordinator->>TerminalNotificationDeliveryGate: endBlockingDecision(forKey:) when waiter removed
  TerminalNotificationStore->>TerminalNotificationDeliveryGate: claimSound(forKey:) during deliver path
  TerminalNotificationDeliveryGate-->>TerminalNotificationStore: grant/deny (Bool)
  TerminalNotificationStore->>TerminalNotificationStore: clear effects.sound if denied
  TerminalNotificationStore->>TerminalNotificationStore: withdrawRecentDeliveredDesktopNotifications(forGateKey:since:) when delivering new banner
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#3924: Modifies the same FeedCoordinator "still awaiting decision" notification pipeline and interacts with delivery/gating behavior.
  • manaflow-ai/cmux#5313: Also changes FeedCoordinator.ingestBlocking and AttentionTarget plumbing related to blocking-decision flows.

Poem

🐰
I nudge a gate with gentle paw,
One chime allowed — then silence saw.
Surface keyed, workspace would guide,
Banners tidy, sounds no longer collide.
Hoppy hush; the alerts abide.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error TerminalNotificationDeliveryGate (@unchecked Sendable) is accessed from both main and background threads but lacks documented reason for NSLock or clear safety explanation. Add explicit documentation that NSLock protects mutable state across main/background thread access, or provide clear safety explanation near @unchecked Sendable marker.
Cmux Swift Blocking Runtime ❌ Error NSLock in TerminalNotificationDeliveryGate lacks required documentation explaining why an actor cannot be used, violating the rules. Document why TerminalNotificationDeliveryGate cannot use an actor pattern instead of NSLock for thread-safe cross-thread synchronization.
Cmux Algorithmic Complexity ❌ Error Line 2221 in TerminalNotificationStore.swift performs unbounded O(n) scan on notifications array in hot path without documented bounds. Replace full-array compactMap scan with indexed lookup by surfaceId to avoid O(n) complexity on notifications collection during blocking decision delivery.
Cmux Architecture Rethink ❌ Error Introduces shared mutable singleton gate with NSLock coordinating two notification paths with different focus checks, leaving bad state representable when feed banner doesn't post despite gate claim. Align gate-claim timing with actual feed posting predicate or consolidate to single focus check.
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main objective: preventing duplicate notifications (two banners and two sounds) for blocking prompts by ensuring only one sound is played.
Description check ✅ Passed The description is comprehensive and well-structured, covering the problem statement, root cause analysis, the fix with detailed implementation strategy, test coverage, and verification steps—all organized clearly for review.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Expensive Synchronous Load ✅ Passed Gate is pure in-memory with O(1) dictionary operations. No FileManager, Process, sysctl, or disk I/O added. No RestorableAgentSessionIndex.load() calls.
Cmux Cache Substitution Correctness ✅ Passed Gate is transient in-memory coordination tracking only timestamps and ref-counts, never persisted or loaded from disk, so it doesn't violate cache substitution rules.
Cmux No Hacky Sleeps ✅ Passed All PR changes are Swift-only; the "cmux no hacky sleeps" rule explicitly excludes Swift code (deferred to "swift-blocking-runtime.md").
Cmux Swift Concurrency ✅ Passed No legacy async patterns introduced: no background DispatchQueues, new Combine, completion handlers, or fire-and-forget Tasks with unmanaged lifecycle.
Cmux Swift @Concurrent ✅ Passed TerminalNotificationDeliveryGate is synchronous @unchecked Sendable. No @concurrent violations found: no missing @concurrent, no sync functions marked @concurrent, async calls properly scoped.
Cmux Swift File And Package Boundaries ✅ Passed 173-line TerminalNotificationDeliveryGate and 51-line FeedCoordinator changes stay under 250-line threshold. Focused bug fix with clear extraction path and no UI dependencies.
Cmux Swift Logging ✅ Passed No logging violations. New TerminalNotificationDeliveryGate and effectsAfterSoundGate code contains no print/NSLog; pre-existing NSLog statements are not modified.
Cmux User-Facing Error Privacy ✅ Passed No new user-facing error messages expose vendor names, credentials, tokens, or sensitive implementation details. All visible strings follow existing patterns.
Cmux Full Internationalization ✅ Passed All new user-facing strings use String(localized:) with matching translations in Localizable.xcstrings and web/messages/ for all supported locales.
Cmux Swiftui State Layout ✅ Passed PR introduces no SwiftUI state violations: only non-SwiftUI code changes in FeedCoordinator, TerminalNotificationDeliveryGate, and tests. No new @Published, ObservableObject, or View definitions.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new standalone cmux-owned windows introduced. PR adds only TerminalNotificationDeliveryGate, a thread-safe state management class for notification sound coordination, not a window/panel/controller.
Cmux Source Artifacts ✅ Passed All changed files are hand-written Swift source and test files in legitimate product/test directories (Sources/, cmuxTests/), not artifacts, generated output, or local tool debris.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces TerminalNotificationDeliveryGate to silence the duplicate notification sound that occurs when a blocking agent decision triggers both the agent notification hook (via TerminalNotificationStore) and the feed decision bridge (via FeedCoordinator), each posting its own banner with sound.

  • Adds a thread-safe gate with a 2-second per-surface sound window and a 5-second blocking-decision claim that suppresses the store's desktop banner while a feed decision is pending; both banners still appear unless the store banner is withdrawn by the feed path's withdrawRecentDeliveredDesktopNotifications call.
  • Wraps FeedCoordinator.ingestBlocking's semaphore-wait lifetime with beginBlockingDecision/endBlockingDecision, threads a deliveryGateKey through postNotificationIfStillAwaiting → deliverFeedNotificationIfStillAwaiting, and performs a withdrawal of raced store banners before posting the actionable feed banner.
  • The gate is a shared mutable singleton with @unchecked Sendable and NSLock — concerns about isolation and blocking raised in prior review threads remain open.

Confidence Score: 4/5

The sound deduplication works in the common path, but the async authorization callback in scheduleUserNotification is not re-guarded, leaving a narrow race where both banners can appear simultaneously.

The ensureAuthorization async path does not re-check the gate inside its callback, so if authorization resolves on a later main-actor turn after the feed path has already withdrawn and posted, the store banner is added with no withdrawal — both banners visible, one sound.

Sources/TerminalNotificationStore.swift — the ensureAuthorization callback in scheduleUserNotification and the claimSound compaction path.

Important Files Changed

Filename Overview
Sources/TerminalNotificationStore.swift Adds TerminalNotificationDeliveryGate (sound window + blocking-decision claims) and wires it into scheduleUserNotification and playSuppressedNotificationFeedback; previously-flagged NSLock/@mainactor and @unchecked Sendable concerns apply here.
Sources/Feed/FeedCoordinator.swift Wraps the semaphore-wait lifetime with beginBlockingDecision/endBlockingDecision, threads deliveryGateKey through postNotificationIfStillAwaiting, claims sound in deliverFeedNotificationIfStillAwaiting, and withdraws raced store banners before posting the feed banner.
cmuxTests/NotificationSoundSettingsTests.swift Adds TerminalNotificationDeliveryGateTests covering first grant, within-window silence, re-grant after expiry, denied-don't-extend, per-key independence, nil/empty keys, surface-over-workspace preference, and cross-path withdrawal selection.

Sequence Diagram

sequenceDiagram
    participant BG as Background Thread
    participant Gate as DeliveryGate
    participant Store as TerminalNotificationStore
    participant Feed as FeedCoordinator Task
    participant UN as UNUserNotificationCenter

    Note over BG: Blocking decision arrives
    BG->>Gate: beginBlockingDecision(forKey:)
    BG->>Feed: spawn Task at MainActor

    Note over Store: Agent notification hook fires
    Store->>Gate: hasActiveBlockingDecision? true
    Note over Store: Store returns early - sidebar kept, no banner

    Note over Feed: Task runs on main actor
    Feed->>Gate: claimSound(forKey:) returns true
    Feed->>Store: withdrawRecentDeliveredDesktopNotifications
    Store->>UN: removePending + removeDelivered for store banner IDs
    Feed->>UN: add feed banner with sound and action buttons

    BG->>BG: semaphore.wait
    Note over BG: User responds or times out
    BG->>Gate: endBlockingDecision(forKey:)

    Note over Store: Race - store hook fires before beginBlockingDecision
    Store->>Gate: hasActiveBlockingDecision? false
    Store->>Gate: claimSound(forKey:) true - sound claimed
    Store->>UN: center.add store banner as pending
    Note over Feed: withdrawal removes pending store banner
    Feed->>UN: add feed banner without sound
Loading

Reviews (2): Last reviewed commit: "Make the feed banner the canonical alert..." | Re-trigger Greptile

Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment on lines +787 to +823
final class TerminalNotificationSoundGate: @unchecked Sendable {
static let shared = TerminalNotificationSoundGate()

let suppressionWindow: TimeInterval

private let lock = NSLock()
private var lastGrantDateByKey: [String: Date] = [:]
private let maxTrackedKeys = 128

init(suppressionWindow: TimeInterval = 2.0) {
self.suppressionWindow = suppressionWindow
}

/// The dedupe key for a notification target. Keyed by surface when known
/// so prompts on different panes of one workspace stay independent.
static func key(workspaceId: UUID, surfaceId: UUID?) -> String {
(surfaceId ?? workspaceId).uuidString
}

/// Returns whether a sound may play for the given key, recording a grant
/// when it does. `nil` keys are never deduplicated.
func shouldPlaySound(forKey key: String?, now: Date = Date()) -> Bool {
guard let key, !key.isEmpty else { return true }
lock.lock()
defer { lock.unlock() }
if let lastGrant = lastGrantDateByKey[key],
abs(now.timeIntervalSince(lastGrant)) < suppressionWindow {
return false
}
if lastGrantDateByKey.count >= maxTrackedKeys {
lastGrantDateByKey = lastGrantDateByKey.filter {
abs(now.timeIntervalSince($0.value)) < suppressionWindow
}
}
lastGrantDateByKey[key] = now
return true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Manual lock on @MainActor call sites

TerminalNotificationSoundGate uses NSLock — a blocking primitive — to guard lastGrantDateByKey, but both call sites that invoke shouldPlaySound are already on @MainActor: TerminalNotificationStore.effectsAfterSoundGate is a method of an @MainActor final class, and FeedCoordinator.deliverFeedNotificationIfStillAwaiting is explicitly @MainActor. The main actor serialises all access, so the lock provides no safety benefit while adding a blocking acquisition on the main thread. If a background caller is ever introduced, it would be the @MainActor path that blocks waiting for the background thread to release — the opposite of the intended protection. The type should be an actor (or isolated to @MainActor) so Swift's concurrency checker owns the invariant and the lock can be removed entirely.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was reviewed against the first commit — 48bd85f renamed the type to TerminalNotificationDeliveryGate and expanded it. On @MainActor: beginBlockingDecision/endBlockingDecision are called from FeedCoordinator.ingestBlocking, which runs on the CLI socket thread (before the DispatchQueue.main.sync hop and after the semaphore wait), and the claim must be registered synchronously before postNotificationIfStillAwaiting spawns its task. Making the gate @MainActor would either force an async hop (losing that ordering guarantee) or push main.sync deeper into the socket path. The NSLock-guarded micro-critical-section mirrors the existing patterns in these exact files: FeedCoordinator.waiterLock and NotificationSoundSettings.activePlaybackSoundsLock.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fair point — my comment was against the first commit and missed the beginBlockingDecision/endBlockingDecision call sites running on the socket thread. With a synchronous ordering requirement before the postNotificationIfStillAwaiting task spawn, @MainActor isn't viable without breaking that guarantee, and the NSLock micro-critical-section pattern is consistent with waiterLock and activePlaybackSoundsLock in the same files. The lock is appropriate here.

One small suggestion: a brief inline comment on the lock explaining why it's not @MainActor (the socket-thread call sites) would help the next reviewer avoid the same confusion:

Suggested change
final class TerminalNotificationSoundGate: @unchecked Sendable {
static let shared = TerminalNotificationSoundGate()
let suppressionWindow: TimeInterval
private let lock = NSLock()
private var lastGrantDateByKey: [String: Date] = [:]
private let maxTrackedKeys = 128
init(suppressionWindow: TimeInterval = 2.0) {
self.suppressionWindow = suppressionWindow
}
/// The dedupe key for a notification target. Keyed by surface when known
/// so prompts on different panes of one workspace stay independent.
static func key(workspaceId: UUID, surfaceId: UUID?) -> String {
(surfaceId ?? workspaceId).uuidString
}
/// Returns whether a sound may play for the given key, recording a grant
/// when it does. `nil` keys are never deduplicated.
func shouldPlaySound(forKey key: String?, now: Date = Date()) -> Bool {
guard let key, !key.isEmpty else { return true }
lock.lock()
defer { lock.unlock() }
if let lastGrant = lastGrantDateByKey[key],
abs(now.timeIntervalSince(lastGrant)) < suppressionWindow {
return false
}
if lastGrantDateByKey.count >= maxTrackedKeys {
lastGrantDateByKey = lastGrantDateByKey.filter {
abs(now.timeIntervalSince($0.value)) < suppressionWindow
}
}
lastGrantDateByKey[key] = now
return true
}
// NSLock rather than @MainActor: beginBlockingDecision/endBlockingDecision
// are called from the CLI socket thread before the main-thread hop, so
// @MainActor isolation would require an async hop that breaks the
// synchronous ordering guarantee. Pattern mirrors FeedCoordinator.waiterLock.
private let lock = NSLock()

Otherwise the design is sound — withdrawing this concern.

Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment on lines +787 to +824
final class TerminalNotificationSoundGate: @unchecked Sendable {
static let shared = TerminalNotificationSoundGate()

let suppressionWindow: TimeInterval

private let lock = NSLock()
private var lastGrantDateByKey: [String: Date] = [:]
private let maxTrackedKeys = 128

init(suppressionWindow: TimeInterval = 2.0) {
self.suppressionWindow = suppressionWindow
}

/// The dedupe key for a notification target. Keyed by surface when known
/// so prompts on different panes of one workspace stay independent.
static func key(workspaceId: UUID, surfaceId: UUID?) -> String {
(surfaceId ?? workspaceId).uuidString
}

/// Returns whether a sound may play for the given key, recording a grant
/// when it does. `nil` keys are never deduplicated.
func shouldPlaySound(forKey key: String?, now: Date = Date()) -> Bool {
guard let key, !key.isEmpty else { return true }
lock.lock()
defer { lock.unlock() }
if let lastGrant = lastGrantDateByKey[key],
abs(now.timeIntervalSince(lastGrant)) < suppressionWindow {
return false
}
if lastGrantDateByKey.count >= maxTrackedKeys {
lastGrantDateByKey = lastGrantDateByKey.filter {
abs(now.timeIntervalSince($0.value)) < suppressionWindow
}
}
lastGrantDateByKey[key] = now
return true
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 @unchecked Sendable papers over the actor isolation issue

TerminalNotificationSoundGate is a shared mutable reference type marked @unchecked Sendable to satisfy the concurrency checker without actually providing isolation. The cmux convention (and Swift 6 intent) for a shared mutable type whose callers are all @MainActor is to make the type itself @MainActor-isolated or an actor, letting the compiler verify the invariant. @unchecked Sendable turns a compile-time guarantee into a run-time assumption that the manual lock is sufficient — an assumption that won't hold once the lock is removed.

Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same rationale as the sibling thread: the type is accessed from both the main actor (store + feed delivery) and the CLI socket thread (ingestBlocking claim lifecycle), so it is genuinely cross-isolation shared state — the same shape as FeedCoordinator itself and PendingWaiter, both @unchecked Sendable with lock/queue discipline in this file. All mutable state is touched only inside the single NSLock.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fair point — if ingestBlocking runs off the main actor on the CLI socket thread, @MainActor isolation would be wrong and actor would require await at every call site in latency-sensitive paths. Matching the FeedCoordinator/PendingWaiter pattern is the right call here.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment thread Sources/TerminalNotificationStore.swift Outdated
Comment on lines +787 to +824
final class TerminalNotificationSoundGate: @unchecked Sendable {
static let shared = TerminalNotificationSoundGate()

let suppressionWindow: TimeInterval

private let lock = NSLock()
private var lastGrantDateByKey: [String: Date] = [:]
private let maxTrackedKeys = 128

init(suppressionWindow: TimeInterval = 2.0) {
self.suppressionWindow = suppressionWindow
}

/// The dedupe key for a notification target. Keyed by surface when known
/// so prompts on different panes of one workspace stay independent.
static func key(workspaceId: UUID, surfaceId: UUID?) -> String {
(surfaceId ?? workspaceId).uuidString
}

/// Returns whether a sound may play for the given key, recording a grant
/// when it does. `nil` keys are never deduplicated.
func shouldPlaySound(forKey key: String?, now: Date = Date()) -> Bool {
guard let key, !key.isEmpty else { return true }
lock.lock()
defer { lock.unlock() }
if let lastGrant = lastGrantDateByKey[key],
abs(now.timeIntervalSince(lastGrant)) < suppressionWindow {
return false
}
if lastGrantDateByKey.count >= maxTrackedKeys {
lastGrantDateByKey = lastGrantDateByKey.filter {
abs(now.timeIntervalSince($0.value)) < suppressionWindow
}
}
lastGrantDateByKey[key] = now
return true
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Singleton side-channel patches symptom without fixing the invariant

The fix introduces TerminalNotificationSoundGate.shared — a global mutable singleton — to wire a shared side-channel between TerminalNotificationStore and FeedCoordinator. Both paths already post to UNUserNotificationCenter independently; the gate adds a third coordinating artefact rather than naming which path owns notification delivery for a blocking prompt and routing through it exclusively. A follow-on caller that posts a third banner (or any path that bypasses the gate) will double-ding again. The "Known behavior change" note in the PR also acknowledges that two legitimate distinct notifications on the same surface within 2 s will have one sound silenced — a user-visible regression on surfaces with high event cadence.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed that the long-term fix is consolidating to a single delivery funnel — that's what #2322 calls for ("consolidating to a single notification path"). This PR is deliberately scoped to a deterministic, minimal contract that can land safely now: feed banner canonical for blocking decisions, store stands down while claimed, raced banners withdrawn, single sound guaranteed. Happy to follow up with the funnel consolidation if maintainers want to go that way.

Replaces the race-dependent sound-only dedupe with a deterministic
contract: while a blocking decision claims a surface, the store keeps
its sidebar record but stands down from desktop banners, sounds, and
notification commands; if the agent-hook banner raced ahead of the
claim, the feed withdraws it from Notification Center when posting its
own actionable banner. Claims are refcounted for overlapping decisions
and time-boxed so a decision answered in the terminal (which stays
pending app-side until the hook timeout) cannot swallow legitimate
later alerts like the completion banner. The shared per-surface sound
window remains as the last-resort guarantee of a single ding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@danmirman danmirman changed the title Silence duplicate notification sound when a blocking prompt posts two banners Post one banner and one sound for blocking agent prompts Jun 10, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Feed/FeedCoordinator.swift`:
- Around line 159-170: The code is claiming the TerminalNotificationDeliveryGate
unconditionally
(TerminalNotificationDeliveryGate.shared.beginBlockingDecision(forKey:
deliveryGateKey)) before we know feed banner will actually replace the store
banner; only claim the gate when the same predicate used by
postNotificationIfStillAwaiting allows posting (i.e. mirror the eligibility
checks around resolvedAttentionTarget/NSApp.isActive and the
TerminalNotificationStore key-window terminal check), and ensure you call
beginBlockingDecision only when that predicate is true and release it when the
predicate fails or you bail out; update the logic around resolvedAttentionTarget
and the NSApp.isActive / TerminalNotificationStore key-window check so they are
identical, and add a regression test covering the case “cmux is active but the
key window is not a terminal window” to verify no gate is claimed and both
banner/sound/notification behavior matches expected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b48536c2-fead-48dd-8bea-3e6bdcb3fcfe

📥 Commits

Reviewing files that changed from the base of the PR and between 9af3f90 and 48bd85f.

📒 Files selected for processing (3)
  • Sources/Feed/FeedCoordinator.swift
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/NotificationSoundSettingsTests.swift

Comment on lines +159 to +170
// While this decision is pending, this banner is the canonical
// desktop alert for the surface: the same prompt also fires the
// agent's notification hook, which would otherwise post a second
// banner (and second sound) through TerminalNotificationStore
// (manaflow-ai/cmux#2322). Claim the surface for the decision's
// lifetime so the store keeps its sidebar record but stands down
// from desktop alerts.
let deliveryGateKey = resolvedAttentionTarget.map {
TerminalNotificationDeliveryGate.key(workspaceId: $0.workspaceId, surfaceId: $0.surfaceId)
}
TerminalNotificationDeliveryGate.shared.beginBlockingDecision(forKey: deliveryGateKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only claim the delivery gate when the feed banner can actually replace the store banner.

Line 169 starts suppressing the store path before we know whether postNotificationIfStillAwaiting will post any desktop alert. Later, Lines 744-747 bail out whenever NSApp.isActive is true, but Sources/TerminalNotificationStore.swift explicitly treats non-terminal key windows as not focused so the store path remains the only banner/sound path in that state. With the new guard on Lines 2108-2110 there, a blocking prompt opened while Settings/About/debug UI is frontmost now produces no banner, no sound, and no notification command at all.

Please align the claim lifecycle with the same predicate that makes the feed banner eligible to post, and add a regression for the “cmux is active but the key window is not a terminal window” case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Feed/FeedCoordinator.swift` around lines 159 - 170, The code is
claiming the TerminalNotificationDeliveryGate unconditionally
(TerminalNotificationDeliveryGate.shared.beginBlockingDecision(forKey:
deliveryGateKey)) before we know feed banner will actually replace the store
banner; only claim the gate when the same predicate used by
postNotificationIfStillAwaiting allows posting (i.e. mirror the eligibility
checks around resolvedAttentionTarget/NSApp.isActive and the
TerminalNotificationStore key-window terminal check), and ensure you call
beginBlockingDecision only when that predicate is true and release it when the
predicate fails or you bail out; update the logic around resolvedAttentionTarget
and the NSApp.isActive / TerminalNotificationStore key-window check so they are
identical, and add a regression test covering the case “cmux is active but the
key window is not a terminal window” to verify no gate is claimed and both
banner/sound/notification behavior matches expected.

@teamleaderleo teamleaderleo added area: notifications Notifications, banners, badges, the bell review: needs-attention Actionable automated review finding needs an author reply S2: major A crash, hang, lost state, broken connection, or a regression on a path people use labels Sep 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: notifications Notifications, banners, badges, the bell review: needs-attention Actionable automated review finding needs an author reply S2: major A crash, hang, lost state, broken connection, or a regression on a path people use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants