From 5b341ff163d996197e357638f8d187ad63aa9fb7 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:29:17 +0000 Subject: [PATCH] fix(notifications): stop clearing phone alerts that were never sent (#37095) Reading an agent on the desktop rebuilt notification ids for turns that never alerted (every chat pane, silenced workers) and forwarded each one to the phone as a silent dismiss push plus a dismissal-store record. Only forward a clear when the store holds a live delivery for that id. --- .../notifications-subject-retirement.test.ts | 30 +++++++++++++++++++ .../mobile-notification-dismissal-store.ts | 20 +++++++++---- .../runtime-mobile-notification-controller.ts | 4 +++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/notifications-subject-retirement.test.ts b/src/main/ipc/notifications-subject-retirement.test.ts index c3374b5b7ae..8a511d04569 100644 --- a/src/main/ipc/notifications-subject-retirement.test.ts +++ b/src/main/ipc/notifications-subject-retirement.test.ts @@ -141,6 +141,36 @@ describe('notifications:dismiss by acknowledged subject', () => { expect(dismissMobileNotification).toHaveBeenCalledWith('agent:phone-only') }) + // Reads rebuild ids for turns that never alerted; each forwarded one was a silent push and a store slot. + it('clears on the phone only alerts that reached it, including ones sent before a restart', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-clear-delivered-')) + const original = new RuntimeMobileNotificationController() + const restarted = new RuntimeMobileNotificationController() + try { + original.configureDismissalStore(directory) + original.dispatch({ + type: 'notification', + source: 'agent-task-complete', + title: 'Done', + body: '', + notificationId: 'agent:sent' + }) + await original.closeDismissals() + restarted.configureDismissalStore(directory) + const events: MobileNotificationEvent[] = [] + restarted.onDispatched((event) => events.push(event)) + register({ suppressWhenFocused: false }, restarted) + getDismissHandler()({}, ['agent:never-sent', 'agent:sent'], [PANE]) + getDismissHandler()({}, ['agent:sent'], [PANE]) + expect(events.map((event) => [event.type, event.notificationId])).toEqual([ + ['dismiss', 'agent:sent'] + ]) + } finally { + await restarted.closeDismissals() + rmSync(directory, { recursive: true, force: true }) + } + }) + it('records nothing for a request neither the desktop nor the phone announced', async () => { const { dismissMobileNotification } = register({ suppressWhenFocused: false }) const dispatch = getDispatchHandler() diff --git a/src/main/runtime/mobile-notification-dismissal-store.ts b/src/main/runtime/mobile-notification-dismissal-store.ts index b429d06d8de..4a659766dc3 100644 --- a/src/main/runtime/mobile-notification-dismissal-store.ts +++ b/src/main/runtime/mobile-notification-dismissal-store.ts @@ -121,15 +121,19 @@ export class MobileNotificationDismissalStore { return this.flush() } + /** Unreadable history cannot rule a delivery out. */ + hasLiveDelivery(notificationId: string): boolean { + const now = Date.now() + return ( + this.unreadable || + this.entries.some((entry) => entry.notificationId === notificationId && isLive(entry, now)) + ) + } + liveDeliveries(prefix = ''): DeliveredNotificationRecord[] { const now = Date.now() return this.entries - .filter( - (entry) => - entry.expiresAt > now && - entry.notificationId.startsWith(prefix) && - entry.dismissedThrough < entry.notificationSeq - ) + .filter((entry) => entry.notificationId.startsWith(prefix) && isLive(entry, now)) .map(({ notificationId, notificationEpoch, notificationSeq, structuredOrigin }) => ({ notificationId, notificationEpoch, @@ -153,6 +157,10 @@ export class MobileNotificationDismissalStore { } } +function isLive(entry: RecordEntry, now: number): boolean { + return entry.expiresAt > now && entry.dismissedThrough < entry.notificationSeq +} + /** Keep records with unknown future origin kinds, degrading only their origin to none. */ function readEntry(value: unknown): RecordEntry[] { if (!isEntry(value)) { diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index 8bbdbd8af8d..32ba39b8698 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -188,6 +188,10 @@ export class RuntimeMobileNotificationController { } dismiss(notificationId: string): void { + // Reads rebuild ids for alerts that never went out (chat panes, silenced workers); each would cost a silent push. + if (this.dismissalStore && !this.dismissalStore.hasLiveDelivery(notificationId)) { + return + } this.dispatch({ type: 'dismiss', notificationId }) }