Simplify mobile push delivery and mirror desktop notification categories

This commit is contained in:
Jinwoo-H
2026-09-09 03:17:40 -04:00
parent 35864ab077
commit 2c0dcc83d1
75 changed files with 1011 additions and 5411 deletions
+14 -5
View File
@@ -35,17 +35,26 @@ the repository's root [MIT license](../LICENSE).
Orca credential for it: the desktop host authenticates with the same X25519
key it uses for the relay, answering an encrypted challenge to mint a 24 hour
session, then registers each paired phone's native push token and asks the
gateway to push. The gateway coalesces a burst per registration into one
notification, enforces per-host and per-registration quotas, and retires a
gateway to push. The gateway queues each event as its own notification,
enforces per-host quotas and request limits, and retires a
registration as soon as Apple or Google reports the token unregistered.
Provider push is the only ordinary mobile OS-banner path. The notification
socket is retained only for live dismissal and reconnect tray reconciliation;
it never creates or recovers banners. Desktop notification categories remain
authoritative.
During a rolling gateway update, old workers can still use their former summary
and collapse behavior; the individual-presentation guarantee starts only after
those revisions retire.
Legacy category and summary fields remain only for mixed-version compatibility.
FCM notification messages are inherently collapsible while offline and have a
small concurrent collapse-key budget, so every pending alert is not guaranteed.
Storage follows the relay pattern: PostgreSQL in production, SQLite for tests
and local development. Configure it with `ORCA_PUSH_PUBLIC_URL`,
`ORCA_PUSH_DATABASE_URL`, the three APNs variables (`ORCA_PUSH_APNS_KEY`,
`ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`, all three or none), and
optionally `ORCA_PUSH_APNS_TOPIC`, `ORCA_PUSH_FCM_PROJECT_ID`, and
`ORCA_PUSH_COALESCE_MS`. The FCM credential comes from the runtime service
account, so no key material is configured for Android. The full contract lives
optionally `ORCA_PUSH_APNS_TOPIC` and `ORCA_PUSH_FCM_PROJECT_ID`. The FCM credential comes from
the runtime service account, so no key material is configured for Android. The full contract lives
in `docs/reference/mobile-push-contract.md` at the repository root.
Logging is aggregate counters only. Tokens, notification titles, notification
+6 -10
View File
@@ -17,7 +17,7 @@ function credentials(): ApnsCredentials {
return { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' }
}
function delivery(coalescedCount = 1) {
function delivery() {
return buildPushDelivery({
registrationId: 'reg-1',
hostFingerprint: HOST,
@@ -30,10 +30,7 @@ function delivery(coalescedCount = 1) {
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
},
title: 'Agent needs input',
body: 'Waiting on your answer',
coalescedCount
}
})
}
@@ -108,22 +105,21 @@ describe('apns client', () => {
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
coalescedCount: 1
agentState: 'needs-input'
}
})
})
it('targets the sandbox host and the host collapse id for a summary', async () => {
it('targets the sandbox host and keeps the individual collapse id', async () => {
const fake = fakeTransport({ status: 200, body: '' })
const client = new ApnsClient({
topic: 'com.stably.orca.mobile',
credentials: credentials(),
transport: fake.transport
})
await client.send(delivery(3), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
await client.send(delivery(), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' })
expect(fake.requests[0]?.host).toBe('api.sandbox.push.apple.com')
expect(fake.requests[0]?.headers['apns-collapse-id']).toBe(`host:${HOST}`)
expect(fake.requests[0]?.headers['apns-collapse-id']).toMatch(/^[a-f0-9]{64}$/)
})
it.each([
+2 -6
View File
@@ -1,5 +1,5 @@
import { generateKeyPairSync } from 'node:crypto'
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract'
import { describe, expect, it } from 'vitest'
import { loadPushConfig, PUSH_DATABASE_POOL_MAX } from './config.js'
@@ -25,7 +25,6 @@ describe('push gateway config', () => {
apns: undefined,
apnsTopic: PUSH_DEFAULTS.apnsTopic,
fcmProjectId: PUSH_DEFAULTS.fcmProjectId,
coalesceMs: PUSH_LIMITS.coalesceWindowMs,
trustedProxyHops: 0
})
})
@@ -42,7 +41,6 @@ describe('push gateway config', () => {
ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456',
ORCA_PUSH_APNS_TOPIC: 'com.stably.orca.mobile.dev',
ORCA_PUSH_FCM_PROJECT_ID: 'onorca-staging',
ORCA_PUSH_COALESCE_MS: '1500',
ORCA_PUSH_TRUSTED_PROXY_HOPS: '1'
})
expect(config).toMatchObject({
@@ -52,8 +50,7 @@ describe('push gateway config', () => {
apns: { keyPem, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
apnsTopic: 'com.stably.orca.mobile.dev',
trustedProxyHops: 1,
fcmProjectId: 'onorca-staging',
coalesceMs: 1500
fcmProjectId: 'onorca-staging'
})
})
@@ -97,7 +94,6 @@ it('treats blank defaulted environment settings as absent', () => {
'ORCA_PUSH_DATA_DIR',
'ORCA_PUSH_APNS_TOPIC',
'ORCA_PUSH_FCM_PROJECT_ID',
'ORCA_PUSH_COALESCE_MS',
'ORCA_PUSH_DATABASE_POOL_MAX',
'ORCA_PUSH_TRUSTED_PROXY_HOPS'
].map((key) => [key, ' '])
+1 -9
View File
@@ -1,4 +1,4 @@
import { PUSH_DEFAULTS, PUSH_LIMITS } from '@orca-cloud/push-contract'
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract'
import { z } from 'zod'
export const PUSH_DATABASE_POOL_MAX = 10
@@ -35,12 +35,6 @@ const EnvSchema = z.object({
.string()
.regex(/^[a-z0-9-]{4,64}$/)
.default(PUSH_DEFAULTS.fcmProjectId),
ORCA_PUSH_COALESCE_MS: z.coerce
.number()
.int()
.nonnegative()
.max(60_000)
.default(PUSH_LIMITS.coalesceWindowMs),
// How many proxies append to x-forwarded-for after the client. 0 is Cloud Run
// alone; raise it to 1 when a load balancer fronts the service.
ORCA_PUSH_TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().max(8).default(0)
@@ -58,7 +52,6 @@ export type PushConfig = {
apns?: ApnsCredentials
apnsTopic: string
fcmProjectId: string
coalesceMs: number
trustedProxyHops: number
}
@@ -113,7 +106,6 @@ export function loadPushConfig(env: NodeJS.ProcessEnv = process.env): PushConfig
apns: readApnsCredentials(parsed),
apnsTopic: parsed.ORCA_PUSH_APNS_TOPIC,
fcmProjectId: parsed.ORCA_PUSH_FCM_PROJECT_ID,
coalesceMs: parsed.ORCA_PUSH_COALESCE_MS,
trustedProxyHops: parsed.ORCA_PUSH_TRUSTED_PROXY_HOPS
}
}
+187 -18
View File
@@ -60,42 +60,180 @@ describe('durable push acceptance', () => {
expect(await store.accept('host', 'phone1', notification(301))).toBe('queued')
})
it('recovers acknowledged work and coalesces across independent service instances', async () => {
it('queues one delivery per event and recovers work across service instances', async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
const restarted = new DurablePushStore(db, clock)
await restarted.accept('host', 'phone', notification(1))
advance(1)
await restarted.accept('host', 'phone', notification(2))
advance(3000)
const batch = await restarted.claim()
expect(batch?.notifications).toHaveLength(2)
const rows = await db.query(
"SELECT payload_json, due_at, created_at FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending'",
['phone']
)
expect(rows).toHaveLength(2)
expect(
rows.every((row) => (JSON.parse(String(row.payload_json)) as unknown[]).length === 1)
).toBe(true)
expect(rows.every((row) => Number(row.due_at) === 0)).toBe(true)
const delivery = await restarted.claim()
expect(delivery?.notification.notificationSeq).toBe(1)
expect(await store.claim()).toBeNull()
advance(DELIVERY_LEASE_MS)
const reclaimed = await store.claim()
expect(reclaimed?.id).toBe(batch?.id)
expect(reclaimed?.lease).not.toBe(batch?.lease)
await restarted.finish(batch!)
expect(reclaimed?.id).toBe(delivery?.id)
expect(reclaimed?.lease).not.toBe(delivery?.lease)
await restarted.finish(delivery!)
expect(await store.claim()).toBeNull()
await store.finish(reclaimed!)
advance(DELIVERY_LEASE_MS)
const second = await restarted.claim()
expect(second?.notification.notificationSeq).toBe(2)
await restarted.finish(second!)
expect(await restarted.claim()).toBeNull()
})
it('keeps zero-marked new rows out of an old writer coalescing lookup', async () => {
const { db, store, clock } = await fixture()
await store.accept('host', 'phone', notification(1))
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES ('legacy-row', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 0, ?)`,
[JSON.stringify([notification(2)]), clock() + 3000, clock() + 300_000, clock()]
)
const oldWriterRows = await db.query(
"SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND kind = 'alert' AND state = 'pending' AND attempts = 0 AND due_at > ?",
['phone', clock()]
)
expect(oldWriterRows.map((row) => row.batch_id)).toEqual(['legacy-row'])
})
it('atomically splits a legacy summary without changing its deadline', async () => {
const { db, store, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const rows = await db.query(
"SELECT * FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' ORDER BY created_at",
['phone']
)
const deadline = Number(rows[0]!.expires_at)
await db.query(
'UPDATE push_delivery_batches SET payload_json = ?, attempts = 2 WHERE batch_id = ?',
[JSON.stringify([notification(1), notification(2)]), rows[0]!.batch_id]
)
await db.query('DELETE FROM push_delivery_batches WHERE batch_id = ?', [rows[1]!.batch_id])
expect(await store.accept('host', 'phone', notification(2))).toBe('queued')
expect(await store.pendingCount('phone')).toBe(2)
const first = (await store.claim())!
expect(first.notification.notificationSeq).toBe(1)
expect(first.expiresAt).toBe(deadline)
expect(first.attempts).toBe(3)
await store.finish(first)
const second = (await store.claim())!
expect(second.notification.notificationSeq).toBe(2)
expect(second.expiresAt).toBe(deadline)
expect(second.attempts).toBe(3)
await store.finish(second)
expect(await store.claim()).toBeNull()
})
it('never extends expiry and refuses conflicting duplicate content', async () => {
const { store, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
expect(await store.accept('host', 'phone', { ...notification(1), body: 'changed' })).toBe(
'error'
)
advance(3000)
const batch = (await store.claim())!
await store.finish(batch, 10 * 60_000)
const delivery = (await store.claim())!
await store.finish(delivery, 10 * 60_000)
advance(60_000)
expect(await store.claim()).toBeNull()
advance(5 * 60_000)
expect(await store.accept('host', 'phone', notification(1))).toBe('error')
})
it('orders a due retry before a fresh first attempt without delaying the retry', async () => {
const { store, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
const first = (await store.claim())!
await store.finish(first, 1000)
expect(await store.claim()).toBeNull()
advance(1000)
await store.accept('host', 'phone', notification(2))
const retry = (await store.claim())!
expect(retry.notification.notificationSeq).toBe(1)
await store.finish(retry)
const fresh = (await store.claim())!
expect(fresh?.notification.notificationSeq).toBe(2)
await store.finish(fresh!)
})
it('orders an expired first-attempt lease by creation time after a retry becomes due', async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
const retry = (await store.claim())!
await store.finish(retry, 1000)
advance(2000)
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES ('crashed-singleton', 'host', 'phone', 'alert', ?, 'pending', 0, ?, 0, 1, ?)`,
[JSON.stringify([notification(2)]), clock() + 300_000, clock()]
)
const reclaimedRetry = (await store.claim())!
expect(reclaimedRetry.notification.notificationSeq).toBe(1)
await store.finish(reclaimedRetry)
const reclaimedCrash = (await store.claim())!
expect(reclaimedCrash.notification.notificationSeq).toBe(2)
await store.finish(reclaimedCrash)
})
it('keeps every legacy member ahead of a same-ID event accepted one millisecond later', async () => {
const { db, store, advance } = await fixture()
const oldA = { ...notification(1), notificationId: 'A' }
const oldC = { ...notification(2), notificationId: 'C' }
const oldB = { ...notification(3), notificationId: 'B' }
const newerB = { ...notification(4), notificationId: 'B' }
await store.accept('host', 'phone', oldA)
const [row] = await db.query(
"SELECT batch_id FROM push_delivery_batches WHERE registration_id = 'phone' AND state = 'pending'"
)
await db.query('UPDATE push_delivery_batches SET payload_json = ?, due_at = ? WHERE batch_id = ?', [
JSON.stringify([oldA, oldC, oldB]),
1_003_000,
row!.batch_id
])
advance(1)
await store.accept('host', 'phone', newerB)
const first = (await store.claim())!
expect(first.notification.notificationSeq).toBe(1)
const split = await db.query(
"SELECT batch_id, created_at FROM push_delivery_batches WHERE batch_id LIKE ? ORDER BY batch_id",
[`${String(row!.batch_id)}:legacy:%`]
)
expect(
split.map((item) => ({
batchId: String(item.batch_id),
createdAt: Number(item.created_at)
}))
).toEqual([
{ batchId: `${String(row!.batch_id)}:legacy:01`, createdAt: 1_000_000 },
{ batchId: `${String(row!.batch_id)}:legacy:02`, createdAt: 1_000_000 }
])
await store.finish(first)
const second = (await store.claim())!
expect(second.notification.notificationSeq).toBe(2)
await store.finish(second)
const third = (await store.claim())!
expect(third.notification.notificationSeq).toBe(3)
await store.finish(third)
const fourth = (await store.claim())!
expect(fourth.notification.notificationSeq).toBe(4)
await store.finish(fourth)
})
it('rolls quota and payload back together if persistence fails', async () => {
const { db, store } = await fixture()
await db.query('ALTER TABLE push_delivery_batches RENAME TO push_delivery_batches_unavailable')
@@ -120,26 +258,57 @@ it('serializes concurrent instances at the quota boundary', async () => {
})
it('cancels unsent alerts and prevents an older replay after dismissal', async () => {
const { store, advance } = await fixture()
const { store } = await fixture()
const alert = notification(1)
await store.accept('host', 'phone', alert)
await store.accept('host', 'phone', {
...notification(2, 'dismiss'),
notificationId: alert.notificationId
})
advance(3000)
const batch = (await store.claim())!
expect(batch.notifications.map((item) => item.kind)).toEqual(['dismiss'])
await store.finish(batch)
const delivery = (await store.claim())!
expect(delivery.notification.kind).toBe('dismiss')
await store.finish(delivery)
expect(await store.claim()).toBeNull()
await store.accept('host', 'another-phone', alert)
expect(await store.claim()).toBeNull()
})
it('cancels one member of a legacy queued summary without dropping the others', async () => {
const { db, store, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const rows = await db.query(
"SELECT batch_id FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' ORDER BY created_at",
['phone']
)
await db.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [
JSON.stringify([notification(1), notification(2)]),
rows[0]!.batch_id
])
await db.query('DELETE FROM push_delivery_batches WHERE batch_id = ?', [rows[1]!.batch_id])
advance(1)
await store.accept('host', 'phone', {
...notification(3, 'dismiss'),
notificationId: notification(1).notificationId
})
const delivered: PushNotification[] = []
for (;;) {
const queued = await store.claim()
if (!queued) break
delivered.push(queued.notification)
await store.finish(queued)
}
expect(delivered.map((item) => [item.kind ?? 'alert', item.notificationSeq])).toEqual([
['alert', 2],
['dismiss', 3]
])
})
it('does not resurrect an in-flight alert after a dismissal and transient provider failure', async () => {
const { store, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(3000)
const inFlight = (await store.claim())!
await store.accept('host', 'phone', {
...notification(2, 'dismiss'),
@@ -147,7 +316,7 @@ it('does not resurrect an in-flight alert after a dismissal and transient provid
})
await store.finish(inFlight, 1000)
const dismissal = (await store.claim())!
expect(dismissal.notifications[0]?.kind).toBe('dismiss')
expect(dismissal.notification.kind).toBe('dismiss')
await store.finish(dismissal)
advance(1000)
expect(await store.claim()).toBeNull()
+63 -62
View File
@@ -1,16 +1,16 @@
import { canCoalescePushNotifications } from './push-delivery-message.js'
import { reconcileQueuedDismissal, removeDismissedAlerts } from './push-queued-dismissal.js'
import { createHash, randomUUID } from 'node:crypto'
import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract'
import type { PushDatabase, SqlRow } from './push-database.js'
const RETENTION_MS = 24 * 60 * 60_000
const LEGACY_BATCH_MAX_NOTIFICATIONS = 32
export const DELIVERY_LEASE_MS = 30_000
export type DeliveryBatch = {
export type QueuedPushDelivery = {
id: string
registrationId: string
hostFingerprint: string
notifications: PushNotification[]
notification: PushNotification
expiresAt: number
lease: string
attempts: number
@@ -19,8 +19,7 @@ export type DeliveryBatch = {
export class DurablePushStore {
constructor(
private readonly database: PushDatabase,
private readonly now = Date.now,
private readonly coalesceMs: number = PUSH_LIMITS.coalesceWindowMs
private readonly now = Date.now
) {}
async accept(
@@ -66,46 +65,22 @@ export class DurablePushStore {
if (recipient) return 'queued'
if (await reconcileQueuedDismissal(tx, host, registrationId, notification, now))
return 'queued'
const [batch] =
kind === 'alert'
? await tx.query(
"SELECT * FROM push_delivery_batches WHERE registration_id = ? AND kind = ? AND state = 'pending' AND attempts = 0 AND due_at > ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1",
[registrationId, kind, now, now]
)
: []
const notifications = batch
? [...(JSON.parse(String(batch.payload_json)) as PushNotification[]), notification]
: [notification]
if (batch && canCoalescePushNotifications(notifications, host)) {
await tx.query(
'UPDATE push_delivery_batches SET payload_json = ?, expires_at = ? WHERE batch_id = ?',
[
JSON.stringify(notifications),
Math.min(expiresAt, Number(batch.expires_at)),
batch.batch_id
]
)
} else {
if (batch)
await tx.query('UPDATE push_delivery_batches SET due_at = ? WHERE batch_id = ?', [
now,
batch.batch_id
])
await tx.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, 0, ?)`,
[
randomUUID(),
host,
registrationId,
kind,
JSON.stringify([notification]),
now + (kind === 'dismiss' ? 0 : this.coalesceMs),
expiresAt,
now
]
)
}
await tx.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, 0, ?)`,
[
randomUUID(),
host,
registrationId,
kind,
// Keep the persisted envelope readable by workers from the previous release.
JSON.stringify([notification]),
// Zero marks a singleton so an overlapping old gateway will not append to it.
0,
expiresAt,
now
]
)
await tx.query(
'INSERT INTO push_event_recipients(event_id, registration_id, created_at) VALUES (?, ?, ?)',
[eventId, registrationId, now]
@@ -114,15 +89,15 @@ export class DurablePushStore {
})
}
async claim(): Promise<DeliveryBatch | null> {
async claim(): Promise<QueuedPushDelivery | null> {
return this.database.transaction(async (tx) => {
await tx.lockQuotaScope('push-worker-claim')
const now = this.now()
const params = [now, now, now, now]
const predicate =
"state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)"
"state = 'pending' AND lease_until <= ? AND expires_at > ? AND (attempts = 0 OR due_at <= ?) AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)"
let [row] = await tx.query(
`SELECT * FROM push_delivery_batches WHERE ${predicate} ORDER BY due_at, created_at LIMIT 1`,
`SELECT * FROM push_delivery_batches WHERE ${predicate} ORDER BY CASE WHEN attempts = 0 OR due_at = 0 THEN created_at ELSE due_at END, created_at, batch_id LIMIT 1`,
params
)
if (!row) return null
@@ -131,64 +106,90 @@ export class DurablePushStore {
row.batch_id
])
if (!row || row.state !== 'pending' || Number(row.expires_at) <= now) return null
const notifications = await removeDismissedAlerts(
const notifications = JSON.parse(String(row.payload_json)) as PushNotification[]
if (notifications.length > LEGACY_BATCH_MAX_NOTIFICATIONS) {
throw new Error('legacy_push_delivery_exceeds_member_limit')
}
const deliverable = await removeDismissedAlerts(
tx,
String(row.host_fingerprint),
JSON.parse(String(row.payload_json)) as PushNotification[]
notifications
)
if (!notifications.length) {
if (!deliverable.length) {
await tx.query(
"UPDATE push_delivery_batches SET state = 'dismissed', payload_json = '[]' WHERE batch_id = ?",
[row.batch_id]
)
return null
}
row.payload_json = JSON.stringify(notifications)
const [notification, ...legacyRemainder] = deliverable
row.payload_json = JSON.stringify([notification])
await tx.query('UPDATE push_delivery_batches SET payload_json = ? WHERE batch_id = ?', [
row.payload_json,
row.batch_id
])
for (const [index, queued] of legacyRemainder.entries()) {
await tx.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, ?, ?)`,
[
`${String(row.batch_id)}:legacy:${String(index + 1).padStart(2, '0')}`,
row.host_fingerprint,
row.registration_id,
queued.kind ?? 'alert',
JSON.stringify([queued]),
row.due_at,
row.expires_at,
row.attempts,
row.created_at
]
)
}
const lease = randomUUID()
await tx.query(
'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?',
[lease, now + DELIVERY_LEASE_MS, row.batch_id]
)
return this.batch(row, lease)
return this.delivery(row, lease)
})
}
private batch(row: SqlRow, lease: string): DeliveryBatch {
private delivery(row: SqlRow, lease: string): QueuedPushDelivery {
return {
id: String(row.batch_id),
registrationId: String(row.registration_id),
hostFingerprint: String(row.host_fingerprint),
notifications: JSON.parse(String(row.payload_json)) as PushNotification[],
notification: (JSON.parse(String(row.payload_json)) as PushNotification[])[0]!,
expiresAt: Number(row.expires_at),
lease,
attempts: Number(row.attempts) + 1
}
}
async renew(batch: DeliveryBatch): Promise<void> {
async renew(delivery: QueuedPushDelivery): Promise<void> {
await this.database.query(
'UPDATE push_delivery_batches SET lease_until = ? WHERE batch_id = ? AND lease_token = ?',
[this.now() + DELIVERY_LEASE_MS, batch.id, batch.lease]
[this.now() + DELIVERY_LEASE_MS, delivery.id, delivery.lease]
)
}
async finish(batch: DeliveryBatch, retryAfterMs?: number, outcome = 'done'): Promise<void> {
async finish(
delivery: QueuedPushDelivery,
retryAfterMs?: number,
outcome = 'done'
): Promise<void> {
const now = this.now()
const retryAt = retryAfterMs === undefined ? Infinity : now + Math.max(1000, retryAfterMs)
const retry = retryAt < batch.expiresAt
const retry = retryAt < delivery.expiresAt
await this.database.query(
`UPDATE push_delivery_batches SET state = ?, payload_json = ?, due_at = ?, lease_until = 0, lease_token = NULL
WHERE batch_id = ? AND lease_token = ?`,
[
retry ? 'pending' : retryAfterMs !== undefined ? 'expired' : outcome,
retry ? JSON.stringify(batch.notifications) : '[]',
retry ? JSON.stringify([delivery.notification]) : '[]',
retry ? retryAt : now,
batch.id,
batch.lease
delivery.id,
delivery.lease
]
)
}
+27 -44
View File
@@ -67,40 +67,36 @@ async function fixture() {
}
}
it('waits for the real coalescing deadline and sends complete summaries', async () => {
it('sends every burst event immediately with its original content and identity', async () => {
const h = await fixture()
await h.accept(note(1))
h.advance(2000)
await h.accept(note(2, { agentState: 'needs-input' }))
await h.accept(
note(2, { agentState: 'needs-input', title: 'Answer needed', body: 'Please respond' })
)
await h.worker.runDue()
expect(h.send).not.toHaveBeenCalled()
h.advance(1000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
expect(h.send.mock.calls[0]![0]).toMatchObject({
title: 'Orca',
body: '2 agents need attention',
orca: {
notificationSeq: 2,
coalescedCount: 2,
summaryMembers: [
{ notificationId: 'note-1', notificationSeq: 1, notificationEpoch: 'epoch' },
{ notificationId: 'note-2', notificationSeq: 2, notificationEpoch: 'epoch' }
]
}
})
await h.accept(note(3))
h.advance(3000)
await h.worker.runDue()
expect(h.send.mock.calls[1]![0]).toMatchObject({
expect(h.send).toHaveBeenCalledTimes(2)
const first = h.send.mock.calls.find(([delivery]) => delivery.orca.notificationSeq === 1)![0]
const second = h.send.mock.calls.find(([delivery]) => delivery.orca.notificationSeq === 2)![0]
expect(first).toMatchObject({
title: 'Done',
body: 'Finished task',
orca: { coalescedCount: 1 }
orca: {
notificationId: 'note-1',
notificationSeq: 1
}
})
expect(second).toMatchObject({
title: 'Answer needed',
body: 'Please respond',
orca: { notificationId: 'note-2', notificationSeq: 2 }
})
expect(first.collapseId).not.toBe(second.collapseId)
expect(h.send.mock.calls.every(([delivery]) => !('coalescedCount' in delivery.orca))).toBe(true)
expect(h.send.mock.calls.every(([delivery]) => !('summaryMembers' in delivery.orca))).toBe(true)
expect(h.onRetry).not.toHaveBeenCalled()
})
it('keeps untrackable bells individual and summaries scoped to each phone', async () => {
it('keeps untrackable bells and per-phone deliveries individually replaceable', async () => {
const h = await fixture()
const other = await h.devices.upsert({
hostFingerprint: 'host',
@@ -114,22 +110,15 @@ it('keeps untrackable bells individual and summaries scoped to each phone', asyn
await h.accept(note(2))
await h.accept(note(3))
await h.store.accept('host', other.registrationId, note(2))
h.advance(3000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledTimes(3)
expect(h.send).toHaveBeenCalledTimes(4)
const deliveries = h.send.mock.calls.map(([delivery]) => delivery)
expect(deliveries.find((delivery) => delivery.orca.source === 'terminal-bell')).toMatchObject({
collapseId: 'host:host',
orca: { coalescedCount: 1 }
})
expect(deliveries.find((delivery) => delivery.orca.coalescedCount === 2)).toMatchObject({
registrationId: h.registrationId,
body: '2 updates'
})
const primary = deliveries.filter((delivery) => delivery.registrationId === h.registrationId)
expect(primary).toHaveLength(3)
expect(new Set(primary.map((delivery) => delivery.collapseId)).size).toBe(3)
expect(
deliveries.find((delivery) => delivery.registrationId === other.registrationId)?.orca
.coalescedCount
).toBe(1)
deliveries.find((delivery) => delivery.registrationId === other.registrationId)
).toMatchObject({ orca: { notificationId: 'note-2', notificationSeq: 2 } })
})
it('persists provider retry delay and resumes it through a new worker', async () => {
@@ -141,7 +130,6 @@ it('persists provider retry delay and resumes it through a new worker', async ()
retryAfterMs: 10000
})
await h.accept(note(1))
h.advance(3000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
await h.worker.stop()
@@ -166,7 +154,6 @@ it('expires instead of shortening a provider delay beyond the delivery lifetime'
retryAfterMs: 600000
})
await h.accept(note(1))
h.advance(3000)
await h.worker.runDue()
h.advance(600000)
await h.worker.runDue()
@@ -179,7 +166,6 @@ it('rechecks the device before a persisted retry and does not send after unregis
const h = await fixture()
h.send.mockResolvedValue({ status: 'error', reason: 'timeout', retryable: true })
await h.accept(note(1))
h.advance(3000)
await h.worker.runDue()
await h.devices.deleteOwned('host', h.registrationId)
h.advance(3000)
@@ -202,11 +188,9 @@ it('joins active work on shutdown and leaves unclaimed work for the next instanc
})
})
await h.accept(note(1))
h.advance(3000)
const pending = h.worker.runDue()
await entered
await h.accept(note(2))
h.advance(3000)
let stopped = false
const stopping = h.worker.stop().then(() => {
stopped = true
@@ -230,7 +214,6 @@ it('runs due work on its timer and releases the timer on stop', async () => {
h.worker.start()
h.worker.start()
expect(vi.getTimerCount()).toBe(1)
h.advance(3000)
await vi.advanceTimersByTimeAsync(1000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
+14 -20
View File
@@ -1,4 +1,4 @@
import { buildPushDelivery, summaryBody } from './push-delivery-message.js'
import { buildPushDelivery } from './push-delivery-message.js'
import type { PushDispatcher } from './push-dispatcher.js'
import type { DurablePushStore } from './durable-push-store.js'
@@ -45,41 +45,35 @@ export class DurablePushWorker {
private async drain(): Promise<void> {
for (let count = 0; count < 25 && !this.stopped; count++) {
const batch = await this.store.claim()
if (!batch) return
const latest = batch.notifications.at(-1)!
const multiple = batch.notifications.length > 1
const queued = await this.store.claim()
if (!queued) return
const delivery = buildPushDelivery({
registrationId: batch.registrationId,
hostFingerprint: batch.hostFingerprint,
notification: latest,
title: multiple ? 'Orca' : latest.title,
body: multiple ? summaryBody(batch.notifications) : latest.body,
coalescedCount: batch.notifications.length,
notifications: batch.notifications
registrationId: queued.registrationId,
hostFingerprint: queued.hostFingerprint,
notification: queued.notification
})
delivery.expiresAt = batch.expiresAt
if ((this.options.now ?? Date.now)() >= batch.expiresAt) {
await this.store.finish(batch)
delivery.expiresAt = queued.expiresAt
if ((this.options.now ?? Date.now)() >= queued.expiresAt) {
await this.store.finish(queued)
continue
}
const heartbeat = setInterval(() => {
void this.store.renew(batch).catch(() => {})
void this.store.renew(queued).catch(() => {})
}, 10_000)
heartbeat.unref()
try {
if (batch.attempts > 1) this.options.onRetry?.()
if (queued.attempts > 1) this.options.onRetry?.()
const outcome = await this.dispatcher.sendOnce(delivery)
const retryAfterMs =
outcome.status === 'error' && outcome.retryable
? Math.max(
outcome.retryAfterMs ?? 0,
Math.min(30_000, 1000 * 2 ** Math.min(batch.attempts, 5))
Math.min(30_000, 1000 * 2 ** Math.min(queued.attempts, 5))
)
: undefined
await this.store.finish(batch, retryAfterMs, outcome.status)
await this.store.finish(queued, retryAfterMs, outcome.status)
} catch {
await this.store.finish(batch, 5000)
await this.store.finish(queued, 5000)
} finally {
clearInterval(heartbeat)
}
+10 -11
View File
@@ -6,7 +6,7 @@ import { buildPushDelivery } from './push-delivery-message.js'
const HOST = 'abcdefghijklmnop'
const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
function delivery(coalescedCount = 1, agentState: 'needs-input' | null = 'needs-input') {
function delivery(agentState: 'needs-input' | null = 'needs-input') {
return buildPushDelivery({
registrationId: 'reg-1',
hostFingerprint: HOST,
@@ -19,10 +19,7 @@ function delivery(coalescedCount = 1, agentState: 'needs-input' | null = 'needs-
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
},
title: coalescedCount > 1 ? 'Orca' : 'Agent needs input',
body: coalescedCount > 1 ? '3 agents need attention' : 'Waiting on your answer',
coalescedCount
}
})
}
@@ -85,8 +82,7 @@ describe('fcm client', () => {
notificationSeq: '7',
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
coalescedCount: '1'
agentState: 'needs-input'
}
}
})
@@ -94,7 +90,7 @@ describe('fcm client', () => {
it('carries every data value as a string and omits a null agent state', async () => {
const { fake, client: fcm } = client({ status: 200, body: '{}' })
await fcm.send(delivery(3, null), { token: TOKEN })
await fcm.send(delivery(null), { token: TOKEN })
const message = JSON.parse(fake.requests[0]!.body) as {
message: {
android: { collapse_key: string; notification: { tag: string } }
@@ -105,9 +101,12 @@ describe('fcm client', () => {
true
)
expect(message.message.data.agentState).toBeUndefined()
expect(message.message.data.coalescedCount).toBe('3')
expect(message.message.android.notification.tag).toBe(`host:${HOST}`)
expect(message.message.android.collapse_key).toBe(fcmCollapseKey(`host:${HOST}`))
const tag = createHash('sha256')
.update(JSON.stringify([HOST, 'note-1']))
.digest('hex')
expect(message.message.data.coalescedCount).toBeUndefined()
expect(message.message.android.notification.tag).toBe(tag)
expect(message.message.android.collapse_key).toBe(fcmCollapseKey(tag))
expect(message.message.android.collapse_key).toHaveLength(32)
})
@@ -38,10 +38,7 @@ async function registered() {
const delivery = buildPushDelivery({
registrationId: row.registrationId,
hostFingerprint: input.hostFingerprint,
notification: note,
title: note.title,
body: note.body,
coalescedCount: 1
notification: note
})
return { db, devices, input, delivery }
}
+11 -76
View File
@@ -1,6 +1,5 @@
import { createHash } from 'node:crypto'
import { type PushNotification } from '@orca-cloud/push-contract'
import { pushSummaryMembers, type PushSummaryMember } from './push-summary-members.js'
export type PushOrcaData = {
kind?: 'alert' | 'dismiss'
@@ -11,8 +10,6 @@ export type PushOrcaData = {
notificationEpoch: string
source: string
agentState: string | null
coalescedCount: number
summaryMembers?: PushSummaryMember[]
}
export type PushDelivery = {
@@ -26,31 +23,13 @@ export type PushDelivery = {
orca: PushOrcaData
}
export function hostCollapseId(hostFingerprint: string): string {
return `host:${hostFingerprint}`
}
// APNs rejects a collapse id over 64 bytes, and notification ids are opaque
// desktop strings that may be longer or carry multi-byte characters.
export function truncateUtf8(value: string, maxBytes: number): string {
const encoded = Buffer.from(value, 'utf8')
if (encoded.byteLength <= maxBytes) return value
let end = maxBytes
// Walk back off a continuation byte so the cut never splits a code point.
while (end > 0 && (encoded[end]! & 0b1100_0000) === 0b1000_0000) end -= 1
return encoded.subarray(0, end).toString('utf8')
}
export function collapseIdFor(
notification: PushNotification,
hostFingerprint: string,
coalescedCount: number
): string {
if (coalescedCount > 1 || notification.notificationId === undefined) {
return hostCollapseId(hostFingerprint)
}
export function collapseIdFor(notification: PushNotification, hostFingerprint: string): string {
const identity =
notification.notificationId === undefined
? [notification.notificationEpoch, notification.notificationSeq]
: notification.notificationId
return createHash('sha256')
.update(JSON.stringify([hostFingerprint, notification.notificationId]))
.update(JSON.stringify([hostFingerprint, identity]))
.digest('hex')
}
@@ -58,24 +37,15 @@ export function buildPushDelivery(input: {
registrationId: string
hostFingerprint: string
notification: PushNotification
title: string
body: string
coalescedCount: number
notifications?: readonly PushNotification[]
}): PushDelivery {
const { notification, hostFingerprint, coalescedCount } = input
const summaryMembers = input.notifications ? pushSummaryMembers(input.notifications) : undefined
const { notification, hostFingerprint } = input
return {
...(notification.sound === false ? { sound: false } : {}),
registrationId: input.registrationId,
hostFingerprint,
title: input.title,
body: input.body,
collapseId: summaryMembers
? createHash('sha256')
.update(JSON.stringify([hostFingerprint, summaryMembers]))
.digest('hex')
: collapseIdFor(notification, hostFingerprint, coalescedCount),
title: notification.title,
body: notification.body,
collapseId: collapseIdFor(notification, hostFingerprint),
orca: {
...(notification.kind ? { kind: notification.kind } : {}),
hostFingerprint,
@@ -86,9 +56,7 @@ export function buildPushDelivery(input: {
notificationSeq: notification.notificationSeq,
notificationEpoch: notification.notificationEpoch,
source: notification.source,
agentState: notification.agentState,
coalescedCount,
...(summaryMembers ? { summaryMembers } : {})
agentState: notification.agentState
}
}
}
@@ -103,36 +71,3 @@ export function orcaDataStrings(orca: PushOrcaData): Record<string, string> {
])
)
}
export function canCoalescePushNotifications(
notifications: readonly PushNotification[],
hostFingerprint: string
): boolean {
if (!pushSummaryMembers(notifications)) return false
const delivery = buildPushDelivery({
registrationId: '',
hostFingerprint,
notifications,
notification: notifications.at(-1)!,
title: 'Orca',
body: '32 agents need attention',
coalescedCount: notifications.length
})
// Reserve provider envelope space, including FCM's JSON-string escaping.
return (
Buffer.byteLength(
JSON.stringify({
notification: { title: delivery.title, body: delivery.body },
data: orcaDataStrings(delivery.orca)
}),
'utf8'
) <= 3500
)
}
export function summaryBody(notifications: readonly PushNotification[]): string {
const count = notifications.length
return notifications.some((notification) => notification.agentState === 'needs-input')
? `${count} agents need attention`
: `${count} updates`
}
@@ -16,10 +16,7 @@ it('dismissal provider payloads cannot display a new alert or play a sound', ()
agentState: null,
title: 'Orca',
body: ''
},
title: 'Orca',
body: '',
coalescedCount: 1
}
})
expect(JSON.parse(apnsBody(delivery)).aps).toEqual({ 'content-available': 1 })
const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message
+1 -2
View File
@@ -12,8 +12,7 @@ export type PushDispatcherOptions = {
onOutcome?: (outcome: PushProviderOutcome['status']) => void
}
// Sends one coalesced delivery through the provider the registration belongs
// to, and retires the registration when the provider says the token is gone.
// Retires the registration when the provider says the token is gone.
export class PushDispatcher {
constructor(private readonly options: PushDispatcherOptions) {}
@@ -17,10 +17,7 @@ it('carries a silent preference through validation to APNs and Android payloads'
const delivery = buildPushDelivery({
registrationId: 'reg',
hostFingerprint: 'host',
notification,
title: 'Bell',
body: '',
coalescedCount: 1
notification
})
expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound')
expect(
+6 -6
View File
@@ -22,12 +22,12 @@ export async function reconcileQueuedDismissal(
DO UPDATE SET notification_seq = CASE WHEN push_dismissed_events.notification_seq > excluded.notification_seq THEN push_dismissed_events.notification_seq ELSE excluded.notification_seq END, created_at = excluded.created_at`,
[...key, notification.notificationSeq, now]
)
const batches = await tx.query(
"SELECT batch_id, payload_json FROM push_delivery_batches WHERE registration_id = ? AND kind = 'alert' AND state = 'pending' AND lease_until <= ?",
[registrationId, now]
const deliveries = await tx.query(
"SELECT batch_id, payload_json FROM push_delivery_batches WHERE host_fingerprint = ? AND registration_id = ? AND kind = 'alert' AND state = 'pending' AND lease_until <= ?",
[host, registrationId, now]
)
for (const batch of batches) {
const previous = JSON.parse(String(batch.payload_json)) as PushNotification[]
for (const delivery of deliveries) {
const previous = JSON.parse(String(delivery.payload_json)) as PushNotification[]
const remaining = previous.filter(
(item) =>
item.notificationEpoch !== notification.notificationEpoch ||
@@ -37,7 +37,7 @@ export async function reconcileQueuedDismissal(
if (remaining.length === previous.length) continue
await tx.query(
'UPDATE push_delivery_batches SET payload_json = ?, state = ? WHERE batch_id = ?',
[JSON.stringify(remaining), remaining.length ? 'pending' : 'dismissed', batch.batch_id]
[JSON.stringify(remaining), remaining.length ? 'pending' : 'dismissed', delivery.batch_id]
)
}
return false
@@ -6,7 +6,7 @@ afterEach(async () => {
await Promise.all(harnesses.splice(0).map((h) => h.close()))
})
it('returns queued for concurrent retries without double quota or a false summary', async () => {
it('returns queued for concurrent retries without double quota or delivery', async () => {
const h = await createPushServerHarness()
harnesses.push(h)
const token = await h.signIn(createPushHostKeypair(2))
@@ -22,7 +22,7 @@ it('returns queued for concurrent retries without double quota or a false summar
await h.post('/v1/send', body, token)
await h.flushDeliveries()
expect(h.fcmRequests).toHaveLength(1)
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBe('1')
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBeUndefined()
expect(
Number((await h.database.query('SELECT COUNT(*) AS count FROM push_events'))[0]?.count)
).toBe(1)
@@ -1,5 +1,4 @@
import { generateKeyPairSync } from 'node:crypto'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { expect } from 'vitest'
import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
import type { PushConfig } from './config.js'
@@ -45,7 +44,6 @@ export function testPushConfig(): PushConfig {
apns: { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' },
apnsTopic: 'com.stably.orca.mobile',
fcmProjectId: 'onorca-cloud',
coalesceMs: PUSH_LIMITS.coalesceWindowMs,
trustedProxyHops: 0
}
}
@@ -117,7 +115,6 @@ export async function createPushServerHarness() {
answer,
now: () => clock,
flushDeliveries: async (): Promise<void> => {
clock += PUSH_LIMITS.coalesceWindowMs
await server.worker.runDue()
},
advanceClock: (deltaMs: number): void => {
+29 -14
View File
@@ -116,7 +116,7 @@ describe('push gateway send route', () => {
})
})
it('coalesces a burst into one apns summary with a membership-specific identity', async () => {
it('sends a burst as individual APNs alerts grouped by the host thread', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(18))
const registration = await harness.post(
'/v1/devices',
@@ -143,17 +143,30 @@ describe('push gateway send route', () => {
)
}
await harness.flushDeliveries()
expect(harness.apnsRequests).toHaveLength(1)
const request = harness.apnsRequests[0]!
expect(request.host).toBe('api.sandbox.push.apple.com')
const body = JSON.parse(request.body) as {
aps: { alert: { title: string; body: string } }
orca: { coalescedCount: number; notificationSeq: number }
}
expect(body.aps.alert).toEqual({ title: 'Orca', body: '3 agents need attention' })
expect(body.orca.coalescedCount).toBe(3)
expect(body.orca.notificationSeq).toBe(3)
expect(request.headers['apns-collapse-id']).toMatch(/^[a-f0-9]{64}$/)
expect(harness.apnsRequests).toHaveLength(3)
const bodies = harness.apnsRequests.map(
(request) =>
JSON.parse(request.body) as {
aps: { alert: { title: string; body: string }; 'thread-id': string }
orca: Record<string, unknown> & { notificationSeq: number }
}
)
expect(
harness.apnsRequests.every((request) => request.host === 'api.sandbox.push.apple.com')
).toBe(true)
expect(bodies.map((body) => body.aps.alert)).toEqual(
Array.from({ length: 3 }, () => ({
title: 'Agent needs input',
body: 'Waiting on your answer'
}))
)
expect(new Set(bodies.map((body) => body.aps['thread-id'])).size).toBe(1)
expect(bodies.map((body) => body.orca.notificationSeq).sort((a, b) => a - b)).toEqual([1, 2, 3])
expect(bodies.every((body) => !('coalescedCount' in body.orca))).toBe(true)
expect(bodies.every((body) => !('summaryMembers' in body.orca))).toBe(true)
expect(
new Set(harness.apnsRequests.map((request) => request.headers['apns-collapse-id'])).size
).toBe(3)
})
it('sends a lone event through unchanged with its own collapse id', async () => {
@@ -169,7 +182,7 @@ describe('push gateway send route', () => {
message: { android: { notification: { tag: string } }; data: Record<string, string> }
}
expect(message.message.android.notification.tag).toMatch(/^[a-f0-9]{64}$/)
expect(message.message.data.coalescedCount).toBe('1')
expect(message.message.data.coalescedCount).toBeUndefined()
})
it('reports an error for a registration the host does not own', async () => {
@@ -200,7 +213,9 @@ describe('push gateway send route', () => {
await harness.server.deliveryStore.accept(
hostFingerprint,
registrationId,
PushNotificationSchema.parse(notification({ notificationSeq: index + 1000 }))
PushNotificationSchema.parse(
notification({ notificationId: `note-${index + 1000}`, notificationSeq: index + 1000 })
)
)
).toBe('queued')
}
+1 -1
View File
@@ -60,7 +60,7 @@ export function createPushServer(
const challenges = new PushHostChallengeStore(database, config.publicUrl, now)
const sessions = new PushHostSessionStore(database, now)
const devices = new PushDeviceRegistryStore(database, now)
const deliveryStore = new DurablePushStore(database, now, config.coalesceMs)
const deliveryStore = new DurablePushStore(database, now)
const apnsTransport = options.apnsTransport ?? (config.apns ? createApnsHttp2Transport() : null)
const dispatcher = new PushDispatcher({
devices,
@@ -1,80 +0,0 @@
import { expect, it } from 'vitest'
import type { PushNotification } from '@orca-cloud/push-contract'
import { openInMemoryPushDatabase } from './push-database.js'
import { DurablePushStore } from './durable-push-store.js'
import {
buildPushDelivery,
canCoalescePushNotifications,
orcaDataStrings
} from './push-delivery-message.js'
import { apnsBody } from './apns-client.js'
const notification = (seq: number, id = `agent-${seq}`): PushNotification => ({
notificationId: id,
notificationEpoch: 'epoch',
notificationSeq: seq,
source: 'agent-task-complete',
agentState: 'finished',
title: 'Done',
body: ''
})
it('partitions a burst into durable, complete summaries within the provider payload budget', async () => {
const database = await openInMemoryPushDatabase()
try {
let now = 1000
const store = new DurablePushStore(database, () => now)
const expected = Array.from({ length: 70 }, (_, index) => notification(index))
expected.push(notification(70, 'a'.repeat(1800)), notification(71, 'b'.repeat(1800)))
for (const item of expected) expect(await store.accept('host', 'phone', item)).toBe('queued')
now += 3000
const recovered: number[] = []
const collapseIds = new Set<string>()
for (let index = 0; index < expected.length; index++) {
const batch = await store.claim()
if (!batch) break
const delivery = buildPushDelivery({
registrationId: 'phone',
hostFingerprint: 'host',
notification: batch.notifications.at(-1)!,
notifications: batch.notifications,
title: 'Orca',
body: 'Updates',
coalescedCount: batch.notifications.length
})
expect(Buffer.byteLength(apnsBody(delivery))).toBeLessThanOrEqual(4096)
expect(
Buffer.byteLength(
JSON.stringify({
notification: { title: delivery.title, body: delivery.body },
data: orcaDataStrings(delivery.orca)
})
)
).toBeLessThanOrEqual(4096)
if (batch.notifications.length > 1) {
expect(delivery.orca.summaryMembers).toHaveLength(batch.notifications.length)
expect(JSON.parse(orcaDataStrings(delivery.orca).summaryMembers!)).toEqual(
delivery.orca.summaryMembers
)
}
expect(collapseIds.has(delivery.collapseId)).toBe(false)
collapseIds.add(delivery.collapseId)
recovered.push(...batch.notifications.map((item) => item.notificationSeq))
await store.finish(batch)
}
expect(recovered.sort((a, b) => a - b)).toEqual(expected.map((item) => item.notificationSeq))
expect(await store.pendingCount('phone')).toBe(0)
} finally {
await database.close()
}
})
it('does not make an untrackable event part of a dismissible summary', () => {
expect(
canCoalescePushNotifications(
[notification(1), { ...notification(2), notificationId: undefined }],
'host'
)
).toBe(false)
expect(
canCoalescePushNotifications([notification(1), { ...notification(2), kind: 'dismiss' }], 'host')
).toBe(false)
})
@@ -1,19 +0,0 @@
import type { PushNotification } from '@orca-cloud/push-contract'
export type PushSummaryMember = {
notificationId: string
notificationEpoch: string
notificationSeq: number
}
export function pushSummaryMembers(
notifications: readonly PushNotification[]
): PushSummaryMember[] | undefined {
if (notifications.length < 2 || notifications.length > 32) return undefined
if (notifications.some((item) => !item.notificationId || item.kind === 'dismiss')) return undefined
return notifications.map((item) => ({
notificationId: item.notificationId!,
notificationEpoch: item.notificationEpoch,
notificationSeq: item.notificationSeq
}))
}
+56 -40
View File
@@ -3,7 +3,10 @@
`orca-cloud-push` is a public Cloud Run service in `onorca-cloud` that turns a desktop
notification into an APNs or FCM push for a paired phone. The desktop registers each phone's
native token with it and calls `POST /v1/send` after the socket fan-out it already does; the
phone dedupes by `notificationId#notificationSeq`. The service is the only place the Apple
phone treats APNs/FCM as the sole ordinary OS-banner path. The notification socket is retained only
for live dismissal and reconnect tray reconciliation; it does not create or recover banners. Desktop
notification categories remain authoritative; category and summary fields retained in contracts
exist only for mixed-version compatibility. The service is the only place the Apple
`.p8` signing key is readable, which is the reason it exists as a service at all.
The contract every lane builds against is `docs/reference/mobile-push-contract.md` in the
@@ -17,22 +20,22 @@ edit plus a second set of Apple credentials.
## Shape
| Setting | Value | Where |
| --- | --- | --- |
| Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` |
| Region | `us-central1` | `region` |
| Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` |
| Database pool | 2 per instance | `push_database_pool_max` |
| Concurrency | 80 | `push_concurrency` |
| Ingress | all | `INGRESS_TRAFFIC_ALL` |
| Invoker | IAM disabled | `invoker_iam_disabled = true` on the service |
| Runtime identity | `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` | `google_service_account.push_runtime` |
| Database | `orca_push` on the shared Cloud SQL instance | `google_sql_database.push` |
| Hostname | `push.onorca.dev` | `push_base_url` |
| Setting | Value | Where |
| ----------------- | ------------------------------------------------------ | -------------------------------------------- |
| Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` |
| Region | `us-central1` | `region` |
| Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` |
| Database pool | 2 per instance | `push_database_pool_max` |
| Concurrency | 80 | `push_concurrency` |
| Ingress | all | `INGRESS_TRAFFIC_ALL` |
| Invoker | IAM disabled | `invoker_iam_disabled = true` on the service |
| Runtime identity | `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` | `google_service_account.push_runtime` |
| Database | `orca_push` on the shared Cloud SQL instance | `google_sql_database.push` |
| Hostname | `push.onorca.dev` | `push_base_url` |
The minimum of one instance is deliberate and did not move when the ceiling came down to two. A
cold start delays a notification past the point where it is worth showing, and the three-second
coalescing window lives in instance memory, so the floor is what keeps a notification prompt. The
cold start delays a notification past the point where it is worth showing, so the floor is what
keeps a notification prompt. The
ceiling is a different question, answered below.
The maximum and the pool are set by the connection budget, not by the gateway's own appetite. Two
@@ -58,20 +61,20 @@ the only way to reach an open service here.
Set on the container by Terraform:
| Variable | Source |
| --- | --- |
| `PORT` | Cloud Run, container port 8080 |
| `ORCA_PUSH_PUBLIC_URL` | `push_base_url` |
| `ORCA_PUSH_FCM_PROJECT_ID` | `push_fcm_project_id`, empty means `project_id` |
| `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-database-url`, version `latest` |
| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance |
| `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` |
| `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` |
| `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` |
| Variable | Source |
| ----------------------------- | -------------------------------------------------------- |
| `PORT` | Cloud Run, container port 8080 |
| `ORCA_PUSH_PUBLIC_URL` | `push_base_url` |
| `ORCA_PUSH_FCM_PROJECT_ID` | `push_fcm_project_id`, empty means `project_id` |
| `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-database-url`, version `latest` |
| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance |
| `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` |
| `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` |
| `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` |
`ORCA_PUSH_APNS_TOPIC` and `ORCA_PUSH_COALESCE_MS` are left to their application defaults
(`com.stably.orca.mobile` and `3000`). Add them here only when one of them has to differ from
the code default, so that a code-side change stays visible rather than silently overridden.
`ORCA_PUSH_APNS_TOPIC` is left to its application default (`com.stably.orca.mobile`). Add it here
only when it has to differ from the code default, so that a code-side change stays visible rather
than silently overridden.
Terraform owns the three Apple secret **names, labels, and replication, and never a version.**
The `.p8` is issued by the Apple developer portal, so a Terraform-managed version would put the
@@ -251,6 +254,14 @@ The inert phase intentionally cannot validate a new schema by applying it to pro
migrations and validate them against isolated PostgreSQL before dispatch. No actual Cloud Run
rollout, provider delivery or physical-device acceptance is implied by local contract tests.
The individual-presentation contract begins only after every older worker revision has retired.
During rollout overlap, an old worker may still send a pre-existing queue row as a summary or assign
its former host-wide collapse identity to a new singleton identity-less bell. Do not compensate by
fabricating notification IDs or extending the wire contract. After retirement and connection drain,
acceptance must send two alerts for one host (including the identity-less shape) and confirm their
provider replacement identities remain independent and each can be dismissed without replacing the
other.
### Why the FCM probe impersonates the runtime account
A gateway that boots and answers `/ready` can still be unable to send: the FCM grant lives on
@@ -283,6 +294,7 @@ the window between.
```
The team ID does not change, so `orca-cloud-push-apple-team-id` is untouched.
3. Dispatch `Deploy Push Gateway Production`. The container reads `latest` at start, so only a
new revision picks the key up; there is no in-place reload.
4. Verify from a real device that an iOS notification still arrives. The workflow's FCM probe
@@ -321,11 +333,11 @@ problem, not device churn.
Two independent limits, both enforced in the gateway and both returning HTTP 200 with
`status: "rate_limited"` per result rather than failing the request:
| Limit | Scope |
| --- | --- |
| 300 logical alerts per rolling 15 minutes | per `hostFingerprint` |
| 300 logical dismissals per rolling 15 minutes | per `hostFingerprint`, separate budget |
| 20 `registrationIds` | per request, hard cap, HTTP 400 over it |
| Limit | Scope |
| --------------------------------------------- | --------------------------------------- |
| 300 logical alerts per rolling 15 minutes | per `hostFingerprint` |
| 300 logical dismissals per rolling 15 minutes | per `hostFingerprint`, separate budget |
| 20 `registrationIds` | per request, hard cap, HTTP 400 over it |
Fanout to several phones counts one logical event; there is no per-phone daily allowance.
Unauthenticated handshakes and invalid bearer attempts have separate 30/minute IP buckets.
@@ -333,7 +345,8 @@ Authenticated requests use a 600/minute host bucket per instance. Auth database
and waiting work are bounded independently of HTTP concurrency.
`push_events` backs quota accounting. `push_event_recipients` deduplicates fanout and
`push_delivery_batches` persists coalescing, worker leases, retries and outcomes. Identity metadata
`push_delivery_batches` retains its historical name and persists individual deliveries, worker
leases, retries and outcomes. Identity metadata
is retained for 24 hours. Payloads expire within five minutes and are cleared on completion or by
minute-level expiry cleanup. FCM project-level provider quotas remain independent of host limits.
@@ -355,7 +368,6 @@ push.onorca.dev. CNAME ghs.googlehosted.com. (DNS only, not proxied)
record is ever lost, recreate it exactly like that; Cloudflare proxying blocks certificate
issuance and breaks Cloud Run host routing.
### Recovery and delivery guarantees
Candidate tags and deterministic revision names are recorded before deployment. Promotion intent is
@@ -370,14 +382,18 @@ Session replacement is serialized per host and a unique host index upgrades olde
retaining their newest session. Cloud Verify runs push concurrency tests against PostgreSQL.
Accepted sends commit quota and pending work together before returning `queued`. Workers resume
unfinished batches after restarts without relying on desktop retries. Shared batching and expiring
leases coordinate replicas. All provider attempts retain the original five-minute deadline and
respect provider backoff; no retry extends alert life. Silent dismissal messages have their own quota
and cancel matching unsent alerts. Mobile OS delivery/execution is not guaranteed.
unfinished deliveries after restarts without relying on desktop retries. The durable queue and
expiring leases coordinate replicas. All provider attempts retain the original five-minute deadline
and respect provider backoff; no retry extends alert life. Silent dismissal messages have their own
quota and cancel matching unsent alerts. Mobile OS delivery/execution is not guaranteed.
Shutdown stops admission and new claims; unfinished leases remain recoverable. Provider acceptance
and SQL completion cannot be atomic, so repeated transport delivery remains possible after a crash.
Stable collapse identities reduce duplicates without promising exactly-once visible delivery.
Stable per-event replacement identities reduce duplicates without promising exactly-once visible
delivery. FCM notification messages are inherently collapsible while offline and support only a
small number of concurrent collapse keys per device, so excess pending messages may be discarded and
every offline alert is not guaranteed to appear. Socket reconnect reconciles dismissals against the
current native tray; it has no stored replay watermark and never recovers a missed OS banner.
### Dedicated database preparation
@@ -45,12 +45,10 @@ describe('push contract limits', () => {
maxDevicesPerListResponse: 1_024,
hostEventsPerWindow: 300,
eventQuotaWindowMs: 900_000,
coalesceWindowMs: 3_000,
challengeTtlMs: 10_000,
clockSkewToleranceMs: 30_000,
sessionTtlMs: 86_400_000,
notificationTtlSeconds: 300,
apnsCollapseIdMaxBytes: 64,
hostRetentionMs: 3_600_000,
unauthenticatedRequestsPerMinutePerIp: 30,
authenticatedRequestsPerMinutePerHost: 600
@@ -11,13 +11,11 @@ export const PUSH_LIMITS = {
maxHttpBodyBytes: 16 * 1024,
hostEventsPerWindow: 300,
eventQuotaWindowMs: 15 * 60 * 1000,
coalesceWindowMs: 3_000,
challengeTtlMs: 10_000,
// Covers routine NTP drift without extending the signed challenge window.
clockSkewToleranceMs: 30_000,
sessionTtlMs: 24 * 60 * 60 * 1000,
notificationTtlSeconds: 5 * 60,
apnsCollapseIdMaxBytes: 64,
// Nothing reads a host row, and any keypair mints one for free, so a host
// with no registration left is kept only long enough to survive a phone swap.
hostRetentionMs: 60 * 60 * 1000,
@@ -43,8 +43,7 @@ export const PushNotificationSchema = z
export const PushSendRequestSchema = z
.object({
v: z.literal(1),
// Deduped before the gateway sees it: a repeated id would otherwise reserve
// quota twice and inflate the coalesced count for one banner.
// Deduped before the gateway sees it so a repeated id cannot reserve quota twice.
registrationIds: z
.array(OpaqueIdSchema)
.min(1)
+108 -82
View File
@@ -7,10 +7,13 @@ This document is the single contract every lane builds against. Do not deviate w
A small Orca-hosted push gateway (`cloud/apps/push`) holds the APNs key and FCM credentials and sends
to phones. The desktop host registers each paired phone's native push token with the gateway and asks
the gateway to push on every mobile notification it already fans out over the socket. The phone dedupes
by host, counter epoch, and `notificationId#notificationSeq`. Foreground pushes and socket events
share the same per-host delivery queue so a pending native schedule cannot produce a second banner. No ack gate, no generic mode, no staging gateway, one auth path for
signed-in and accountless hosts.
the gateway to push each eligible desktop notification. Native APNs/FCM delivery is the only ordinary
mobile OS-banner path. The notification socket is retained only for live dismissals and reconnect tray
reconciliation; ordinary notification frames never create banners. Reconciliation compares the current
native tray with durable host dismissal history and does not replay alerts. Desktop notification
categories are authoritative. Legacy category, replay-notification, and summary fields remain on the
wire only for mixed-version compatibility. No ack gate, no generic mode, no staging gateway, one auth
path for signed-in and accountless hosts.
## Identities
@@ -96,9 +99,10 @@ replaces the old. `deviceId` is caller-chosen, so a host is capped at 64 registr
distinct `deviceId` → 409 `{ "error": "too_many_devices" }`. Re-registering a `deviceId` the host
already owns is always accepted, and deleting a registration frees its slot. `GET /v1/devices` is
bounded at 1024 rows to match its response schema, which the per-host cap keeps well out of reach.
`filter` is stored but enforced by the host (see desktop); gateway stores it only so a
host restart can re-read it. iOS tokens are variable-length, hex-encoded byte strings; Android
tokens are FCM registration strings.
The gateway `filter` contains only the legacy `sources` and `agentStates` arrays. It remains stored and
accepted for mixed-version schema compatibility but is not an updated delivery-policy input. The host
persists and enforces desktop eligibility plus the phone-specific away-only, sound, and expiry settings.
iOS tokens are variable-length, hex-encoded byte strings; Android tokens are FCM registration strings.
`DELETE /v1/devices/:registrationId` (Bearer) → 204. Only the owning host may delete.
@@ -162,25 +166,37 @@ tokens are FCM registration strings.
but the OS banner shows while the app is backgrounded. Reaching it needs the victim's native token,
which the gateway never returns and which only the phone and its host ever see.
### Coalescing (gateway)
### Individual delivery (gateway)
Per registration, persist a three-second window and summarize bursts using the latest event's
routing fields and `coalescedCount`. Windows are shared across replicas. Single-alert collapse IDs
hash host and notification identity; summaries hash the host and their complete membership.
Each summary carries optional `summaryMembers` (ID, epoch, sequence), encoded as a JSON string
in FCM data. Admission splits bursts into independently leased batches of at most 32 members,
and reserves provider envelope space within the 4 KB payload limit. An event without a notification
identity stays individual. Batches that fill the membership/payload budget become immediately due;
quota accounting still counts logical alerts, not batches or recipients.
Each accepted event immediately creates one queued delivery per eligible registration. Bursts retain
their original title, body, routing fields, and replacement identity; the gateway does not generate
summary text or summary membership. APNs groups alerts visually by the host thread identifier. Android
uses a distinct notification tag for each event and relies on platform behavior rather than a custom
summary notification. Quota accounting still counts logical alerts, not deliveries or recipients.
Four worker lanes per instance claim delivery batches with expiring, renewed SQL leases. Retry state
Four worker lanes per instance claim deliveries with expiring, renewed SQL leases. Retry state
is persistent, with exponential backoff and provider minimum delays. Retry-After is never shortened
to fit event lifetime: expire instead. Device validity is checked before each attempt. Dismissals
cancel pending matching alerts, bypass alert coalescing, and use silent provider messages. Mobile OS
cancel the matching pending alert and use silent provider messages. Mobile OS
background execution remains best effort, particularly after force-quit on iOS.
The queue table retains its historical `push_delivery_batches` name to avoid a data migration. New
rows keep a one-element JSON array only as a rolling-deploy storage envelope so an older worker can
read them, but new code models and sends one notification. New rows are immediately due, which keeps
an overlapping older gateway from appending to them. A new worker atomically splits a pre-deployment
multi-event row into individual rows, preserving its fixed expiry, attempt count, and existing event
recipient records; legacy rows were capped at 32 events, bounding that transaction. A legacy row
already leased by an older revision completes under that revision's behavior, while immediately due
new rows cannot be appended to by its admission path.
Individual presentation is guaranteed only after every older worker revision has retired. During
the overlap, an older worker can still send a pre-existing row as a summary and can assign its former
host-wide collapse identity to a new singleton identity-less bell. Do not add fabricated IDs or new
wire fields to conceal old-binary behavior. Post-retirement acceptance must verify that two alerts for
one host retain independent provider replacement identities and can be dismissed independently.
Shutdown stops admission and work acquisition, waits for active work within the platform grace, and
leaves unfinished batches recoverable after their leases expire. A provider acceptance followed by a
leaves unfinished deliveries recoverable after their leases expire. A provider acceptance followed by a
crash before SQL completion can still cause a repeated send; collapse identity mitigates this without
promising exactly-once delivery.
@@ -190,10 +206,11 @@ APNs (HTTP/2, `api.push.apple.com` or `api.sandbox.push.apple.com` by `apnsEnvir
from key id + team id + `.p8`, token cached and refreshed every 50 min):
- headers: `apns-topic: com.stably.orca.mobile`, `apns-push-type: alert`, `apns-priority: 10`,
`apns-expiration: fixed event deadline (at most five minutes)`, `apns-collapse-id: <sha256(host + notification identity), or host:<fp>>`
`apns-expiration: fixed event deadline (at most five minutes)`, `apns-collapse-id: <sha256(host + notification identity)>`
(identity-less events use their epoch and sequence)
- body: `{"aps":{"alert":{"title","body"},"sound":"default","thread-id":"<hostFingerprint>"},
"orca":{ hostFingerprint, worktreeId, notificationId, notificationSeq, notificationEpoch, source,
agentState, coalescedCount }}`
agentState }}`
- Dismissals use `apns-push-type: background`, priority `5`, no collapse header, and
`aps: {"content-available": 1}` with `orca.kind: "dismiss"`. They carry no alert or sound.
- Dead token: 410, or 400 with `BadDeviceToken`/`Unregistered`/`DeviceTokenNotForTopic`.
@@ -206,9 +223,12 @@ metadata server or `GOOGLE_APPLICATION_CREDENTIALS` locally):
"data":{ all orca fields as strings }}}`
- Dismissals are data-only (`kind: "dismiss"`); omit both `message.notification` and
`android.notification`. No visible alert or sound is requested.
- FCM notification messages are inherently collapsible while offline; `collapse_key` does not
preserve every alert. Android `tag` controls replacement after delivery. On reconnect, the
existing host notification replay recovers retained events; the tray is not an event log.
- FCM notification messages are inherently collapsible while offline, and FCM supports only a small
number of concurrent collapse keys per device. Per-event `collapse_key` and Android `tag` preserve
individual replacement identity while a message is retained, but excess offline pending messages
may be discarded and every alert is not guaranteed to appear. Android automatic grouping remains
platform-owned and is unverified on physical devices. Socket reconnect never recovers missed OS
banners; the tray is not an event log.
- Dead token: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token.
### Gateway storage (Postgres in prod, SQLite in tests, same pattern as `cloud/apps/relay/src/database.ts`)
@@ -225,7 +245,8 @@ expires_at, consumed_at)`
filter_json, dead_at, created_at, updated_at, unique(host_fingerprint, device_id))`
- `push_events` holds logical event identity, content fingerprint, quota timestamp, and expiry.
- `push_event_recipients` records accepted event/phone pairs for idempotent fanout.
- `push_delivery_batches` holds coalesced payloads, retry deadlines, and renewable worker leases.
- `push_delivery_batches` holds individual payload envelopes, retry deadlines, and renewable worker
leases.
- `push_dismissed_events` fences older alerts from replaying after dismissal.
- Queue identities and dismissal fences are retained for 24 hours; completed payloads are cleared.
@@ -237,7 +258,7 @@ Logging: aggregate counters only. Never log tokens, titles, bodies, or raw finge
`PORT`, `ORCA_PUSH_PUBLIC_URL`, `ORCA_PUSH_DATABASE_URL` (absent → SQLite under `ORCA_PUSH_DATA_DIR`),
`ORCA_PUSH_APNS_KEY` (PEM text), `ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`,
`ORCA_PUSH_APNS_TOPIC` (default `com.stably.orca.mobile`), `ORCA_PUSH_FCM_PROJECT_ID` (default
`onorca-cloud`), `ORCA_PUSH_COALESCE_MS` (default 3000), `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0,
`onorca-cloud`), `ORCA_PUSH_TRUSTED_PROXY_HOPS` (default 0,
proxies appending to `x-forwarded-for` after the client).
Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-key`,
`orca-cloud-push-apns-key-id`, `orca-cloud-push-apple-team-id`. Runtime SA:
@@ -247,16 +268,19 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
- Capability `NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1'` in
`src/shared/protocol-version.ts`, advertised statically.
- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }` (same shapes
as the gateway `POST /v1/devices` minus deviceId, which comes from `ctx.pairedDeviceId`). Returns
- RPC `notifications.registerPush` params `{ platform, token, apnsEnvironment?, filter }`. The mobile
filter includes away-only, expiry, and sound settings plus the legacy category fields; the host
persists that full policy, while its gateway `POST /v1/devices` forwards only `sources` and
`agentStates` for compatibility. `deviceId` comes from `ctx.pairedDeviceId`. The RPC returns
`{ registered: true, registrationId } | { registered: false, reason: 'gateway_unreachable' |
'gateway_rejected' | 'not_mobile' | 'registration_storage_failed' | 'throttled' }`. A device may
register at most 10 times per minute (`throttled` beyond that, its earlier registration untouched):
each call is a gateway write plus a synchronous registry write on the main thread, and a paired
phone could otherwise loop it. The unregister RPC is not throttled, since with nothing registered it
is a lookup and with something registered it can only run once per successful register. The params
schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists `pushRegistration:
{ registrationId, platform, filter, registeredAt }` on `DeviceEntry` in `device-registry.ts` (new
schema is strict, so a caller-supplied `deviceId` is an error, not a key silently dropped. Persists
`pushRegistration: { registrationId, platform, filter, registeredAt }` on `DeviceEntry` in
`device-registry.ts` (new
optional field, tolerated by old registries). When the gateway accepted the token but the host could
not store it — the device left mobile scope mid-call (`not_mobile`) or the registry write threw
(`registration_storage_failed`) — the host queues the gateway delete in the unregister outbox rather
@@ -279,8 +303,9 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
`answerRelayHostChallenge` with the push transcript fields. Shared code with the relay proof is
welcome if it stays a pure refactor.
- Dispatch hook: in `RuntimeMobileNotificationController.dispatch`, after the socket fan-out, call
`pushDispatcher.enqueue(eventWithSeq)`. The dispatcher applies each device's `filter`, skips `dismiss`
events, maps `agentState` to `needs-input | finished` (blocked/waiting → needs-input, else finished),
`pushDispatcher.enqueue(eventWithSeq)`. The dispatcher requires desktop category eligibility, applies
each device's phone-specific away-only and sound preferences, skips `dismiss` events, maps `agentState`
to `needs-input | finished` (blocked/waiting → needs-input, else finished),
batches matching registrationIds into `POST /v1/send` requests of at most 20 registrations each (the
gateway's per-request cap; extra devices get their own request rather than being dropped), and drops
unchanged registrations the gateway reports `dead`. Failure categories are counted without payload
@@ -301,10 +326,10 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
`apnsEnvironment`: `__DEV__ ? 'sandbox' : 'production'` (dev-client builds are debug, TestFlight and
App Store are release). Listen with `addPushTokenListener` and re-register on change.
- Settings (`mobile/app/notifications.tsx`): one default-off **Enable notifications** switch
controls connected and background delivery. Hint: “Get agent alerts even when the app is closed.
Delivered through Orca’s push service and Apple or Google.” Source controls remain visible,
indented and disabled while **Use desktop settings** is on. Phone sound and focus controls
remain independent. **Only when away from desktop** defaults on (180 seconds of OS input idle,
controls native push registration. Hint: “Get agent alerts even when the app is closed.
Delivered through Orca’s push service and Apple or Google.” Desktop category controls are
authoritative and are not duplicated as phone overrides. Phone sound and viewing controls remain
independent. **Only when away from desktop** defaults on (180 seconds of OS input idle,
or locked). Unknown/headless presence does not suppress; it is never inferred from remote CPU
activity. The detailed payload disclosure remains in the notification documentation.
- `notifications.delivery-policy.v1` advertises the away and mobile-inactivity lease policy.
@@ -319,14 +344,17 @@ Secret Manager names (already exist in `onorca-cloud`): `orca-cloud-push-apns-ke
switch is on, call `notifications.registerPush` on that host if it advertises the capability. On
switch-off call `notifications.unregisterPush` on every connected host and remember to retry on hosts
that were offline. On host removal, best-effort unregister before deleting credentials.
- Receive: `addNotificationReceivedListener` (foreground) checks `data.orca.notificationId` +
`notificationSeq` against the host session seen set in `notification-reconnect-catchup.ts`; if seen,
suppress via `setNotificationHandler` returning no banner; otherwise show and mark seen. Background and
killed: OS shows it.
- Receive: `addNotificationReceivedListener` (foreground) validates that the host is paired, master
consent is enabled, the destination is not currently viewed, and the event is not fenced by a
persisted dismissal. A bounded process-local identity claim suppresses concurrent duplicates by
host, epoch, sequence, and optional notification ID. Background and killed delivery remains owned by
the OS and is best effort.
- Tap: `data.orca.hostFingerprint` → hostId by computing the same sha256/base64url/16 derivation over each
stored host's `publicKeyB64`; then existing `getNotificationNavigationTarget` + `useOpenNotificationRoute`.
- Reopen: existing replay catch-up runs unchanged. Dismiss events also
`dismissNotificationAsync` any presented notification whose `data.orca.notificationId` matches.
- Reopen: subscribe to socket notifications for live dismissals, but ignore ordinary
notification frames for banner presentation. Reconnect reconciliation sends identities currently in
the native tray and applies returned dismissal decisions; it does not replay notifications or create
banners. Live dismiss events also remove matching presented notifications.
- Old host without the capability: nothing changes.
## Infra (`cloud/infra/terraform`, `.github/workflows`)
@@ -353,25 +381,23 @@ alert messages, Live Activities, account-based quota tiers.
### Device delivery preferences
The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains
active when desktop notifications are off; semantic validity checks still precede delivery.
IPC publishes `desktopAllowed: false` for terminal events disabled by the desktop master or
source switch. Desktop focus and native authorization remain desktop-only delivery gates.
The desktop advertises `notifications.delivery-preferences.v1`. Completion detection remains active
when desktop notifications are off; semantic validity checks still precede delivery. IPC publishes
`desktopAllowed: false` when the desktop master or source/category switch rejects an event. That
desktop category decision is authoritative for both desktop and phone alerts. Desktop focus and
native authorization remain desktop-only presentation gates and do not change mobile eligibility.
`notifications.subscribe` and `notifications.getMissedSince` accept optional
`includeDesktopSuppressed: true`. Only opted-in callers receive those events, including replay;
legacy callers keep the old filtered stream. A new phone against an older host can narrow the
available events but cannot recover events that host never published.
Updated mobile subscribes with `includeDesktopSuppressed: true` for mixed-version compatibility and
to receive dismissals, but it never turns ordinary socket notification frames into OS banners. The
optional subscribe/replay fields, replayed `notifications`, `followDesktop`, `sources`, and
`agentStates` remain accepted and populated only for mixed-version compatibility. Updated hosts ignore
the mobile category fields and gate provider alerts on `desktopAllowed`; old hosts and clients retain
their previous behavior without a wire break.
The phone defaults to following each host. `filter.followDesktop` is optional: absent retains
legacy desktop gating; explicit false permits independent event choices. The desktop persists
it with the paired registration and evaluates it for every send, so desktop preference changes
work while the phone is disconnected. This flag is host-local and is not sent to the gateway.
The phone uses the same shared event predicate for socket/replay delivery as the push dispatcher.
Optional `emittedAt` carries the event time for per-device five-second burst suppression after
source filtering. Desktop eligibility, source, and agent state use separate upstream cooldown
buckets so filtered events cannot suppress the next eligible event. Legacy RPC callers retain
workspace-wide burst suppression on the host.
The mobile registration always sends `followDesktop: true` and complete legacy category arrays.
There are no active phone category overrides or category defaults. Optional `emittedAt` and replay
notification response fields remain compatibility surface rather than a second delivery policy or
banner path.
`filter.sound` is also host-local. False groups that device's requests separately and adds
optional `notification.sound: false` to gateway sends. The gateway omits APNs `aps.sound` and
@@ -379,20 +405,21 @@ uses Android's `orca-desktop-silent` channel. Missing sound preserves existing a
Deploy the updated gateway before distributing hosts that send the optional sound field: older
gateways strictly reject unknown notification fields. No token or database migration is needed.
The phone's master switch disables background registration as well as local scheduling. Sound
and viewing preferences belong to the receiving phone. The phone suppresses a banner for its
The phone's master switch disables native push registration. Sound and viewing preferences belong to
the receiving phone. The phone suppresses a foreground banner for its
currently viewed host/workspace only while active; it never assumes desktop focus means the
phone is viewing that workspace. Changes to an offline host's persisted filter take effect on
reconnection. No live APNs/FCM delivery is implied by simulator notification injection.
phone is viewing that workspace. Once registered, the host applies its persisted phone settings while
the phone is disconnected; preference changes synchronize when it reconnects. No live APNs/FCM
delivery is implied by simulator notification injection.
For a phone registered for background push, socket notification delivery waits while the app is
inactive. On foreground, it checks the native push tray before scheduling a local fallback, so
a still-connected background socket cannot duplicate APNs/FCM delivery. Unsubscribing cancels
the wait without claiming delivery. Hosts without push registration keep local delivery.
APNs/FCM is the sole ordinary OS-banner path whether the app is foregrounded, backgrounded, or
killed. Socket notification events do not wait, schedule a local fallback, or recover a missed native
alert. Hosts without push registration therefore have no mobile OS-banner fallback.
Native notification readers accept Expo's iOS `request.trigger.payload` as well as
`request.content.data`. APNs custom fields can exist only in the former; foreground deduplication,
tray replay suppression, dismissal, and tap routing all use the same reader.
`request.content.data`. APNs custom fields can exist only in the former; foreground identity claims,
dismissal, reconciliation, and tap routing all use the same reader. Legacy summary members remain
readable only for notifications already delivered during a mixed-version transition.
### Dismissal recovery and desktop presence
@@ -403,20 +430,19 @@ attention; explicit mark-read actions remain available, including in browser cli
The runtime persists notification identities and dismissal sequence fences in its own user-data
directory before fanout. History is bounded to 4,096 records retained for seven days. It stores no
notification text or push tokens. On reconnect, mobile optionally includes up to 256 `deliveredPushes`
identities in `notifications.getMissedSince`; updated hosts return optional `dismissedPushes` for
confirmed handled identities. This recovers dismissals after event replay eviction or host restart
within retained history. Unknown IDs, newer sequences and different epochs are preserved. Older
hosts ignore the optional request field, and older clients ignore the additional response field.
No new RPC method or stream opcode is required.
notification text or push tokens. On reconnect, mobile sends up to 256 `deliveredPushes` identities
from the current native tray in each `notifications.getMissedSince` request; updated hosts return
optional `dismissedPushes` for confirmed handled identities. Mobile processes only those dismissal
decisions. Unknown IDs, newer sequences, and different epochs are preserved. Older hosts ignore the
optional request field, and older clients ignore the additional response fields. Legacy replayed
`notifications` and epoch fields remain wire-compatible but do not drive current mobile banners.
On iOS, a local Expo module handles silent dismissals directly through the native notification
center, independent of JavaScript initialization. Native and JavaScript dismissal paths use the
same host/epoch/sequence fences; native watermarks retain up to 512 entries for 24 hours. Older
same host/epoch/sequence fences; native dismissal fences retain up to 512 entries for 24 hours. Older
native shells and Android retain the JavaScript implementation. A native callback test proves
processing only when invoked: iOS background push delivery remains best-effort, including while
suspended or force-quit. Summaries with complete membership are removed only when every member is
covered by a matching host/epoch/sequence dismissal fence. Partial, malformed and legacy summaries
without membership remain preserved. Reconciliation inspects up to 2,048 represented identities,
sending pages of 256 without repeating historical replay. A first connection without a saved
watermark reconciles the tray without replaying old alerts.
processing only when invoked: iOS background push delivery remains best effort, including while
suspended or force-quit. Narrow legacy decoding keeps already delivered summary notifications from
being mistaken for individual events during the transition. Reconciliation inspects up to 2,048
represented identities in pages of 256. It has no stored replay watermark: every connection compares
the current tray with host dismissal history and never replays an alert.
@@ -60,6 +60,8 @@ final class PushDismissalLedger {
}
func containsNotification(_ payload: [String: Any], now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
// Summary expansion is retained only for legacy delivered tray entries; new
// pushes always carry one individual identity.
if let count = payload["coalescedCount"] as? NSNumber, count.doubleValue > 1 {
guard count.doubleValue.rounded(.down) == count.doubleValue, count.intValue <= 32,
let members = payload["summaryMembers"] as? [[String: Any]], members.count == count.intValue,
@@ -12,7 +12,7 @@ vi.mock('react-native', () => ({
Switch: 'Switch'
}))
it('keeps inherited controls visible and disables editing until mirroring is off', () => {
it('shows only phone-specific controls while desktop owns category eligibility', () => {
const onChange = vi.fn()
let renderer: ReturnType<typeof create>
act(() => {
@@ -22,31 +22,16 @@ it('keeps inherited controls visible and disables editing until mirroring is off
})
const switches = () => renderer.root.findAllByType('Switch' as never)
expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([
'Use desktop settings',
'Agent task complete',
'Terminal bell',
'Plugin notifications',
'Only when away from desktop',
'Notification sound',
'Suppress while focused'
])
expect(switches()[1].props.disabled).toBe(true)
expect(switches()[2].props.disabled).toBe(true)
expect(JSON.stringify(renderer.toJSON())).toContain(
'Alert types follow each paired desktop’s notification settings.'
)
act(() => switches()[0].props.onValueChange(false))
const independent = onChange.mock.calls[0][0]
expect(independent.followDesktop).toBe(false)
act(() =>
renderer.update(createElement(NotificationDeliverySection, { value: independent, onChange }))
)
expect(switches()[1].props.disabled).toBe(false)
expect(switches().map((node) => node.props.accessibilityLabel)).toContain('Terminal bell')
act(() =>
switches()
.find((node) => node.props.accessibilityLabel === 'Terminal bell')!
.props.onValueChange(false)
)
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ terminalBell: false, taskFinished: true, needsInput: true })
expect.objectContaining({ onlyWhenDesktopAway: false, sound: true, suppressWhileViewing: true })
)
act(() => renderer.unmount())
})
@@ -9,15 +9,9 @@ type Props = {
}
export function NotificationDeliverySection({ value, disabled, onChange }: Props) {
const row = (
key: keyof NotificationDeliveryPreferences,
label: string,
hint?: string,
inherited = false
) => {
const locked = disabled || (inherited && value.followDesktop)
const row = (key: keyof NotificationDeliveryPreferences, label: string, hint?: string) => {
return (
<View key={key} style={[styles.row, locked && styles.disabled]}>
<View key={key} style={[styles.row, disabled && styles.disabled]}>
<View style={styles.labelGroup}>
<Text style={styles.label}>{label}</Text>
{hint && <Text style={styles.hint}>{hint}</Text>}
@@ -26,14 +20,8 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props
accessibilityLabel={label}
testID={`notification-${key}`}
value={value[key]}
disabled={locked}
onValueChange={(enabled) =>
onChange({
...value,
[key]: enabled,
...(key === 'taskFinished' ? { needsInput: enabled } : {})
})
}
disabled={disabled}
onValueChange={(enabled) => onChange({ ...value, [key]: enabled })}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
@@ -42,20 +30,6 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props
}
return (
<>
<View style={styles.section}>
{row('followDesktop', 'Use desktop settings', 'Use each desktop’s alert preferences.')}
<View style={styles.children}>
{value.followDesktop && <Text style={styles.inherited}>Managed on each desktop</Text>}
{row(
'taskFinished',
'Agent task complete',
'An agent finishes or needs your input.',
true
)}
{row('terminalBell', 'Terminal bell', 'A terminal requests your attention.', true)}
{row('plugin', 'Plugin notifications', undefined, true)}
</View>
</View>
<View style={styles.section}>
{row(
'onlyWhenDesktopAway',
@@ -70,7 +44,8 @@ export function NotificationDeliverySection({ value, disabled, onChange }: Props
)}
</View>
<Text style={styles.footer}>
Notifications pause after 7 days without using this app. Open it and reconnect to resume.
Alert types follow each paired desktop’s notification settings. Notifications pause after 7
days without using this app; open it and reconnect to resume.
</Text>
</>
)
@@ -87,18 +62,6 @@ const styles = StyleSheet.create({
labelGroup: { flex: 1, gap: spacing.xs },
label: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary },
hint: { fontSize: typography.metaSize, color: colors.textMuted },
children: {
marginLeft: spacing.xl,
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle,
marginBottom: spacing.md
},
inherited: {
fontSize: typography.metaSize,
color: colors.textMuted,
paddingHorizontal: spacing.md,
paddingTop: spacing.xs
},
disabled: { opacity: 0.5 },
footer: {
fontSize: typography.metaSize,
@@ -0,0 +1,6 @@
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
@@ -1,254 +0,0 @@
const memory = vi.hoisted(() => new Map<string, string>())
import { beforeEach, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import {
dismissLocalNotification,
showLocalNotification,
type NotificationEvent
} from './local-notification-scheduling'
import { allowsLocalNotification } from './notification-viewing-policy'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { Platform } from 'react-native'
import { shouldSuppressForegroundPush } from './push-receive'
vi.mock('react-native', () => ({ AppState: { currentState: 'active' }, Platform: { OS: 'ios' } }))
vi.mock('expo-notifications', () => ({
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(async () => ({ status: 'granted', canAskAgain: true })),
scheduleNotificationAsync: vi.fn(async () => 'local'),
dismissNotificationAsync: vi.fn(async () => {})
}))
vi.mock('../transport/host-store', () => ({
loadHostCatalog: vi.fn(async () =>
[
'host',
'host-policy',
'host-preferences',
'host-permission',
'host-enabled',
'host-replacement',
'host-newer-epoch',
'host-newer-new-epoch',
'host-channel'
].map((id) => ({ id }))
)
}))
vi.mock('./push-host-fingerprint', () => ({
resolveHostIdForFingerprint: () => 'host',
deriveHostFingerprint: () => 'abcdefghijklmnop'
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: async () => true,
loadPushNotificationsEnabled: vi.fn(async () => true),
loadRemotePushHostRegistrations: async () => ({ registeredHostIds: ['host'] })
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => memory.get(key) ?? null,
setItem: async (key: string, value: string) => {
memory.set(key, value)
}
}
}))
vi.mock('./notification-viewing-policy', () => ({
allowsLocalNotification: vi.fn(async () => true)
}))
vi.mock('./notification-delivery-preferences', () => ({
loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true }))
}))
const event: NotificationEvent = {
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: '',
notificationId: 'lifetime',
notificationSeq: 20,
notificationEpoch: 'epoch'
}
const dismiss = {
type: 'dismiss' as const,
notificationId: 'lifetime',
notificationSeq: 21,
notificationEpoch: 'epoch'
}
beforeEach(() => {
vi.clearAllMocks()
memory.clear()
Platform.OS = 'ios'
})
it.each(['policy', 'preferences', 'enabled', 'permission'] as const)(
'honors dismissal during %s before scheduling',
async (stage) => {
let finish!: () => void
const gate = new Promise<void>((resolve) => {
finish = resolve
})
const entered = vi.fn()
if (stage === 'policy') {
vi.mocked(allowsLocalNotification).mockImplementationOnce(async () => {
entered()
await gate
return true
})
} else if (stage === 'preferences') {
vi.mocked(loadNotificationDeliveryPreferences).mockImplementationOnce(async () => {
entered()
await gate
return { sound: true } as never
})
} else if (stage === 'enabled') {
vi.mocked(loadPushNotificationsEnabled).mockImplementationOnce(async () => {
entered()
await gate
return true
})
} else {
vi.mocked(Notifications.getPermissionsAsync).mockImplementationOnce(async () => {
entered()
await gate
return { status: 'granted' } as never
})
}
const timedEvent = { ...event, emittedAt: 100000 }
const pending = showLocalNotification(timedEvent, `host-${stage}`)
await vi.waitFor(() => expect(entered).toHaveBeenCalled())
await dismissLocalNotification(dismiss, `host-${stage}`)
finish()
await pending
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()
await showLocalNotification(
{ ...timedEvent, notificationSeq: 22, emittedAt: 100001 },
`host-${stage}`
)
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
}
)
it('cleans up a push dismissal that completes while native scheduling is pending', async () => {
let finish!: (id: string) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const pending = showLocalNotification(event, 'host')
await vi.waitFor(() => expect(finish).toBeDefined())
await shouldSuppressForegroundPush({
orca: { ...dismiss, kind: 'dismiss', hostFingerprint: 'abcdefghijklmnop' }
})
finish('late-native')
await pending
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('late-native')
})
it.each([
['epoch', 22],
['new-epoch', 1]
] as const)(
'preserves newer same-ID alert %s/%s during and after scheduling',
async (notificationEpoch, notificationSeq) => {
let finish!: (id: string) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const pending = showLocalNotification(
{ ...event, notificationEpoch, notificationSeq },
`host-newer-${notificationEpoch}`
)
await vi.waitFor(() => expect(finish).toBeDefined())
await dismissLocalNotification(dismiss, `host-newer-${notificationEpoch}`)
finish('newer-native')
await pending
await dismissLocalNotification(dismiss, `host-newer-${notificationEpoch}`)
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('newer-native')
}
)
it.each([
[true, 'channel-id'],
[false, 'channel-id'],
[true, undefined],
[false, undefined]
])('selects Android channel in the trigger (sound=%s, id=%s)', async (sound, notificationId) => {
Platform.OS = 'android'
vi.mocked(loadNotificationDeliveryPreferences).mockResolvedValueOnce({ sound } as never)
await showLocalNotification(
{ ...event, notificationId: notificationId as string | undefined },
'host-channel'
)
const request = vi.mocked(Notifications.scheduleNotificationAsync).mock.calls[0][0]
expect(request.trigger).toEqual({ channelId: sound ? 'orca-desktop' : 'orca-desktop-silent' })
expect(request.content).not.toHaveProperty('channelId')
expect(request.content.sound).toBe(sound ? 'default' : false)
})
it('honors dismissal while awaiting removal of a previous local banner', async () => {
await showLocalNotification(
{ ...event, notificationSeq: 19, emittedAt: 90000 },
'host-replacement'
)
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls[0][0].trigger).toBeNull()
let finish!: () => void
vi.mocked(Notifications.dismissNotificationAsync).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const pending = showLocalNotification({ ...event, emittedAt: 100000 }, 'host-replacement')
await vi.waitFor(() => expect(finish).toBeDefined())
await dismissLocalNotification(dismiss, 'host-replacement')
finish()
await pending
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
await showLocalNotification(
{ ...event, notificationSeq: 22, emittedAt: 100001 },
'host-replacement'
)
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
})
it('releases cooldown for an identified notification whose native schedule fails', async () => {
const retryEvent = { ...event, notificationId: 'retry', worktreeId: 'retry', emittedAt: 100000 }
vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(
new Error('native failed')
)
await expect(showLocalNotification(retryEvent, 'host')).rejects.toThrow('native failed')
await showLocalNotification(retryEvent, 'host')
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
})
it('does not release a newer concurrent notification cooldown when an older schedule fails', async () => {
const older = {
...event,
notificationId: 'older-failure',
worktreeId: 'overlap',
emittedAt: 100000
}
let fail!: (error: Error) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
fail = reject
})
)
const pending = showLocalNotification(older, 'host').catch((error) => error)
await vi.waitFor(() => expect(fail).toBeDefined())
await showLocalNotification(
{ ...older, notificationId: 'newer-success', emittedAt: 106000 },
'host'
)
fail(new Error('native failed'))
expect(await pending).toBeInstanceOf(Error)
await showLocalNotification(
{ ...older, notificationId: 'within-newer-cooldown', emittedAt: 106001 },
'host'
)
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
})
@@ -1,251 +0,0 @@
import { reserveNotificationCooldown } from '../../../src/shared/notification-burst-cooldown'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
import { allowsLocalNotification } from './notification-viewing-policy'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { DESKTOP_NOTIFICATION_CHANNEL_ID } from './desktop-notification-channel'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import { ensureNotificationPermissions } from './notification-permissions'
import { dismissHostPushNotification, wasHostPushDismissed } from './push-socket-dismissal'
export type NotificationEvent = {
type: 'notification'
desktopAllowed?: boolean
desktopAway?: boolean
emittedAt?: number
agentState?: string
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
// Counter lifetime the seq belongs to (#8591); absent on older runtimes.
notificationEpoch?: string
}
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
type ScheduledNotificationState = {
event?: NotificationEvent
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const recentNotifications = new Map<string, number>()
function reserveLocalNotification(event: NotificationEvent, hostId: string): boolean {
return (
event.emittedAt === undefined ||
reserveNotificationCooldown(
recentNotifications,
JSON.stringify([hostId, event.worktreeId ?? 'global']),
event.emittedAt
)
)
}
function releaseLocalNotification(event: NotificationEvent, hostId: string): void {
const key = JSON.stringify([hostId, event.worktreeId ?? 'global'])
if (event.emittedAt !== undefined && recentNotifications.get(key) === event.emittedAt) {
recentNotifications.delete(key)
}
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export async function showLocalNotification(
event: NotificationEvent,
hostId: string
): Promise<void> {
if (!(await allowsLocalNotification(event, hostId))) {
return
}
const preferences = await loadNotificationDeliveryPreferences()
const channelId = preferences.sound
? DESKTOP_NOTIFICATION_CHANNEL_ID
: `${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
if (!reserveLocalNotification(event, hostId)) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
sound: preferences.sound ? 'default' : false,
data: buildLocalNotificationData(event, hostId)
},
trigger: Platform.OS === 'android' ? { channelId } : null
})
return
}
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
notificationState.event = event
let reserved = false
let scheduled = false
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if ((await wasHostPushDismissed(event, hostId)) || notificationState.dismissAfterSchedule) {
return null
}
reserved = reserveLocalNotification(event, hostId)
if (!reserved) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
if ((await wasHostPushDismissed(event, hostId)) || notificationState.dismissAfterSchedule) {
return null
}
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
sound: preferences.sound ? 'default' : false,
data: buildLocalNotificationData(event, hostId)
},
trigger: Platform.OS === 'android' ? { channelId } : null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
scheduled = true
const dismissed = await wasHostPushDismissed(event, hostId)
if (dismissed || notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
// Only roll back this attempt; another notification may own a newer reservation.
if (reserved && !scheduled) {
releaseLocalNotification(event, hostId)
}
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
export async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
if (!event.notificationId) {
return
}
// Why first and unconditionally: a push the OS presented while Orca was closed has
// no entry below, so the local registry alone would leave it in the tray forever.
await dismissHostPushNotification(event, hostId)
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
if (
event.notificationEpoch &&
event.notificationSeq !== undefined &&
state.event?.notificationEpoch &&
state.event.notificationSeq !== undefined &&
(state.event.notificationEpoch !== event.notificationEpoch ||
state.event.notificationSeq > event.notificationSeq)
) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
@@ -1,726 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
getNotificationPermissionState,
subscribeToDesktopNotifications
} from './mobile-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { dismissHostPushNotification } from './push-socket-dismissal'
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
vi.mock('./push-socket-dismissal', () => ({
dismissHostPushNotification: vi.fn(async () => {})
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
vi.mock('./push-dismissal-reconciliation', () => ({
requestNotificationCatchup: vi.fn(async () => ({ ok: true }))
}))
vi.mock('./notification-permissions', () => ({}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
type Handler = (data: unknown) => void
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
// Why (#8591): the reconnect watermark/seen-set now live per host at module
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
// tests so each case starts from a genuine cold open.
resetHostNotificationSessionsForTests()
})
describe('getNotificationPermissionState', () => {
it.each([
{ os: 'android', version: 32, expected: false },
{ os: 'android', version: 33, expected: true },
{ os: 'ios', version: 18, expected: true }
])(
'reports whether a granted $os $version authorization reflects user choice',
async ({ os, version, expected }) => {
Object.assign(Platform, { OS: os, Version: version })
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
await expect(getNotificationPermissionState()).resolves.toMatchObject({
granted: true,
authorizationReflectsUserChoice: expected
})
}
)
})
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
// client must fetch missed notifications from its watermark and push exactly
// the ones it had not yet delivered — never re-pushing an already-delivered id.
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const AsyncStorageMock = vi.mocked(AsyncStorage)
beforeEach(() => {
vi.clearAllMocks()
AsyncStorageMock.getItem.mockResolvedValue(null)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function makeClient() {
let onData: ((data: unknown) => void) | null = null
const sentRequests: { method: string; params: unknown }[] = []
const client = {
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(
async (method: string, _params: unknown = {}) =>
({
ok: true,
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
}) as never
)
}
// Why: onData is captured live via a getter (not destructured) because the
// subscribe mock assigns it asynchronously as a side effect of
// subscribeToDesktopNotifications calling client.subscribe.
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
sentRequests
function client() {
let handler: Handler | undefined
return {
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async () => ({ ok: true })),
subscribe: vi.fn((_method: string, _params: unknown, callback: Handler) => {
handler = callback
return vi.fn()
}),
emit(data: unknown) {
handler?.(data)
}
}
}
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
beforeEach(() => vi.clearAllMocks())
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
'notifications.getMissedSince',
expect.anything()
)
})
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
// because seq 11 (agent:dup) was already delivered on the live stream and
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 10
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open (no fetch).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream already delivered agent:dup (seq 11) before reap.
sub.onData?.({
describe('subscribeToDesktopNotifications', () => {
it('never presents an OS banner for socket alert or replay events', async () => {
const rpc = client()
subscribeToDesktopNotifications(rpc as never, 'host-1')
rpc.emit({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
rpc.emit({
type: 'notification',
source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
notificationId: 'agent-1',
title: 'Needs input',
body: 'Reply',
source: 'agent-task-complete'
})
await flushAsync()
// Reconnect ready → fetchMissed sends the watermark (11).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// The watermark passed to getMissedSince is the delivered seq.
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 11 })
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('voids a persisted watermark whose epoch predates a desktop restart', async () => {
// #8591: the desktop's seq counter restarts at 0 each launch while this watermark
// is persisted. Reconnecting to a restarted desktop with seq 57 would make
// `57 >= 2` true and silently kill catch-up. The epoch on 'ready' is what tells
// the client the counter changed, so the stale watermark must be dropped.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
: null
await Promise.resolve()
expect(requestNotificationCatchup).toHaveBeenCalledWith(
rpc,
'host-1',
undefined,
expect.any(Function)
)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// Cold open under the OLD desktop process, so the watermark loads as 57.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-before-restart' })
await flushAsync()
await flushAsync()
// Desktop restarts: new epoch, counter back near 0.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
await flushAsync()
await flushAsync()
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// The cold open catches up from its stored watermark against the SAME counter —
// 57 is meaningful there, so it is the correct cut (#8591 second pass).
expect(missedCalls[0]?.[1]).toEqual({
includeDesktopSuppressed: true,
lastSeenSeq: 57,
epoch: 'epoch-before-restart'
})
// After the restart the watermark is reset to 0 and tagged with the live epoch —
// not the stale 57, which would make `57 >= 2` true and kill catch-up silently.
expect(missedCalls.at(-1)?.[1]).toEqual({
includeDesktopSuppressed: true,
lastSeenSeq: 0,
epoch: 'epoch-after-restart'
})
expect(dismissHostPushNotification).not.toHaveBeenCalled()
})
it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => {
// The seed read is deliberately not awaited (so subscribe doesn't block on
// AsyncStorage), which means it can land AFTER 'ready' already adopted the live
// epoch. If it seeds unconditionally it reinstates the exact stale cut #8591 is
// about — the reset having already happened doesn't help, because the seed runs
// last and wins. Only a stored epoch matching the live one may seed.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
// Hold the storage read open so 'ready' is guaranteed to be processed first.
let releaseStorage: () => void = () => {}
const storageGate = new Promise<void>((resolve) => {
releaseStorage = resolve
})
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => {
await storageGate
return key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
: null
})
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// Live epoch adopted while the stored one is still in flight.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-after-restart' })
await flushAsync()
releaseStorage()
await flushAsync()
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({
includeDesktopSuppressed: true,
lastSeenSeq: 0,
epoch: 'epoch-after-restart'
})
})
it('keeps the persisted watermark when the desktop epoch is unchanged', async () => {
// The reset must be narrow: a plain socket reap with the same desktop process
// still has to send the real watermark, or every reconnect re-pushes the buffer.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-stable' })
: null
)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-stable' })
await flushAsync()
await flushAsync()
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-stable' })
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({
includeDesktopSuppressed: true,
lastSeenSeq: 57,
epoch: 'epoch-stable'
})
})
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
// (already delivered live) alongside a new seq 12.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
},
{
type: 'notification',
source: 'agent-task-complete',
title: 'new',
body: 'b',
notificationId: 'agent:new',
notificationSeq: 12
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivers seq 5.
sub.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 't',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 5
})
await flushAsync()
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsWatermark:host-1',
JSON.stringify({ seq: 5, epoch: null })
)
})
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
// and persist the watermark. This is the exact case the seq/notificationSeq
// field mismatch broke — the desktop replay path returns `notificationSeq`
// (matching the live fan-out), so the client watermark moves and the next
// reconnect resumes from it instead of re-fetching from 0.
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 8
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// First reconnect → replay delivers seq 8 (no prior live delivery).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// Watermark advanced to the replayed seq and was persisted.
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsWatermark:host-1',
JSON.stringify({ seq: 8, epoch: null })
)
// Second reconnect resumes from the advanced watermark, not 0.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCalls.at(-1)?.[1]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 8 })
})
it('replays a terminal bell at a seq the previous desktop counter already used', async () => {
// Round-1 review finding: seen-keys are seq-derived, and terminal bells carry no
// notificationId (they key on `seq:N` alone). Epoch A delivers a bell at seq 1;
// after a restart, epoch B's first bell is ALSO seq 1. The catch-up path is the
// one that consults the seen-set, so without clearing it on epoch change the
// replayed post-restart bell is mistaken for a duplicate and silently skipped —
// #8591's silent loss again, now one notification at a time.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Catch-up returns epoch B's first bell — same seq 1 the old counter used.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
epoch: 'epoch-B',
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'bell',
body: 'B',
notificationSeq: 1
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
await flushAsync()
// A live bell under epoch A — no notificationId, so its seen-key is `seq:1`.
sub.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'bell',
body: 'A',
notificationSeq: 1
})
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
// Desktop restarts; reconnect triggers catch-up against the fresh counter.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-B' })
await flushAsync()
await flushAsync()
// The post-restart bell must reach the user, not be swallowed as a stale `seq:1`.
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(2)
})
it('does not trust a legacy epoch-less watermark against a live counter', async () => {
// Round-1 review finding: pre-upgrade installs stored a bare seq with no epoch.
// Seeding it and then treating the first observed epoch as "nothing changed"
// leaves 57 cutting a counter it was never measured against — #8591 reached
// through the upgrade path. An unprovenanced seq may not survive epoch adoption.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
// Only the LEGACY key exists — exactly what an upgrading install has on disk.
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsLastSeq:') ? '57' : null
)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// Seed lands FIRST (no epoch known yet), so 57 is provisionally adopted...
await flushAsync()
await flushAsync()
// ...then the live epoch arrives for the first time.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
await flushAsync()
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-live' })
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// Must not be 57: that seq was never shown to belong to this counter.
expect(missedCall?.[1]).toEqual({
includeDesktopSuppressed: true,
lastSeenSeq: 0,
epoch: 'epoch-live'
})
})
it('catches up on the FIRST connection after an upgrade, without a second ready', async () => {
// Round-2 review finding: catch-up hung off `connectedBefore`, which is false on
// the first 'ready' of a process. So a cold app open — post-upgrade, or after the
// OS evicted the app — adopted the epoch but never replayed. Everything between
// the stored watermark and the next live seq was then lost permanently, because
// the first live event advances the watermark past the gap.
//
// The earlier migration test masked this by emitting a SECOND 'ready'. This one
// emits exactly one, which is what a real cold open does.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? JSON.stringify({ seq: 57, epoch: 'epoch-live' })
: null
)
const sub = makeClient()
vi.mocked(sub.client.sendRequest).mockImplementation(async (method: string) =>
method === 'notifications.getMissedSince'
? {
ok: true,
result: {
epoch: 'epoch-live',
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
notificationId: 'missed-58',
notificationSeq: 58,
notificationEpoch: 'epoch-live',
title: 'while the app was closed',
body: 'b'
}
]
}
}
: { ok: true, result: {} }
)
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
await flushAsync()
await flushAsync()
await flushAsync()
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
// The single 'ready' must replay from the stored watermark, not skip it.
expect(missedCall?.[1]).toEqual({
includeDesktopSuppressed: true,
lastSeenSeq: 57,
epoch: 'epoch-live'
})
// And the missed notification must actually reach the user.
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
})
it('does not replay the desktop buffer at a first-ever pairing', async () => {
// The other side of the finding above: with nothing stored, this device has never
// delivered for this host. Catching up would push the whole retained buffer at a
// user who was never subscribed for any of it.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
await flushAsync()
await flushAsync()
await flushAsync()
expect(
vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
).toHaveLength(0)
})
it('persists seq and epoch as one value so a crash cannot split the pair', async () => {
// Round-1 review finding: written as two keys, a process death between the writes
// leaves epoch-B beside seq-57-from-A. That pair looks internally valid on the
// next launch and is therefore trusted — silently cutting B's first 57 events.
// One key means the pair is always written whole or not at all.
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
await flushAsync()
sub.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 't',
body: 'b',
notificationId: 'agent:x',
notificationSeq: 9
})
await flushAsync()
// Every watermark write is a single key carrying both halves together.
const watermarkWrites = AsyncStorageMock.setItem.mock.calls.filter((c: unknown[]) =>
String(c[0]).startsWith('orca:mobileNotifications')
)
expect(watermarkWrites.length).toBeGreaterThan(0)
for (const [key, value] of watermarkWrites) {
expect(key).toBe('orca:mobileNotificationsWatermark:host-1')
expect(JSON.parse(String(value))).toHaveProperty('epoch')
expect(JSON.parse(String(value))).toHaveProperty('seq')
}
expect(JSON.parse(String(watermarkWrites.at(-1)?.[1]))).toEqual({
seq: 9,
epoch: 'epoch-A'
})
it('keeps socket dismissal processing active', async () => {
const rpc = client()
subscribeToDesktopNotifications(rpc as never, 'host-1')
rpc.emit({ type: 'ready', subscriptionId: 'sub-1' })
const dismissal = { type: 'dismiss', notificationId: 'agent-1', notificationSeq: 4 }
rpc.emit(dismissal)
await Promise.resolve()
expect(dismissHostPushNotification).toHaveBeenCalledWith(dismissal, 'host-1')
})
})
@@ -1,219 +1,22 @@
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
import { dismissHostPushNotification } from './push-socket-dismissal'
import type { DismissNotificationEvent } from './desktop-notification-events'
import type { RpcClient } from '../transport/rpc-client'
export {
ensureNotificationPermissions,
getNotificationPermissionState,
type NotificationPermissionState
} from './notification-permissions'
export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling'
import {
dismissLocalNotification,
showLocalNotification,
type DismissNotificationEvent,
type NotificationEvent
} from './local-notification-scheduling'
import { ensureDesktopNotificationChannel } from './desktop-notification-channel'
import {
adoptNotificationEpoch,
catchUpWatermarkSeq,
enqueueHostDelivery,
getHostNotificationSession,
quarantineCatchUpWatermark,
releaseQueuedShowNotificationId,
resolveCatchUpQuarantine,
saveWatermark,
seedWatermarkFromStorage,
seenKeyForEvent,
shouldQueueShowForNotificationId
} from './notification-reconnect-catchup'
import { markPresentedPushesSeen, readPresentedPushSeenKeys } from './push-tray-seen-seed'
type SubscribeResult = {
type: 'ready'
subscriptionId: string
// Desktop counter lifetime (#8591); absent from runtimes that predate it.
epoch?: string
}
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
void ensureDesktopNotificationChannel().catch(() => {})
let subscriptionId: string | null = null
let disposed = false
const deliveryAbort = new AbortController()
const session = getHostNotificationSession(hostId)
/**
* Queue one delivery on the host chain, dropping a show whose notificationId
* already has one queued.
*
* Why the claim is taken HERE and not inside deliverLive (#8591): the point of
* the dedup is to notice a second event arriving while the first is still
* outstanding. Inside the queued task the first has already finished, so the
* overlap is no longer observable — it has to be checked before enqueueing.
*/
function queueDelivery(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (
type === 'notification' &&
!shouldQueueShowForNotificationId(session, event.notificationId)
) {
return Promise.resolve()
}
// Tray cleanup must not wait for a background show to hand off on foreground.
const dismissal =
type === 'dismiss'
? dismissLocalNotification(event as DismissNotificationEvent, hostId)
: undefined
void dismissal?.catch(() => {})
return enqueueHostDelivery(session, async () => {
try {
await deliverLive(type, event, dismissal)
} finally {
if (type === 'notification') {
releaseQueuedShowNotificationId(session, event.notificationId)
}
}
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
// surface as an unhandled rejection (a RN redbox) instead of being retried by
// the next catch-up — which is now possible, since `seen` is marked after the show.
}).catch(() => {})
}
async function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent,
dismissal?: Promise<void>
): Promise<void> {
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
const epochAtDelivery = session.lastDeliveredEpoch
const key = seenKeyForEvent(event)
if (type === 'notification') {
const show =
!(event.notificationEpoch && key && session.seen.has(key)) &&
(await waitForSocketPushHandoff(event as NotificationEvent, hostId, deliveryAbort.signal))
if (disposed) {
throw new Error('notification_subscription_disposed')
}
if (show) {
await showLocalNotification(event as NotificationEvent, hostId)
}
} else {
await (dismissal ?? dismissLocalNotification(event as DismissNotificationEvent, hostId))
}
// Claim only after local delivery or a matching presented push.
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
session.seen.add(key)
}
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
// the live seq would let the next catch-up ask from above the gap and the desktop
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
}
async function deliverMissedEvent(
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
return
}
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
return
}
if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
// Preserve the delivered floor if catch-up fails.
const askFrom = catchUpWatermarkSeq(session)
// Read concurrently; claim inside the queue after epoch adoption to avoid stale keys.
const presentedPushKeys = readPresentedPushSeenKeys(hostId)
const missed = await requestNotificationCatchup(
client,
hostId,
{
lastSeenSeq: askFrom,
includeDesktopSuppressed: true,
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
},
() => disposed
)
.then((response) => {
if (!response.ok) {
return null
}
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
adoptNotificationEpoch(session, hostId, result?.epoch)
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => null)
if (missed == null) {
// Why quarantine rather than retry: the range this catch-up abandoned stays
// unrecovered until SOME later one succeeds, and a live seq persisting past it
// meanwhile would make the desktop cut it forever.
quarantineCatchUpWatermark(session, hostId, askFrom)
return
}
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
// the event loop between replays, so a live seq 11 slots into the chain between
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
markPresentedPushesSeen(session, await presentedPushKeys)
let contiguousSeq = askFrom
let drained = false
try {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
const event = raw as NotificationEvent | DismissNotificationEvent
await deliverMissedEvent(event)
contiguousSeq = event.notificationSeq ?? contiguousSeq
}
drained = true
} finally {
if (drained) {
resolveCatchUpQuarantine(session, hostId)
} else {
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
}
}
// Why swallowed here: the `finally` above already recorded the contiguous point,
// and the only caller is an un-awaited 'ready' continuation — letting a failed
// show escape turns every one into an unhandled rejection (a RN redbox).
}).catch(() => {})
}
seedWatermarkFromStorage(session, hostId)
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
@@ -223,81 +26,26 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
const params = { includeDesktopSuppressed: true }
const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => {
const event = data as
| NotificationEvent
| DismissNotificationEvent
| SubscribeResult
| { type: 'end' }
const event = data as DismissNotificationEvent | SubscribeResult | { type: string }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
const isReconnect = session.connectedBefore
session.connectedBefore = true
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
const readyEpoch = (event as SubscribeResult).epoch
// Why (#8591) the await: on a cold app open the persisted read is still in
// flight, so deciding here would see watermarkLoaded false and skip catch-up —
// which is precisely the post-upgrade / post-process-death case that loses
// every notification between the stored watermark and the next live seq.
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why before fetchMissed: adopting the epoch here is what voids a watermark
// left over from a previous desktop lifetime, so the catch-up request carries
// a watermark that means something against the counter now answering it.
adoptNotificationEpoch(session, hostId, readyEpoch)
// First pairing recovers tray dismissals without replaying historical alerts.
if (isReconnect || session.hadStoredWatermark) {
await fetchMissed()
} else {
await requestNotificationCatchup(client, hostId, undefined, () => disposed).catch(
() => {}
)
}
})()
// A max watermark asks only which delivered pushes are stale; socket history
// never becomes a second OS-notification delivery route.
void requestNotificationCatchup(client, hostId, undefined, () => disposed).catch(() => {})
return
}
if (event.type === 'end') {
if (disposed) {
unsubscribeStream()
}
return
if (!disposed && event.type === 'dismiss') {
void dismissHostPushNotification(event as DismissNotificationEvent, hostId).catch(() => {})
}
if (disposed) {
return
}
if (event.type !== 'notification' && event.type !== 'dismiss') {
return
}
// Why the await (#8591): deliverLive advances the watermark. A live event landing
// while the persisted read is still in flight would push it past the buffered seqs
// the catch-up is about to ask for, and getMissedSince would cut them. Ordering is
// preserved — every handler waits on the same promise, and the 'ready' continuation
// registered on it first, so catch-up still builds its request before any live seq.
const liveEvent = event
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why the queue (#8591): a live event must not overtake an in-flight
// catch-up replay, or it persists a watermark past seqs still unshown.
await queueDelivery(
liveEvent.type === 'notification' ? 'notification' : 'dismiss',
liveEvent as NotificationEvent | DismissNotificationEvent
)
})()
})
return () => {
disposed = true
deliveryAbort.abort()
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
unsubscribeStream()
if (subscriptionId) {
unsubscribeServer(subscriptionId)
@@ -1,327 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
type MissedOutcome =
| { kind: 'reject' }
| { kind: 'notOk' }
| { kind: 'ok'; notifications: unknown[] }
// Rejects only once `settle()` is called, so a live event can land mid-request.
| { kind: 'heldReject' }
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const askedFrom: number[] = []
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
let releaseHeld: (() => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method !== 'notifications.getMissedSince') {
return { ok: true, result: undefined } as never
}
askedFrom.push(
(params as { includeDesktopSuppressed: true; lastSeenSeq: number }).lastSeenSeq
)
if (outcome.kind === 'heldReject') {
await new Promise<void>((resolve) => {
releaseHeld = resolve
})
throw new Error('socket closed')
}
if (outcome.kind === 'reject') {
throw new Error('socket closed')
}
if (outcome.kind === 'notOk') {
return { ok: false, error: { message: 'timeout' } } as never
}
return { ok: true, result: { notifications: outcome.notifications } } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
askedFrom,
setOutcome(next: MissedOutcome) {
outcome = next
},
settleHeld() {
releaseHeld?.()
}
}
}
function notification(seq: number) {
return {
type: 'notification',
source: 'agent-task-complete',
title: `m${seq}`,
body: 'b',
notificationId: `agent:${seq}`,
notificationSeq: seq
}
}
describe('#8591 catch-up failure quarantines the watermark', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
// window stays open until some catch-up succeeds, not for one round trip.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'reject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
// Second catch-up also fails; the gap is still open.
host.setOutcome({ kind: 'notOk' })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(5)
// Third succeeds and replays the abandoned range.
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5])
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
// m11/m12 are the live events that kept flowing while the gap stayed open.
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
// Only now may the watermark move past the recovered range.
expect(persistedSeq()).toBe(12)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5, 12])
})
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
// the next launch reads it back and resumes past the range this catch-up abandoned.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'heldReject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(11)
host.settleHeld()
await flushAsync()
expect(persistedSeq()).toBe(5)
})
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
// The batch can start before a teardown and still be draining after it, so the
// events past the interruption were never shown. A live seq arriving on the next
// connection must not persist over them.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({
kind: 'ok',
notifications: [notification(6), notification(7), notification(8)]
})
let unsubscribe: (() => void) | null = null
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
if ((request as { content: { title: string } }).content.title === 'm6') {
unsubscribe?.()
}
return 'sched-1'
})
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6'])
// A fresh subscription on the same module-scope session takes a live seq 20 before
// its own catch-up, then resumes from 6 rather than from 20.
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
const host2 = makeHostClient()
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
subscribeToDesktopNotifications(host2.client, 'host-1')
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(6)
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
await flushAsync()
expect(host2.askedFrom).toEqual([6])
expect(
vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
).toEqual(['m6', 'm20', 'm7', 'm8'])
expect(persistedSeq()).toBe(20)
})
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
// The quarantine only holds the RANGE. If the failing event is also marked seen,
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
// never shown, and the first later event to drain the batch lifts the quarantine
// past it. Silent loss with the watermark looking healthy.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
const title = (request as { content: { title: string } }).content.title
if (title === 'm6' && failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6', 'm7'])
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(7)
})
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
// The same hole without any catch-up failing: the live path marks seen before the
// show, so a rejected show leaves the key behind while the watermark stays put.
// The next catch-up dutifully re-fetches the seq and the guard eats it.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
if (failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6'])
expect(persistedSeq()).toBe(6)
})
})
@@ -1,265 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
let getItemImpl: (key: string) => Promise<string | null> = async (key) => storage.get(key) ?? null
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => getItemImpl(key)),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
describe('#8591 per-host delivery ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
getItemImpl = async (key) => storage.get(key) ?? null
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('never persists a watermark past a notification the catch-up has not shown', async () => {
// The watermark is a promise that everything up to that seq reached the user.
// If a live seq 11 is processed while catch-up is still showing seq 6, it
// persists 11 — and a process death before 7 is shown loses 7 forever, because
// the next launch asks the desktop for seq > 11. That is the original #8591
// loss re-entered through concurrency rather than through a restarted counter.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return 'sched-1'
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'm6',
body: 'b',
notificationId: 'a:6',
notificationSeq: 6
},
{
type: 'notification',
source: 'agent-task-complete',
title: 'm7',
body: 'b',
notificationId: 'a:7',
notificationSeq: 7
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Live seq 11 arrives while the replay is wedged on seq 6.
onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'live-11',
body: 'b',
notificationId: 'a:11',
notificationSeq: 11
})
await flushAsync()
expect(persistedSeq()).toBeLessThan(6)
releaseFirstShow()
await flushAsync()
// Once the chain drains, everything is shown and the watermark catches up.
expect(persistedSeq()).toBe(11)
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm7', 'live-11'])
})
it('shows one banner when a replay and a live event carry the same notification id', async () => {
// Serializing deliveries removed the overlap the old dedup relied on: the
// replay's show now COMPLETES before the live duplicate starts, so nothing is
// pending for it to observe and the user gets the same notification twice.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return `sched-${shown}`
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 6
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Same id arrives live while the replay's show is still blocked. A different
// seq, so the seen-set does not catch it — only the queued-show claim does.
onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 7
})
await flushAsync()
releaseFirstShow()
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1)
})
it('still delivers when the persisted watermark read never resolves', async () => {
// Every delivery awaits the seed, so a wedged AsyncStorage read would disable
// this host's notifications for the whole app lifetime — silently.
getItemImpl = (key) =>
key.startsWith('orca:mobileNotificationsWatermark:')
? new Promise<string | null>(() => {})
: Promise.resolve(null)
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never)
} as unknown as RpcClient
vi.useFakeTimers()
try {
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'live-1',
body: 'b',
notificationId: 'a:1',
notificationSeq: 1
})
await vi.advanceTimersByTimeAsync(3100)
} finally {
vi.useRealTimers()
}
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toContain('live-1')
})
})
@@ -7,10 +7,9 @@ import {
saveNotificationDeliveryPreferences
} from './notification-delivery-preferences'
import {
allowsLocalNotification,
setNotificationViewingWorkspace
setNotificationViewingWorkspace,
shouldSuppressNotificationWhileViewing
} from './notification-viewing-policy'
import { allowsMobileNotification } from '../../../src/shared/mobile-notification-policy'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -28,68 +27,65 @@ beforeEach(() => {
AppState.currentState = 'background'
})
it('defaults to following desktop and persists independent event preferences', async () => {
it('persists only phone-specific delivery preferences', async () => {
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
const value = {
...DEFAULT_NOTIFICATION_DELIVERY,
followDesktop: false,
terminalBell: false,
onlyWhenDesktopAway: false,
sound: false
}
await saveNotificationDeliveryPreferences(value)
expect(await loadNotificationDeliveryPreferences()).toEqual(value)
expect(notificationPreferencesFilter(value)).toMatchObject({
followDesktop: false,
followDesktop: true,
onlyWhenDesktopAway: false,
sound: false,
sources: ['agent-task-complete', 'plugin']
expireAfterInactivity: true,
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
agentStates: ['needs-input', 'finished']
})
})
it('preserves explicitly narrowed filters from before the new settings screen', async () => {
storage.set('orca:remotePushAgentStates', '["needs-input"]')
expect(await loadNotificationDeliveryPreferences()).toMatchObject({
followDesktop: false,
needsInput: true,
taskFinished: false
it('ignores obsolete category overrides from the previous settings screen', async () => {
storage.set(
'orca:notificationDeliveryPreferences',
JSON.stringify({
onlyWhenDesktopAway: false,
sound: false,
suppressWhileViewing: false,
followDesktop: false,
taskFinished: false,
needsInput: false,
terminalBell: false,
plugin: false
})
)
expect(await loadNotificationDeliveryPreferences()).toEqual({
onlyWhenDesktopAway: false,
sound: false,
suppressWhileViewing: false
})
expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toMatchObject({
followDesktop: true,
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
agentStates: ['needs-input', 'finished']
})
})
it.each(['agent-task-complete', 'terminal-bell', 'plugin'])(
'uses identical type filtering for socket/replay and background push: %s',
async (source) => {
for (const followDesktop of [true, false]) {
const value = {
...DEFAULT_NOTIFICATION_DELIVERY,
followDesktop,
terminalBell: false,
taskFinished: false
}
await saveNotificationDeliveryPreferences(value)
for (const desktopAllowed of [true, false]) {
const event = { source, desktopAllowed, agentState: 'done' }
expect(await allowsLocalNotification(event, 'host')).toBe(
allowsMobileNotification(notificationPreferencesFilter(value), event)
)
}
}
}
)
it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => {
const event = { source: 'terminal-bell', worktreeId: 'folder-id' }
setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' })
AppState.currentState = 'active'
expect(await allowsLocalNotification(event, 'ssh-host')).toBe(false)
expect(await allowsLocalNotification(event, 'another-host')).toBe(true)
expect(await allowsLocalNotification({ ...event, worktreeId: 'other' }, 'ssh-host')).toBe(true)
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host')).toBe(true)
expect(await shouldSuppressNotificationWhileViewing(event, 'another-host')).toBe(false)
expect(
await shouldSuppressNotificationWhileViewing({ ...event, worktreeId: 'other' }, 'ssh-host')
).toBe(false)
AppState.currentState = 'background'
expect(await allowsLocalNotification(event, 'ssh-host')).toBe(true)
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host')).toBe(false)
})
it.each(['orca:notificationDeliveryPreferences', 'orca:remotePushAgentStates'])(
'recovers defaults from malformed stored %s',
async (key) => {
storage.set(key, '{broken')
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
}
)
it('recovers defaults from malformed stored preferences', async () => {
storage.set('orca:notificationDeliveryPreferences', '{broken')
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
})
@@ -8,22 +8,12 @@ import {
const KEY = 'orca:notificationDeliveryPreferences'
export type NotificationDeliveryPreferences = {
onlyWhenDesktopAway: boolean
followDesktop: boolean
taskFinished: boolean
needsInput: boolean
terminalBell: boolean
plugin: boolean
sound: boolean
suppressWhileViewing: boolean
}
export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = {
onlyWhenDesktopAway: true,
followDesktop: true,
taskFinished: true,
needsInput: true,
terminalBell: true,
plugin: true,
sound: true,
suppressWhileViewing: true
}
@@ -32,19 +22,6 @@ export async function loadNotificationDeliveryPreferences(): Promise<Notificatio
try {
const raw = await AsyncStorage.getItem(KEY)
if (!raw) {
// Preserve an existing explicit background filter when upgrading.
const legacy = await AsyncStorage.getItem('orca:remotePushAgentStates')
if (legacy) {
const states: unknown = JSON.parse(legacy)
if (Array.isArray(states)) {
return {
...DEFAULT_NOTIFICATION_DELIVERY,
followDesktop: false,
taskFinished: states.includes('finished'),
needsInput: states.includes('needs-input')
}
}
}
return { ...DEFAULT_NOTIFICATION_DELIVERY }
}
const stored = JSON.parse(raw) as Record<string, unknown>
@@ -69,30 +46,12 @@ export async function saveNotificationDeliveryPreferences(
export function notificationPreferencesFilter(
value: NotificationDeliveryPreferences
): MobilePushFilter {
if (value.followDesktop) {
return {
onlyWhenDesktopAway: value.onlyWhenDesktopAway,
expireAfterInactivity: true,
sound: value.sound,
followDesktop: true,
sources: MOBILE_PUSH_SOURCES,
agentStates: MOBILE_PUSH_AGENT_STATES
}
}
return {
onlyWhenDesktopAway: value.onlyWhenDesktopAway,
expireAfterInactivity: true,
followDesktop: false,
followDesktop: true,
sound: value.sound,
sources: MOBILE_PUSH_SOURCES.filter((source) =>
source === 'terminal-bell'
? value.terminalBell
: source === 'plugin'
? value.plugin
: value.needsInput || value.taskFinished
),
agentStates: MOBILE_PUSH_AGENT_STATES.filter((state) =>
state === 'needs-input' ? value.needsInput : value.taskFinished
)
sources: MOBILE_PUSH_SOURCES,
agentStates: MOBILE_PUSH_AGENT_STATES
}
}
@@ -1,211 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { showLocalNotification } from './local-notification-scheduling'
import { Platform } from 'react-native'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
// Why (#8591): the reconnect watermark/seen-set now live per host at module
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
// tests so each case starts from a genuine cold open.
resetHostNotificationSessionsForTests()
})
describe('subscribeToDesktopNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Why the macrotask and not N microtask ticks (#8591): deliveries now run through
// the per-host serialization queue, so a delivery is several more `await` hops deep
// than it used to be and a fixed tick count silently under-drains. Yielding to the
// macrotask queue drains whatever depth the chain happens to have.
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
it('drops the local stream when disposed before the desktop returns ready', () => {
const unsubscribeStream = vi.fn()
const client = {
subscribe: vi.fn(() => unsubscribeStream),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
const unsubscribe = subscribeToDesktopNotifications(client, 'host-1')
unsubscribe()
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('stores scheduled notification identifiers, replaces duplicates, and dismisses by id', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync)
.mockResolvedValueOnce('scheduled-1')
.mockResolvedValueOnce('scheduled-2')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-1')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
worktreeId: 'repo::/tmp/worktree',
notificationId: 'agent:one'
})
await flushAsync()
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done again',
body: 'Finished again.',
notificationId: 'agent:one'
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
onEvent?.({ type: 'dismiss', notificationId: 'agent:one' })
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
expect(Notifications.scheduleNotificationAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
content: expect.objectContaining({
data: expect.objectContaining({
hostId: 'host-1',
notificationId: 'agent:one',
worktreeId: 'repo::/tmp/worktree'
})
})
})
)
expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(1, 'scheduled-1')
expect(Notifications.dismissNotificationAsync).toHaveBeenNthCalledWith(2, 'scheduled-2')
})
it('dedupes concurrent notification events with the same desktop notification id', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-concurrent')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:concurrent'
})
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:concurrent'
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
})
})
it('filters before cooldown and retains the existing banner when a later burst is suppressed', async () => {
vi.clearAllMocks()
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify({ followDesktop: false, terminalBell: false })
)
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('cooldown-banner')
const event = {
type: 'notification' as const,
title: 'Done',
body: '',
worktreeId: 'folder',
notificationId: 'cooldown-event',
emittedAt: 10000
}
await showLocalNotification({ ...event, source: 'terminal-bell' }, 'cooldown-host')
await showLocalNotification(
{ ...event, source: 'agent-task-complete', agentState: 'done', emittedAt: 10250 },
'cooldown-host'
)
await showLocalNotification(
{ ...event, source: 'agent-task-complete', agentState: 'done', emittedAt: 10500 },
'cooldown-host'
)
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(1)
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
@@ -1,251 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
// Why (#8591): the reconnect watermark/seen-set now live per host at module
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
// tests so each case starts from a genuine cold open.
resetHostNotificationSessionsForTests()
})
describe('subscribeToDesktopNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Why the macrotask and not N microtask ticks (#8591): deliveries now run through
// the per-host serialization queue, so a delivery is several more `await` hops deep
// than it used to be and a fixed tick count silently under-drains. Yielding to the
// macrotask queue drains whatever depth the chain happens to have.
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
function makeDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => {
resolve = next
})
return { promise, resolve }
}
it('dismisses a notification when dismiss arrives while scheduling is pending', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
let resolveSchedule!: (identifier: string) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveSchedule = resolve
})
)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-dismiss-race')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:pending'
})
await flushAsync()
onEvent?.({ type: 'dismiss', notificationId: 'agent:pending' })
resolveSchedule('scheduled-pending')
await flushAsync()
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-pending')
})
it('does not carry a failed pending dismiss into a future schedule', async () => {
const secondEnabled = makeDeferred<boolean>()
vi.mocked(loadPushNotificationsEnabled)
.mockResolvedValueOnce(true)
.mockReturnValueOnce(secondEnabled.promise)
.mockResolvedValueOnce(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync)
.mockResolvedValueOnce('scheduled-1')
.mockResolvedValueOnce('scheduled-2')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: 'Finished.',
notificationId: 'agent:stale-dismiss'
})
await flushAsync()
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done again',
body: 'Finished again.',
notificationId: 'agent:stale-dismiss'
})
await flushAsync()
onEvent?.({ type: 'dismiss', notificationId: 'agent:stale-dismiss' })
secondEnabled.resolve(false)
await flushAsync()
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 'Done later',
body: 'Finished later.',
notificationId: 'agent:stale-dismiss'
})
await flushAsync()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-1')
})
it('treats unknown dismiss events as no-ops', async () => {
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-unknown')
onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
// Why: notificationId is unique per completion, so the map grew unbounded when
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
setScheduledNotificationsMaxForTests(1)
try {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync)
.mockResolvedValueOnce('scheduled-old')
.mockResolvedValueOnce('scheduled-new')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-1')
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 't',
body: 'b',
notificationId: 'agent:old'
})
await flushAsync()
onEvent?.({
type: 'notification',
source: 'agent-task-complete',
title: 't',
body: 'b',
notificationId: 'agent:new'
})
await flushAsync()
// The older entry was evicted by the cap: dismissing it is a no-op...
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
// ...while the most-recent entry is retained and still dismissable.
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
} finally {
setScheduledNotificationsMaxForTests()
}
})
})
@@ -1,415 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
// The highest desktop notification seq this device has delivered is persisted
// per-host so it survives app restarts. On reconnect we send it to
// notifications.getMissedSince as the catch-up watermark — the desktop then
// returns only notifications dispatched after it, so we never re-push a
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
// Why (#8591): a seq is meaningless without the counter it indexes — after a
// desktop restart that counter is gone. The epoch names the counter's lifetime so
// a reconnect can tell "nothing missed" from "different counter".
//
// Why ONE key holding both, rather than a key each: they are only meaningful as a
// pair. Written separately, a process death between the two writes leaves an epoch
// from one counter beside a seq from another — a pair that looks internally valid
// on the next launch and is therefore trusted, silently cutting real notifications.
// A single JSON value cannot tear that way.
const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:'
// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written.
const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function watermarkStorageKey(hostId: string): string {
return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
// A null epoch means "the counter this seq came from is unknown" — a legacy
// watermark, or nothing stored. It can never be assumed to be the live counter.
export type PersistedWatermark = { seq: number; epoch: string | null }
// `stored` is the record's existence, independent of its seq: it answers "has this
// device ever been subscribed to this host", which is what a cold open needs to tell
// a returning device from a first pairing. A seq of 0 is a real answer, not an absence.
export type LoadedWatermark = PersistedWatermark & { stored: boolean }
function coerceSeq(value: unknown): number {
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
export async function loadWatermark(hostId: string): Promise<LoadedWatermark> {
try {
const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId))
if (raw != null) {
const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown }
const epoch =
typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null
return { seq: coerceSeq(parsed.seq), epoch, stored: true }
}
} catch {
// Unreadable or malformed: fall through to the legacy key rather than throw.
}
try {
const legacy = await AsyncStorage.getItem(
LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
)
return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null }
} catch {
return { seq: 0, epoch: null, stored: false }
}
}
export async function clearWatermark(hostId: string): Promise<void> {
// Why both keys: loadWatermark falls back to the legacy one, so removing only the
// current key would let a re-paired host resurrect a pre-#8591 seq from a counter
// lifetime that is long gone — the exact stale cut this fix removes.
await Promise.all([
AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}),
AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch(
() => {}
)
])
}
export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise<void> {
try {
await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
// delivered notification. That's the accepted at-least-once trade-off;
// within a live session the in-memory watermark is authoritative, so only
// post-restart reconnects are affected.
}
}
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
// on the current connection. The desktop already dedupes by seq on replay, but
// a socket that flickers background→foreground→background can deliver an event
// on the live stream and again in a replay; the seen-set guarantees each
// notificationId maps to at most one local push for the connection lifetime.
// Bounded so a long-lived session can't grow without limit — a 2x superset of
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
clear: () => void
} {
const seen = new Set<string>()
return {
has(id: string): boolean {
return seen.has(id)
},
add(id: string): void {
seen.add(id)
if (seen.size > RECENTLY_SEEN_CAP) {
// Why: insertion order; the oldest entries are first. Drop one to stay
// bounded without disturbing the more-recently-relevant keys.
const first = seen.values().next().value
if (first !== undefined) {
seen.delete(first)
}
}
},
clear(): void {
seen.clear()
}
}
}
// Why (#8591): app/index.tsx tears the notification subscription down on every
// non-'connected' state and builds a fresh one on reconnect, so everything held
// in the subscription closure — the ready counter, the delivered watermark, the
// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host
// at module scope is what makes the catch-up recognise a reconnect (instead of
// mistaking it for a cold open) and keeps dedup effective across the teardown.
export type HostNotificationSession = {
// Highest desktop seq delivered for this host in this app process. Outranks
// the persisted value, which lags because saveLastSeenSeq is fire-and-forget.
lastDeliveredSeq: number
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
// mismatch on reconnect means the desktop restarted and the watermark is void.
lastDeliveredEpoch: string | null
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
catchUpQuarantineSeq: number | null
seen: ReturnType<typeof createSeenNotificationGuard>
// False only until the host's first subscription reaches 'ready' — a true cold open.
connectedBefore: boolean
// Why (#8591): distinguishes "this device has delivered for this host before"
// from a first-ever pairing. Only the former may catch up on a cold open — a
// brand-new pairing fetching from seq 0 would push the desktop's whole buffer
// at someone who was never subscribed for any of it.
hadStoredWatermark: boolean
// Resolves once the persisted read has landed, so the first 'ready' can wait for
// it instead of deciding catch-up against an unread watermark.
watermarkSeeded: Promise<void> | null
// Tail of the per-host delivery chain; see enqueueHostDelivery.
deliveryTail: Promise<void>
// notificationIds with a show queued or in flight on that chain; see
// shouldQueueShowForNotificationId.
queuedShowIds: Set<string>
}
const sessionsByHost = new Map<string, HostNotificationSession>()
export function getHostNotificationSession(hostId: string): HostNotificationSession {
let session = sessionsByHost.get(hostId)
if (!session) {
session = {
lastDeliveredSeq: 0,
lastDeliveredEpoch: null,
catchUpQuarantineSeq: null,
seen: createSeenNotificationGuard(),
connectedBefore: false,
hadStoredWatermark: false,
watermarkSeeded: null,
deliveryTail: Promise.resolve(),
queuedShowIds: new Set<string>()
}
sessionsByHost.set(hostId, session)
}
return session
}
/**
* Run `task` after every delivery already queued for this host, and return a
* promise for its completion.
*
* Why (#8591): the watermark is persisted by whichever delivery advances it, so
* replay and live delivery running concurrently can persist out of order. A live
* seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a
* process death before 7..10 are shown loses them permanently — the next launch
* asks the desktop for seq > 11. Serializing per host makes the watermark's
* monotonic advance mean "everything up to here was actually delivered".
*
* A rejected task does not break the chain: the tail swallows the failure so a
* single bad notification cannot wedge the host's queue forever.
*/
export function enqueueHostDelivery<T>(
session: HostNotificationSession,
task: () => Promise<T>
): Promise<T> {
const run = session.deliveryTail.then(task)
session.deliveryTail = run.then(
() => {},
() => {}
)
return run
}
/**
* Claim a notificationId for a queued show, returning false if one is already
* queued or in flight for it.
*
* Why this exists (#8591): showLocalNotification deduped two same-id events by
* observing that the first was still pending when the second arrived. Serializing
* deliveries removed that overlap — the first now COMPLETES before the second
* starts, so the second reads no pending state and schedules a second banner for
* the same notification. The dedup has to happen where concurrency is still
* visible, which after serialization is enqueue time rather than delivery time.
*
* Only shows are tracked. A dismiss for the same id must still run: it is the
* mechanism that retires the notification the show created.
*/
export function shouldQueueShowForNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): boolean {
if (notificationId == null) {
return true
}
if (session.queuedShowIds.has(notificationId)) {
return false
}
session.queuedShowIds.add(notificationId)
return true
}
/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */
export function releaseQueuedShowNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): void {
if (notificationId != null) {
session.queuedShowIds.delete(notificationId)
}
}
/** Test-only: drop per-host session state so each test starts from a cold open. */
export function resetHostNotificationSessionsForTests(): void {
sessionsByHost.clear()
}
/**
* Freeze the catch-up watermark at the last seq known delivered contiguously,
* after a catch-up that did not complete.
*
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
* the desktop cuts by seq, so those notifications are never replayed and are
* gone. Lowest wins: an earlier failure's gap is still open.
*/
export function quarantineCatchUpWatermark(
session: HostNotificationSession,
hostId: string,
contiguousSeq: number
): void {
session.catchUpQuarantineSeq =
session.catchUpQuarantineSeq == null
? contiguousSeq
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
// Why re-persist: a live event delivered while the catch-up was still in flight
// already stored a seq above the gap. Clamping only later writes would leave that
// value on disk, so a restart still resumes past the abandoned range.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
if (session.catchUpQuarantineSeq == null) {
return
}
session.catchUpQuarantineSeq = null
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
epoch: session.lastDeliveredEpoch
})
}
/**
* The seq a catch-up may ask from and the highest seq safe to persist — the live
* watermark, clamped to any open gap.
*/
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
return session.catchUpQuarantineSeq == null
? session.lastDeliveredSeq
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
}
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
// from a previous lifetime indexes a counter that no longer exists. Comparing it
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
// catch-up dies silently until the new process out-dispatches the old watermark.
// Adopting the new epoch means dropping the watermark with it.
export function adoptNotificationEpoch(
session: HostNotificationSession,
hostId: string,
epoch: string | undefined
): void {
if (!epoch || epoch === session.lastDeliveredEpoch) {
return
}
// Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded
// from a legacy store carries no epoch, so it cannot be shown to belong to this
// counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 —
// the exact #8591 failure, reached through the upgrade path instead of a restart.
session.lastDeliveredSeq = 0
// Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have
// no notificationId at all (they key on `seq:N` alone). Across a restart the new
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
// the new counter's first bell. The dedup window belongs to one counter lifetime.
session.seen.clear()
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
session.catchUpQuarantineSeq = null
session.lastDeliveredEpoch = epoch
void saveWatermark(hostId, { seq: 0, epoch })
}
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
// Only the first subscription for a host needs it; later ones inherit the live value.
/**
* Ms the persisted read may block catch-up and live delivery before they proceed
* without it. AsyncStorage normally answers in single-digit ms; a read that has
* not landed by now is assumed wedged.
*
* Why a bound at all (#8591): every delivery awaits this promise, so a read that
* never settles silently disables notifications for the host for the whole app
* lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly
* better: the watermark stays 0, so catch-up over-fetches and the seen-set
* de-duplicates, which costs a redundant request instead of every notification.
*/
const WATERMARK_SEED_TIMEOUT_MS = 3000
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms)
void promise.then(
() => {
clearTimeout(timer)
resolve()
},
() => {
clearTimeout(timer)
resolve()
}
)
})
}
export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void {
if (session.watermarkSeeded) {
return
}
const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => {
// Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists
// `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for
// this host reloads as seq 0. Keying on the seq would read that as a first pairing
// and skip catch-up for the whole window the epoch change was meant to recover.
if (stored) {
session.hadStoredWatermark = true
}
// Why the epoch comparison: this read can land AFTER 'ready' already adopted a
// live epoch. If the stored watermark belongs to a different (older) counter,
// applying it here would silently reinstate exactly the stale cut this fixes.
// A null stored epoch is a legacy watermark of unknown provenance — it may only
// seed while no live epoch is known, and adopting one later resets it.
if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) {
session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq)
if (session.lastDeliveredEpoch === null && epoch !== null) {
session.lastDeliveredEpoch = epoch
}
}
})
// The late seed still applies when it eventually lands; the timeout only stops it
// from holding delivery hostage. `seeded` never rejects into the awaiters.
session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS)
}
// Why (#8591): sessions live at module scope so they survive the subscription
// teardown a reconnect performs. Nothing else drops them, so a host that is removed
// and re-paired would retain its session and up to 512 seen keys until app restart.
export function forgetHostNotificationSession(hostId: string): void {
sessionsByHost.delete(hostId)
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
// live is suppressed. Replay events always carry a seq (the desktop assigns
// one), so the guard is effective on the reconnect path.
export function seenKeyForEvent(event: {
notificationId?: string
notificationSeq?: number
}): string | null {
const id = event.notificationId
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (id != null) {
return `id:${id}`
}
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}
@@ -1,215 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
// In-memory AsyncStorage so the persisted watermark survives across the
// subscribe/unsubscribe cycles this test exercises (the real device behaviour).
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (k: string) => storage.get(k) ?? null),
setItem: vi.fn(async (k: string, v: string) => {
storage.set(k, v)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
// Models mobile/app/index.tsx:497-537: a per-host client whose notification
// subscription is torn down on any non-'connected' state and re-created from
// scratch on the next 'connected'.
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const getMissedCalls: { includeDesktopSuppressed: true; lastSeenSeq: number }[] = []
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
getMissedCalls.push(params as { includeDesktopSuppressed: true; lastSeenSeq: number })
return { ok: true, result: { notifications: missedQueue } } as never
}
return { ok: true, result: undefined } as never
})
}
let missedQueue: unknown[] = []
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
getMissedCalls,
setMissed(events: unknown[]) {
missedQueue = events
}
}
}
describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
vi.mocked(AsyncStorage.getItem).mockClear()
})
it('fetches missed notifications after a disconnect tears the subscription down', async () => {
const host = makeHostClient()
// ── Connected: cold open, one live notification delivered (desktop seq 7).
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
host.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'live',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 7
})
await flushAsync()
// ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the
// non-'connected' state, destroying the subscribeToDesktopNotifications
// closure (and with it reconnectReadyCount / lastDeliveredSeq).
unsub()
await flushAsync()
// ── While disconnected the desktop dispatched seq 8 and 9.
host.setMissed([
{
type: 'notification',
source: 'agent-task-complete',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
notificationSeq: 8
},
{
type: 'notification',
source: 'agent-task-complete',
title: 'missed-9',
body: 'b',
notificationId: 'agent:m9',
notificationSeq: 9
}
])
// ── Reconnected: app re-subscribes with a FRESH closure.
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
// The user must be told about seq 8 and 9. Nothing else can deliver them:
// the desktop only fans out live, so this catch-up is the only path.
expect(host.getMissedCalls).toHaveLength(1)
expect(host.getMissedCalls[0]).toEqual({ includeDesktopSuppressed: true, lastSeenSeq: 7 })
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
expect(titles).toContain('missed-8')
expect(titles).toContain('missed-9')
})
it('does not re-push a live notification the catch-up replays after a teardown', async () => {
// Why: the seen-set lives on the host session precisely so it survives the teardown.
// getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the
// brief window before the drop is still inside the desktop's retained buffer, so the
// reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate
// banner for something the user was already shown.
const host = makeHostClient()
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
host.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
notificationSeq: 7
})
await flushAsync()
unsub()
await flushAsync()
// The desktop replays seq 7 alongside the genuinely-missed seq 8.
host.setMissed([
{
type: 'notification',
source: 'agent-task-complete',
title: 'live-7',
body: 'b',
notificationId: 'agent:seven',
notificationSeq: 7
},
{
type: 'notification',
source: 'agent-task-complete',
title: 'missed-8',
body: 'b',
notificationId: 'agent:m8',
notificationSeq: 8
}
])
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
expect(titles.filter((title) => title === 'live-7')).toHaveLength(1)
expect(titles).toContain('missed-8')
})
})
@@ -1,237 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { sha256 } from '@noble/hashes/sha256'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
// Why this file exists: a push the OS drew while Orca was closed never runs through
// the foreground handler, so nothing marks it seen. The reconnect catch-up then
// replays the same event and the user gets a second banner for it.
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function presentTray(entries: readonly Record<string, unknown>[]): void {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue(
entries.map((orca, index) => ({
request: {
identifier: `tray-${index}`,
content: { data: null },
trigger: { type: 'push', payload: { orca } }
}
})) as never
)
}
function shownTitles(): string[] {
return vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
/** A catch-up that replays seq 6 and 7 for host-1. */
function catchUpClient(): { client: RpcClient; ready: () => void } {
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method: string, _params: unknown, callback: (data: unknown) => void) => {
onData = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
source: 'agent-task-complete',
title: 'm6',
body: 'b',
notificationId: 'a:6',
notificationSeq: 6
},
{
type: 'notification',
source: 'agent-task-complete',
title: 'm7',
body: 'b',
notificationId: 'a:7',
notificationSeq: 7
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
return {
client,
ready: () => onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
}
}
async function reopenWithTray(): Promise<void> {
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const { client, ready } = catchUpClient()
subscribeToDesktopNotifications(client, 'host-1')
ready()
await flushAsync()
}
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadHostCatalog).mockResolvedValue([
{ id: 'host-1', publicKeyB64 }
] as unknown as HostCatalogEntry[])
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([])
})
describe('reopen after a push the OS showed while Orca was closed', () => {
it('recovers a dismissed push without a stored watermark or replaying historical alerts', async () => {
const identity = { notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' }
presentTray([{ hostFingerprint, ...identity }])
const { client, ready } = catchUpClient()
vi.mocked(client.sendRequest).mockResolvedValue({
ok: true,
result: {
dismissedPushes: [identity],
notifications: [{ type: 'notification', title: 'Historical alert', notificationSeq: 5 }]
}
} as never)
const dispose = subscribeToDesktopNotifications(client, 'host-1')
ready()
await flushAsync()
expect(client.sendRequest).toHaveBeenCalledWith('notifications.getMissedSince', {
lastSeenSeq: Number.MAX_SAFE_INTEGER,
deliveredPushes: [identity]
})
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('tray-0')
expect(shownTitles()).toEqual([])
expect(persistedSeq()).toBe(0)
dispose()
})
it('does not request history on a first pairing with an empty tray', async () => {
const { client, ready } = catchUpClient()
const dispose = subscribeToDesktopNotifications(client, 'host-1')
ready()
await flushAsync()
expect(client.sendRequest).not.toHaveBeenCalled()
dispose()
})
it('replays only the events still missing from the tray', async () => {
presentTray([
{ hostFingerprint, notificationId: 'a:6', notificationSeq: 6, notificationEpoch: 'epoch-1' }
])
await reopenWithTray()
expect(shownTitles()).toEqual(['m7'])
})
it('leaves the watermark to the replay rather than jumping it to the push seq', async () => {
presentTray([
{ hostFingerprint, notificationId: 'a:9', notificationSeq: 9, notificationEpoch: 'epoch-1' }
])
await reopenWithTray()
// Seq 9 in the tray says one event was shown, not that 6..8 were; advancing past
// them would make the desktop cut them out of every later catch-up.
expect(shownTitles()).toEqual(['m6', 'm7'])
expect(persistedSeq()).toBe(7)
})
it('still replays an event a coalesced summary only counted', async () => {
presentTray([
{
hostFingerprint,
notificationId: 'a:6',
notificationSeq: 6,
notificationEpoch: 'epoch-1',
coalescedCount: 3
}
])
await reopenWithTray()
expect(shownTitles()).toEqual(['m6', 'm7'])
})
it('ignores a tray entry pushed for a different paired host', async () => {
presentTray([
{
hostFingerprint: '0123456789abcdef',
notificationId: 'a:6',
notificationSeq: 6,
notificationEpoch: 'epoch-1'
}
])
await reopenWithTray()
expect(shownTitles()).toEqual(['m6', 'm7'])
})
})
@@ -1,27 +1,17 @@
import { AppState } from 'react-native'
import {
allowsMobileNotification,
type MobileNotificationPolicyEvent
} from '../../../src/shared/mobile-notification-policy'
import {
loadNotificationDeliveryPreferences,
notificationPreferencesFilter
} from './notification-delivery-preferences'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
let viewing: { hostId: string; worktreeId: string } | null = null
export function setNotificationViewingWorkspace(value: typeof viewing): void {
viewing = value
}
export async function allowsLocalNotification(
event: MobileNotificationPolicyEvent & { worktreeId?: string },
export async function shouldSuppressNotificationWhileViewing(
event: { worktreeId?: string },
hostId: string
): Promise<boolean> {
const preferences = await loadNotificationDeliveryPreferences()
if (!allowsMobileNotification(notificationPreferencesFilter(preferences), event)) {
return false
}
return !(
return (
preferences.suppressWhileViewing &&
AppState.currentState === 'active' &&
viewing?.hostId === hostId &&
@@ -1,222 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import {
adoptNotificationEpoch,
clearWatermark,
getHostNotificationSession,
resetHostNotificationSessionsForTests,
seedWatermarkFromStorage
} from './notification-reconnect-catchup'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'ios', Version: 18 }
}))
// The reconnect catch-up reads the tray to learn which pushes the OS already showed,
// and mapping those to this host needs the catalog, whose real module pulls the
// native keychain. No push is presented in these tests, so an empty catalog is enough.
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => []) }))
// A storage whose reads can be held open, so a live event can be injected into the
// exact window a real cold open has: subscription up, persisted watermark not yet read.
const storage = new Map<string, string>()
let heldReads: (() => void)[] = []
let holdReads = false
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => {
const read = (): string | null => storage.get(key) ?? null
if (!holdReads) {
return Promise.resolve(read())
}
return new Promise<string | null>((resolve) => {
heldReads.push(() => resolve(read()))
})
}),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => false),
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function releaseReads(): void {
const pending = heldReads
heldReads = []
for (const resolve of pending) {
resolve()
}
}
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const getMissedCalls: { includeDesktopSuppressed: true; lastSeenSeq: number; epoch?: string }[] =
[]
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method === 'notifications.getMissedSince') {
getMissedCalls.push(
params as { includeDesktopSuppressed: true; lastSeenSeq: number; epoch?: string }
)
return { ok: true, result: { notifications: [] } } as never
}
return { ok: true, result: undefined } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
getMissedCalls
}
}
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1'
describe('#8591 watermark seeding races a cold open', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
heldReads = []
holdReads = false
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('asks for catch-up from the persisted seq even if a live event lands first', async () => {
// The window is real: app/index.tsx subscribes immediately, and the desktop's
// 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the
// live seq is allowed to advance the watermark first, getMissedSince is asked to
// start from it and the desktop cuts everything the device actually missed.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' }))
holdReads = true
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
host.onData?.({
type: 'notification',
source: 'agent-task-complete',
title: 'live-12',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 12,
notificationEpoch: 'epoch-a'
})
await flushAsync()
// Nothing may be decided while the read is outstanding.
expect(host.getMissedCalls).toHaveLength(0)
releaseReads()
await flushAsync()
expect(host.getMissedCalls).toEqual([
{ includeDesktopSuppressed: true, lastSeenSeq: 5, epoch: 'epoch-a' }
])
})
it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => {
// adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a
// dead counter. That record still proves this device has been subscribed here, so a
// cold open after it must catch up — reading it as "never paired" drops the window.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' }))
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
expect(host.getMissedCalls).toEqual([
{ includeDesktopSuppressed: true, lastSeenSeq: 0, epoch: 'epoch-a' }
])
})
it('does not catch up on a first-ever pairing', async () => {
const host = makeHostClient()
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
await flushAsync()
expect(host.getMissedCalls).toEqual([])
})
it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => {
// Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications
// can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed
// always resolves first today. Pinned anyway because the guard is load-bearing the
// moment any caller adopts an epoch before seeding: applying a seq 40 from a counter
// that no longer exists would let getMissedSince cut the new counter's 1..40, which
// is the original #8591 loss re-entered through the seeding path.
const session = getHostNotificationSession('host-1')
adoptNotificationEpoch(session, 'host-1', 'epoch-new')
await flushAsync()
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' }))
seedWatermarkFromStorage(session, 'host-1')
await session.watermarkSeeded
await flushAsync()
expect(session.lastDeliveredEpoch).toBe('epoch-new')
expect(session.lastDeliveredSeq).toBe(0)
})
it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => {
// loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired
// host read a pre-#8591 seq belonging to a counter lifetime that no longer exists.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' }))
storage.set(LEGACY_KEY, '57')
await clearWatermark('host-1')
expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual(
expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY])
)
expect(storage.has(WATERMARK_KEY)).toBe(false)
expect(storage.has(LEGACY_KEY)).toBe(false)
})
})
@@ -4,7 +4,6 @@ import { nativePushDismissal } from './native-push-dismissal'
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
import { foregroundNotificationBehavior, shouldSuppressForegroundPush } from './push-receive'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
const memory = vi.hoisted(() => new Map<string, string>())
const nativeLedger = vi.hoisted(() => new Map<string, number>())
@@ -45,7 +44,9 @@ vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: async () => true,
loadRemotePushEnabled: async () => true
}))
vi.mock('./notification-viewing-policy', () => ({ allowsLocalNotification: async () => true }))
vi.mock('./notification-viewing-policy', () => ({
shouldSuppressNotificationWhileViewing: async () => false
}))
vi.mock('./notification-delivery-preferences', () => ({
loadNotificationDeliveryPreferences: vi.fn(async () => ({ sound: true }))
}))
@@ -63,14 +64,13 @@ beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => memory.get(key) ?? null)
memory.clear()
nativeLedger.clear()
resetHostNotificationSessionsForTests()
})
it('suppresses a foreground push dismissed natively during its queued fallback read', async () => {
let finish!: () => void
let reads = 0
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => {
if (key === 'orca:pushDismissalWatermarks:v1' && ++reads === 2) {
if (key === 'orca:pushDismissalWatermarks:v1' && ++reads === 1) {
await new Promise<void>((resolve) => {
finish = resolve
})
@@ -37,7 +37,7 @@ async function readDelivered(hostId: string): Promise<Map<string, OrcaPushPayloa
}
}
} catch {
// Legacy shells can still use ordinary event replay without tray inspection.
// Tray inspection is best-effort; failure leaves OS banners for later reconciliation.
}
return selected
}
@@ -54,7 +54,7 @@ export async function requestNotificationCatchup(
}
const entries = [...delivered.entries()]
const response = await client.sendRequest('notifications.getMissedSince', {
// First pairing reconciles the tray without requesting historical alerts.
// First pairing reconciles tray identities without requesting historical events.
...(params ?? { lastSeenSeq: Number.MAX_SAFE_INTEGER }),
...(delivered.size
? { deliveredPushes: entries.slice(0, 256).map(([, payload]) => identity(payload)!) }
@@ -89,7 +89,7 @@ export async function requestNotificationCatchup(
}
}
await applyDismissals(response, new Map(entries.slice(0, 256)))
// Summaries may represent more identities than one RPC permits; replay only once.
// Page remaining tray identities without requesting historical events again.
for (let offset = 256; offset < entries.length && !isDisposed(); offset += 256) {
const requested = new Map(entries.slice(offset, offset + 256))
try {
@@ -9,7 +9,11 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}
}))
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
import {
areLegacySummaryPushesDismissed,
rememberPushDismissal,
wasPushDismissed
} from './push-dismissal-watermarks'
const payload = {
hostFingerprint: 'host-a',
notificationEpoch: 'epoch-a',
@@ -55,7 +59,7 @@ it('expires retained metadata and ignores unversioned dismissals', async () => {
it('does not discard a summary representing other undismissed alerts', async () => {
await rememberPushDismissal(payload)
expect(await wasPushDismissed({ ...payload, coalescedCount: 3 })).toBe(false)
expect(await areLegacySummaryPushesDismissed({ ...payload, coalescedCount: 3 })).toBe(false)
})
it('joins an overtaking fallback write before retrying a delayed negative snapshot', async () => {
@@ -1,7 +1,7 @@
import { representedPushes } from './push-summary-members'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { OrcaPushPayload } from './push-payload'
import { nativePushDismissal } from './native-push-dismissal'
import { representedPushes } from './push-summary-members'
const STORAGE_KEY = 'orca:pushDismissalWatermarks:v1'
const RETENTION_MS = 24 * 60 * 60 * 1000
@@ -97,10 +97,6 @@ async function readDismissal(payload: OrcaPushPayload, key: string): Promise<boo
}
export async function wasPushDismissed(payload: OrcaPushPayload): Promise<boolean> {
if ((payload.coalescedCount ?? 0) > 1) {
const members = representedPushes(payload)
return members.length > 0 && (await Promise.all(members.map(wasPushDismissed))).every(Boolean)
}
const key = eventKey(payload)
if (!key) {
return false
@@ -114,3 +110,12 @@ export async function wasPushDismissed(payload: OrcaPushPayload): Promise<boolea
// An overtaking write invalidates a negative snapshot; one queued read cannot be overtaken again.
return queueDismissalOperation(() => readDismissal(payload, key))
}
/** Legacy summaries are expanded only while deciding whether to show or remove old deliveries. */
export async function areLegacySummaryPushesDismissed(payload: OrcaPushPayload): Promise<boolean> {
if ((payload.coalescedCount ?? 0) <= 1) {
return wasPushDismissed(payload)
}
const members = representedPushes(payload)
return members.length > 0 && (await Promise.all(members.map(wasPushDismissed))).every(Boolean)
}
+1 -2
View File
@@ -11,8 +11,7 @@ export type OrcaPushPayload = {
readonly worktreeId?: string
readonly source?: string
readonly agentState?: string
// Present only on a gateway summary standing in for N events; see the coalescing
// window in docs/reference/mobile-push-contract.md.
// Legacy-only fields retained during rolling overlap to decode summaries already in OS trays.
readonly summaryMembers?: readonly Identity[]
readonly coalescedCount?: number
}
@@ -40,7 +40,7 @@ beforeEach(() => {
storage.set('orca:remotePushEnabled', 'true')
})
it('replaces an in-flight old registration with the latest event and sound preferences', async () => {
it('replaces an in-flight registration with the latest away and sound preferences', async () => {
const calls: { method: string; params: unknown }[] = []
let finishFirst: ((value: unknown) => void) | undefined
const client = {
@@ -64,8 +64,7 @@ it('replaces an in-flight old registration with the latest event and sound prefe
await vi.waitFor(() => expect(finishFirst).toBeDefined())
const update = setNotificationDeliveryPreferences({
...DEFAULT_NOTIFICATION_DELIVERY,
followDesktop: false,
terminalBell: false,
onlyWhenDesktopAway: false,
sound: false
})
finishFirst!({ ok: true, result: { registered: true, registrationId: 'old' } })
@@ -77,7 +76,14 @@ it('replaces an in-flight old registration with the latest event and sound prefe
)
const latest = calls.findLast((call) => call.method === 'notifications.registerPush')
expect(latest?.params).toMatchObject({
filter: { followDesktop: false, sound: false, sources: ['agent-task-complete', 'plugin'] }
filter: {
onlyWhenDesktopAway: false,
expireAfterInactivity: true,
followDesktop: true,
sound: false,
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
agentStates: ['needs-input', 'finished']
}
})
expect(calls.some((call) => call.method === 'notifications.unregisterPush')).toBe(true)
detach()
+179 -180
View File
@@ -1,47 +1,40 @@
vi.mock('./push-tray-dismissal', () => ({ dismissPresentedPushNotification: vi.fn() }))
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { sha256 } from '@noble/hashes/sha256'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry } from '../transport/types'
import { getNotificationNavigationTarget } from './notification-routing'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
} from './notification-reconnect-catchup'
import {
foregroundNotificationBehavior,
isRemotePushTrigger,
pushNotificationRouteData,
resetForegroundPushClaimsForTests,
shouldSuppressForegroundPush
} from './push-receive'
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
const storage = new Map<string, string>()
const storage = vi.hoisted(() => new Map<string, string>())
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
}),
removeItem: vi.fn(async () => undefined)
setItem: vi.fn(async (key: string, value: string) => storage.set(key, value))
}
}))
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
const publicKeyB64 = Buffer.alloc(32, 1).toString('base64')
const hostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 1)))
.toString('base64url')
.slice(0, 16)
const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
const otherPublicKeyB64 = Buffer.alloc(32, 2).toString('base64')
const otherHostFingerprint = Buffer.from(sha256(Buffer.alloc(32, 2)))
.toString('base64url')
.slice(0, 16)
// APNs nests Orca's fields beside `aps`; FCM sends them flat and stringified.
function apnsData(orca: Record<string, unknown>): unknown {
return { aps: { alert: { title: 'Orca', body: 'Agent needs input' } }, orca }
}
function fcmData(orca: Record<string, unknown>): unknown {
return Object.fromEntries(Object.entries(orca).map(([key, value]) => [key, String(value)]))
}
@@ -51,232 +44,238 @@ beforeEach(() => {
storage.clear()
storage.set('orca:pushNotificationsEnabled', 'true')
storage.set('orca:remotePushEnabled', 'true')
resetHostNotificationSessionsForTests()
vi.mocked(loadHostCatalog).mockResolvedValue(hosts)
resetForegroundPushClaimsForTests()
vi.mocked(loadHostCatalog).mockResolvedValue([
...hosts,
{ id: 'host-2', publicKeyB64: otherPublicKeyB64 }
] as unknown as HostCatalogEntry[])
})
describe('shouldSuppressForegroundPush', () => {
it('suppresses a push whose id and seq the socket already delivered', async () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-1'
session.seen.add('id:agent:one#7')
await expect(
shouldSuppressForegroundPush(
apnsData({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 7,
notificationEpoch: 'epoch-1'
})
)
).resolves.toBe(true)
})
it('shows an unseen push and marks it so the socket replay is dropped', async () => {
const data = apnsData({
const push = () =>
apnsData({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 7,
notificationEpoch: 'epoch-1'
})
await expect(shouldSuppressForegroundPush(data)).resolves.toBe(false)
expect(getHostNotificationSession('host-1').seen.has('id:agent:one#7')).toBe(true)
await expect(shouldSuppressForegroundPush(data)).resolves.toBe(true)
it('allows one eligible native push and suppresses an in-process duplicate', async () => {
await expect(shouldSuppressForegroundPush(push())).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(push())).resolves.toBe(true)
})
it('reads the flat stringified fields an FCM data message carries', async () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-1'
session.seen.add('id:agent:one#7')
it('reads flat FCM fields and allows the first native push', async () => {
await expect(
shouldSuppressForegroundPush(
fcmData({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 7,
notificationSeq: 8,
notificationEpoch: 'epoch-1'
})
)
).resolves.toBe(true)
})
it('keys a terminal bell on its seq alone, since it carries no notification id', async () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-1'
session.seen.add('seq:4')
await expect(
shouldSuppressForegroundPush(
apnsData({
hostFingerprint,
source: 'terminal-bell',
notificationSeq: 4,
notificationEpoch: 'epoch-1'
})
)
).resolves.toBe(true)
})
it('shows a push that names no counter lifetime without letting it claim a key', async () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-1'
session.seen.add('seq:4')
// Without an epoch the seq cannot be tied to this counter, so a forged seq:4
// must neither be swallowed against it nor stop the real bell at seq 4.
await expect(
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 4 }))
).resolves.toBe(false)
await expect(
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 5 }))
).resolves.toBe(false)
expect(session.seen.has('seq:5')).toBe(false)
})
it('voids seen keys from a previous desktop lifetime before testing its own', async () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-old'
session.seen.add('seq:4')
await expect(
shouldSuppressForegroundPush(
apnsData({ hostFingerprint, notificationSeq: 4, notificationEpoch: 'epoch-new' })
)
).resolves.toBe(false)
})
it('leaves a locally scheduled notification to the existing path', async () => {
it('deduplicates ID-less bells by host, epoch, and valid sequence', async () => {
const bell = (overrides: Record<string, unknown> = {}) =>
apnsData({
hostFingerprint,
source: 'terminal-bell',
notificationSeq: 4,
notificationEpoch: 'epoch-1',
...overrides
})
await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(bell())).resolves.toBe(true)
await expect(shouldSuppressForegroundPush(bell({ notificationSeq: 5 }))).resolves.toBe(false)
await expect(
shouldSuppressForegroundPush({ hostId: 'host-1', source: 'agent-task-complete' })
shouldSuppressForegroundPush(bell({ notificationEpoch: 'epoch-2' }))
).resolves.toBe(false)
await expect(
shouldSuppressForegroundPush(bell({ hostFingerprint: otherHostFingerprint }))
).resolves.toBe(false)
expect(loadHostCatalog).not.toHaveBeenCalled()
})
it('suppresses a push for a host this phone no longer has, since its tap routes nowhere', async () => {
it('does not claim invalid sequence values as duplicate identities', async () => {
const invalid = apnsData({
hostFingerprint,
source: 'plugin',
notificationSeq: 1.5,
notificationEpoch: 'epoch-1'
})
await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(invalid)).resolves.toBe(false)
})
it('suppresses pushes for an unpaired host', async () => {
vi.mocked(loadHostCatalog).mockResolvedValue([])
await expect(
shouldSuppressForegroundPush(apnsData({ hostFingerprint, notificationSeq: 1 }))
).resolves.toBe(true)
})
it('seeds the persisted watermark before adopting, so a push cannot void it', async () => {
storage.set(
'orca:mobileNotificationsWatermark:host-1',
JSON.stringify({ seq: 42, epoch: 'epoch-1' })
)
await shouldSuppressForegroundPush(
apnsData({ hostFingerprint, notificationSeq: 43, notificationEpoch: 'epoch-1' })
)
// Unseeded, the null epoch reads as a new counter lifetime: the seq resets to 0
// and {seq: 0} is persisted over a watermark the next reconnect still needs.
expect(getHostNotificationSession('host-1').lastDeliveredSeq).toBe(42)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
it('suppresses a push after a matching persisted dismissal', async () => {
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
const payload = {
hostFingerprint,
notificationId: 'dismissed',
notificationSeq: 2,
notificationEpoch: 'epoch-1'
}
await rememberPushDismissal(payload)
await expect(shouldSuppressForegroundPush(apnsData(payload))).resolves.toBe(true)
})
it('shows a coalesced summary without claiming the key of the one event it names', async () => {
it('keeps a legacy summary visible while an earlier member remains unread', async () => {
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
const members = [
{ notificationId: 'agent:earlier', notificationSeq: 6, notificationEpoch: 'epoch-1' },
{ notificationId: 'agent:latest', notificationSeq: 7, notificationEpoch: 'epoch-1' }
]
await rememberPushDismissal({ hostFingerprint, ...members[1]! })
await expect(
shouldSuppressForegroundPush(
apnsData({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 7,
coalescedCount: 3
...members[1],
coalescedCount: members.length,
summaryMembers: members
})
)
).resolves.toBe(false)
})
// Claiming it would make the socket swallow the banner for agent:one itself,
// which the summary only ever counted.
expect(getHostNotificationSession('host-1').seen.has('id:agent:one#7')).toBe(false)
it('suppresses a legacy summary only after every member is dismissed', async () => {
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
const members = [
{ notificationId: 'agent:earlier', notificationSeq: 6, notificationEpoch: 'epoch-1' },
{ notificationId: 'agent:latest', notificationSeq: 7, notificationEpoch: 'epoch-1' }
]
await Promise.all(
members.map((member) => rememberPushDismissal({ hostFingerprint, ...member }))
)
await expect(
shouldSuppressForegroundPush(
apnsData({
hostFingerprint,
...members[1],
coalescedCount: members.length,
summaryMembers: members
})
)
).resolves.toBe(true)
})
it('keeps malformed legacy summary membership visible', async () => {
const { rememberPushDismissal } = await import('./push-dismissal-watermarks')
const latest = {
notificationId: 'agent:latest',
notificationSeq: 7,
notificationEpoch: 'epoch-1'
}
await rememberPushDismissal({ hostFingerprint, ...latest })
await expect(
shouldSuppressForegroundPush(
apnsData({
hostFingerprint,
...latest,
coalescedCount: 2,
summaryMembers: [latest]
})
)
).resolves.toBe(false)
})
it('keeps legacy summary and individual duplicate claims independent', async () => {
const members = [
{ notificationId: 'agent:earlier', notificationSeq: 6, notificationEpoch: 'epoch-1' },
{ notificationId: 'agent:latest', notificationSeq: 7, notificationEpoch: 'epoch-1' }
]
const summaryPush = apnsData({
hostFingerprint,
...members[1],
coalescedCount: members.length,
summaryMembers: members
})
const individualPush = apnsData({ hostFingerprint, ...members[1] })
await expect(shouldSuppressForegroundPush(summaryPush)).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(individualPush)).resolves.toBe(false)
await expect(shouldSuppressForegroundPush(individualPush)).resolves.toBe(true)
await expect(shouldSuppressForegroundPush(summaryPush)).resolves.toBe(false)
})
it('fails closed for recognized pushes when suppression checks throw', async () => {
const preferences = await import('./notification-delivery-preferences')
const dismissals = await import('./push-dismissal-watermarks')
const preferenceSpy = vi
.spyOn(preferences, 'loadNotificationDeliveryPreferences')
.mockResolvedValueOnce({ sound: true } as never)
.mockRejectedValueOnce(new Error('preference read failed'))
const dismissalSpy = vi.spyOn(dismissals, 'wasPushDismissed')
await expect(
foregroundNotificationBehavior({ request: { content: { data: push() } } })
).resolves.toMatchObject({ shouldShowBanner: false, shouldShowList: false })
expect(dismissalSpy).not.toHaveBeenCalled()
preferenceSpy.mockRestore()
})
it('keeps unrelated notifications visible when suppression checks throw', async () => {
const dismissals = await import('./push-dismissal-watermarks')
const dismissalSpy = vi
.spyOn(dismissals, 'wasPushDismissed')
.mockRejectedValue(new Error('dismissal read failed'))
await expect(
foregroundNotificationBehavior({
request: { content: { data: { title: 'Other app notification' } } }
})
).resolves.toMatchObject({ shouldShowBanner: true, shouldShowList: true })
dismissalSpy.mockRestore()
})
})
describe('pushNotificationRouteData', () => {
it('routes a tap by mapping the fingerprint to the paired host id', () => {
const data = pushNotificationRouteData(
apnsData({
hostFingerprint,
notificationId: 'agent:one',
worktreeId: 'repo::/Users/me/orca/workspaces/feature',
source: 'agent-task-complete'
}),
apnsData({ hostFingerprint, worktreeId: 'repo::/feature', source: 'agent-task-complete' }),
hosts
)
expect(getNotificationNavigationTarget(data, { knownHostIds: new Set(['host-1']) })).toEqual({
hostId: 'host-1',
sessionTarget: {
name: '[hostId]/session/[worktreeId]',
params: { hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' }
params: { hostId: 'host-1', worktreeId: 'repo::/feature' }
}
})
})
it('falls back to the host screen for a push with no worktree', () => {
it('maps a push without a worktree to the host screen', () => {
const data = pushNotificationRouteData(
fcmData({ hostFingerprint, source: 'terminal-bell' }),
hosts
)
expect(getNotificationNavigationTarget(data)).toEqual({
hostId: 'host-1',
sessionTarget: null
})
expect(getNotificationNavigationTarget(data)).toEqual({ hostId: 'host-1', sessionTarget: null })
})
it('passes locally scheduled data through untouched', () => {
const data = { hostId: 'host-9', source: 'agent-task-complete' }
expect(pushNotificationRouteData(data, hosts)).toBe(data)
})
it('leaves an unresolvable fingerprint unrouted rather than guessing a host', () => {
const data = pushNotificationRouteData(apnsData({ hostFingerprint: '0123456789abcdef' }), hosts)
expect(getNotificationNavigationTarget(data)).toBeNull()
})
it('leaves a remote push unrouted when no host catalog could be read', () => {
const data = { hostId: 'host-1', orca: { hostFingerprint, notificationId: 'agent:one' } }
expect(pushNotificationRouteData(data, [], true)).toBeNull()
})
it('leaves a remote push with no fingerprint unrouted instead of treating it as local', () => {
const data = { hostId: 'host-1', worktreeId: 'wt-1', source: 'agent-task-complete' }
expect(pushNotificationRouteData(data, hosts, true)).toBeNull()
// The same shape from this app's own scheduler still routes.
expect(pushNotificationRouteData(data, hosts, false)).toBe(data)
})
it('recognises only a provider-delivered trigger as remote', () => {
expect(isRemotePushTrigger({ type: 'push' })).toBe(true)
expect(isRemotePushTrigger({ type: 'timeInterval', seconds: 1 })).toBe(false)
expect(isRemotePushTrigger({ channelId: 'orca-desktop' })).toBe(false)
expect(isRemotePushTrigger(null)).toBe(false)
expect(isRemotePushTrigger(undefined)).toBe(false)
})
it('drops a gateway payload that pairs an unresolvable fingerprint with a stray hostId', () => {
const data = {
hostId: 'host-1',
orca: { hostFingerprint: '0123456789abcdef', notificationId: 'agent:one' }
}
// Returning the raw data would let the stray hostId route a tap the push never named.
expect(pushNotificationRouteData(data, hosts)).toBeNull()
it('keeps local data untouched and rejects an unresolvable remote fingerprint', () => {
const local = { hostId: 'host-9', source: 'agent-task-complete' }
expect(pushNotificationRouteData(local, hosts)).toBe(local)
expect(
getNotificationNavigationTarget(pushNotificationRouteData(data, hosts), {
knownHostIds: new Set(['host-1'])
})
pushNotificationRouteData(
{ hostId: 'host-1', orca: { hostFingerprint: 'unknown' } },
hosts,
true
)
).toBeNull()
})
it('recognises only provider-delivered triggers', () => {
expect(isRemotePushTrigger({ type: 'push' })).toBe(true)
expect(isRemotePushTrigger({ type: 'timeInterval' })).toBe(false)
})
})
+49 -66
View File
@@ -1,29 +1,59 @@
import { wasPushDismissed } from './push-dismissal-watermarks'
import { areLegacySummaryPushesDismissed, wasPushDismissed } from './push-dismissal-watermarks'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
import { allowsLocalNotification } from './notification-viewing-policy'
import { shouldSuppressNotificationWhileViewing } from './notification-viewing-policy'
import { loadPushNotificationsEnabled, loadRemotePushEnabled } from '../storage/preferences'
import { loadHostCatalog } from '../transport/host-store'
import {
adoptNotificationEpoch,
enqueueHostDelivery,
getHostNotificationSession,
seedWatermarkFromStorage,
seenKeyForEvent
} from './notification-reconnect-catchup'
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
import type { Notification, NotificationBehavior } from 'expo-notifications'
import { readNativeNotificationData } from './native-notification-data'
import { loadNotificationDeliveryPreferences } from './notification-delivery-preferences'
const RECENT_FOREGROUND_PUSH_CAP = 512
const recentForegroundPushes = new Set<string>()
function claimForegroundPush(payload: OrcaPushPayload): boolean {
const seq = payload.notificationSeq
if (
!payload.notificationEpoch ||
typeof seq !== 'number' ||
!Number.isSafeInteger(seq) ||
seq < 0
) {
return true
}
const key = JSON.stringify([
payload.hostFingerprint,
payload.notificationEpoch,
payload.notificationId ?? null,
seq
])
if (recentForegroundPushes.has(key)) {
return false
}
recentForegroundPushes.add(key)
if (recentForegroundPushes.size > RECENT_FOREGROUND_PUSH_CAP) {
const oldest = recentForegroundPushes.values().next().value
if (oldest !== undefined) {
recentForegroundPushes.delete(oldest)
}
}
return true
}
export function resetForegroundPushClaimsForTests(): void {
recentForegroundPushes.clear()
}
export async function foregroundNotificationBehavior(
notification: Pick<Notification, 'request'>
): Promise<NotificationBehavior> {
const preferences = await loadNotificationDeliveryPreferences()
// No storage awaits after suppression: a dismissal may arrive during any read.
const suppressed = await shouldSuppressForegroundPush(
readNativeNotificationData(notification.request)
).catch(() => false)
const data = readNativeNotificationData(notification.request)
const recognizedPush = readOrcaPushPayload(data) !== null
const preferences = await loadNotificationDeliveryPreferences().catch(() => ({ sound: true }))
// Unrecognized notifications retain normal behavior; recognized pushes fail closed
// when consent, host, viewing, or dismissal checks cannot complete.
const suppressed = await shouldSuppressForegroundPush(data).catch(() => recognizedPush)
return {
shouldShowBanner: !suppressed,
shouldShowList: !suppressed,
@@ -37,14 +67,6 @@ async function resolvePushHostId(payload: OrcaPushPayload): Promise<string | nul
return resolveHostIdForFingerprint(payload.hostFingerprint, hosts)
}
/**
* Whether a foreground notification is a push for an event the socket already
* delivered, and must therefore be swallowed instead of banner'd a second time.
*
* Marking happens here rather than in a received listener because the handler is
* the only hook that can actually suppress, and the key must be claimed exactly
* once — a listener running afterwards would mark an event the handler dropped.
*/
export async function shouldSuppressForegroundPush(data: unknown): Promise<boolean> {
const payload = readOrcaPushPayload(data)
if (!payload) {
@@ -60,9 +82,6 @@ export async function shouldSuppressForegroundPush(data: unknown): Promise<boole
}
return true
}
if (await wasPushDismissed(payload)) {
return true
}
const hostId = await resolvePushHostId(payload)
// Why suppressed rather than shown: the only pushes that outlive their host are
// ones a gateway registration still holds after a removal whose unregister never
@@ -74,50 +93,14 @@ export async function shouldSuppressForegroundPush(data: unknown): Promise<boole
if (!(await loadPushNotificationsEnabled()) || !(await loadRemotePushEnabled())) {
return true
}
if (
!(await allowsLocalNotification(
{ ...payload, source: payload.source ?? 'agent-task-complete' },
hostId
))
) {
if (await shouldSuppressNotificationWhileViewing(payload, hostId)) {
return true
}
const session = getHostNotificationSession(hostId)
// Why seeded first: the socket may never have connected this launch (phone on
// cellular), leaving lastDeliveredEpoch null. Adopting against an unseeded session
// resets the seq to 0 and persists that over a valid watermark, so the next
// reconnect replays the desktop's whole retained buffer.
seedWatermarkFromStorage(session, hostId)
await session.watermarkSeeded
// A push that names no counter lifetime cannot claim a seq-derived key: the
// desktop always sends the epoch, so this is shown as-is and never marked.
if (payload.notificationEpoch == null) {
return false
// Keep this last: a socket/native dismissal may land during any preference or host read.
if ((payload.coalescedCount ?? 0) > 1) {
return areLegacySummaryPushesDismissed(payload)
}
// Push and socket delivery share one claim, including an in-flight native schedule.
return enqueueHostDelivery(session, async () => {
if (await wasPushDismissed(payload)) {
return true
}
// The seen keys are seq-derived, so a push from a new desktop lifetime must void
// them before its own key is tested against a counter that no longer exists.
adoptNotificationEpoch(session, hostId, payload.notificationEpoch)
// Why a coalesced summary is neither suppressed nor marked: it carries only the
// latest event's fields, so claiming that key would make the socket swallow the
// specific banner for an event the summary only ever counted.
if ((payload.coalescedCount ?? 0) > 1) {
return false
}
const key = seenKeyForEvent(payload)
if (!key) {
return false
}
if (session.seen.has(key)) {
return true
}
session.seen.add(key)
return false
})
return (await wasPushDismissed(payload)) || !claimForegroundPush(payload)
}
/** Whether the OS says a notification came from a provider rather than this app. */
@@ -6,14 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient, SendRequestOptions } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import {
loadRemotePushAgentStates,
loadRemotePushEnabled,
loadRemotePushFilter,
loadRemotePushHostRegistrations,
saveRemotePushAgentStates,
saveRemotePushEnabled,
saveRemotePushHostRegistrations,
type RemotePushAgentState,
type RemotePushHostRegistrations
} from '../storage/preferences'
import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
@@ -21,7 +18,6 @@ import {
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY,
attachPushRegistration,
resetPushRegistrationForTests,
setRemotePushAgentStates,
setRemotePushEnabled,
startPushTokenSync,
unregisterPushForRemovedHost
@@ -30,8 +26,6 @@ import {
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(),
saveRemotePushEnabled: vi.fn(),
loadRemotePushAgentStates: vi.fn(),
saveRemotePushAgentStates: vi.fn(),
loadRemotePushFilter: vi.fn(),
loadRemotePushHostRegistrations: vi.fn(),
saveRemotePushHostRegistrations: vi.fn()
@@ -92,7 +86,6 @@ function methodsIn(sent: SentRequest[]): string[] {
}
let enabled = false
let agentStates: readonly RemotePushAgentState[] = ['needs-input', 'finished']
let stored: RemotePushHostRegistrations
beforeEach(() => {
@@ -100,20 +93,15 @@ beforeEach(() => {
AppState.currentState = 'active'
resetPushRegistrationForTests()
enabled = false
agentStates = ['needs-input', 'finished']
stored = { registeredHostIds: [], pendingUnregisterHostIds: [] }
vi.mocked(loadRemotePushEnabled).mockImplementation(async () => enabled)
vi.mocked(saveRemotePushEnabled).mockImplementation(async (value) => {
enabled = value
})
vi.mocked(loadRemotePushAgentStates).mockImplementation(async () => agentStates)
vi.mocked(saveRemotePushAgentStates).mockImplementation(async (value) => {
agentStates = value
})
vi.mocked(loadRemotePushFilter).mockImplementation(async () => ({
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
agentStates
agentStates: ['needs-input', 'finished']
}))
vi.mocked(loadRemotePushHostRegistrations).mockImplementation(async () => stored)
vi.mocked(saveRemotePushHostRegistrations).mockImplementation(async (value) => {
@@ -193,7 +181,7 @@ describe('push registration capability gating', () => {
attachPushRegistration('host-legacy', client)
await flush()
await setRemotePushAgentStates(['needs-input'])
await setRemotePushEnabled(true)
await flush()
expect(methodsIn(sent)).toEqual(['status.get'])
@@ -221,7 +209,7 @@ describe('push registration capability gating', () => {
// A latched `false` would keep this host unregistered for the connection's life.
probeFails = false
await setRemotePushAgentStates(['needs-input'])
await setRemotePushEnabled(true)
await flush()
expect(sent).toEqual(['status.get', 'status.get', 'notifications.registerPush'])
@@ -236,14 +224,14 @@ describe('push registration capability gating', () => {
expect(methodsIn(sent)).toEqual(['status.get'])
// A token can be missing only for now — APNs registration still in flight.
await setRemotePushAgentStates(['needs-input'])
await setRemotePushEnabled(true)
await flush()
expect(methodsIn(sent)).toContain('notifications.registerPush')
})
})
describe('push registration token and filter changes', () => {
describe('push registration token changes', () => {
it('re-registers every connected host when the provider rolls the token', async () => {
let onTokenChange: ((token: MobilePushToken) => void) | null = null
vi.mocked(addPushTokenListener).mockImplementation((listener) => {
@@ -267,25 +255,6 @@ describe('push registration token and filter changes', () => {
})
stop()
})
it('re-registers with the narrowed filter when a sub-switch is turned off', async () => {
const { client, sent } = makeClient([NOTIFICATIONS_REMOTE_PUSH_CAPABILITY])
await setRemotePushEnabled(true)
attachPushRegistration('host-1', client)
await flush()
await setRemotePushAgentStates(['needs-input'])
await flush()
const registers = sent.filter((request) => request.method === 'notifications.registerPush')
expect(registers).toHaveLength(2)
expect(registers[1]?.params).toMatchObject({
filter: {
sources: ['agent-task-complete', 'terminal-bell', 'plugin'],
agentStates: ['needs-input']
}
})
})
})
describe('push unregistration', () => {
@@ -16,10 +16,8 @@ import {
loadRemotePushEnabled,
loadRemotePushFilter,
loadRemotePushHostRegistrations,
saveRemotePushAgentStates,
saveRemotePushEnabled,
saveRemotePushHostRegistrations,
type RemotePushAgentState,
type RemotePushFilter
} from '../storage/preferences'
import { addPushTokenListener, getDevicePushToken, type MobilePushToken } from './push-token'
@@ -258,15 +256,6 @@ export async function setNotificationDeliveryPreferences(
await reconcileAllHosts()
}
/** Re-registers every connected host so the gateway stores the narrowed filter. */
export async function setRemotePushAgentStates(
states: readonly RemotePushAgentState[]
): Promise<void> {
consentGeneration++
await saveRemotePushAgentStates(states)
await reconcileAllHosts()
}
// Offline hosts retain the registration until unpaired or its mobile-use lease expires.
export async function unregisterPushForRemovedHost(hostId: string): Promise<void> {
const state = hostsById.get(hostId)
@@ -2,7 +2,7 @@ import { wasPushDismissed } from './push-dismissal-watermarks'
import { loadHostCatalog } from '../transport/host-store'
import { deriveHostFingerprint } from './push-host-fingerprint'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
import type { DismissNotificationEvent } from './local-notification-scheduling'
import type { DismissNotificationEvent } from './desktop-notification-events'
async function hostFingerprint(hostId: string): Promise<string | null> {
const hosts = await loadHostCatalog().catch(() => [])
@@ -1,190 +0,0 @@
const memory = vi.hoisted(() => new Map<string, string>())
import { beforeEach, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { shouldSuppressForegroundPush } from './push-receive'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
} from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
vi.mock('react-native', () => ({ AppState: { currentState: 'active' }, Platform: { OS: 'ios' } }))
vi.mock('expo-notifications', () => ({
getPresentedNotificationsAsync: vi.fn(async () => []),
getPermissionsAsync: vi.fn(async () => ({ status: 'granted', canAskAgain: true })),
scheduleNotificationAsync: vi.fn(async () => 'local'),
dismissNotificationAsync: vi.fn(async () => {})
}))
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn(async () => [{ id: 'host' }]) }))
vi.mock('./push-host-fingerprint', () => ({
resolveHostIdForFingerprint: () => 'host',
deriveHostFingerprint: () => 'abcdefghijklmnop'
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: async () => true,
loadPushNotificationsEnabled: async () => true,
loadRemotePushHostRegistrations: async () => ({ registeredHostIds: ['host'] })
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => memory.get(key) ?? null,
setItem: async (key: string, value: string) => {
memory.set(key, value)
}
}
}))
const event = {
type: 'notification',
source: 'agent-task-complete',
title: 'Done',
body: '',
notificationId: 'done',
notificationSeq: 1,
notificationEpoch: 'epoch'
}
const push = { orca: { ...event, hostFingerprint: 'abcdefghijklmnop' } }
const disposals: (() => void)[] = []
beforeEach(() => {
for (const dispose of disposals.splice(0)) {
dispose()
}
memory.clear()
vi.clearAllMocks()
resetHostNotificationSessionsForTests()
})
async function socket() {
let receive!: (data: unknown) => void
const client = {
subscribe: (_method: string, _params: unknown, callback: typeof receive) => {
receive = callback
return () => {}
},
getState: () => 'connected',
sendRequest: async () => ({ ok: true, result: { notifications: [] } })
}
disposals.push(subscribeToDesktopNotifications(client as unknown as RpcClient, 'host'))
receive({ type: 'ready', subscriptionId: 'sub', epoch: 'epoch' })
await getHostNotificationSession('host').watermarkSeeded
return receive
}
it('does not show a second banner when a foreground push precedes its live socket event', async () => {
const receive = await socket()
expect(await shouldSuppressForegroundPush(push)).toBe(false)
receive(event)
await vi.waitFor(() => expect(getHostNotificationSession('host').lastDeliveredSeq).toBe(1))
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()
})
it('waits for in-flight socket scheduling before deciding whether to show its push', async () => {
let finish!: (id: string) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const receive = await socket()
receive(event)
await vi.waitFor(() => expect(finish).toBeDefined())
let decision: boolean | undefined
const pending = shouldSuppressForegroundPush(push).then((value) => {
decision = value
return value
})
await new Promise((resolve) => setTimeout(resolve, 10))
const beforeScheduleFinished = decision
finish('local')
expect(await pending).toBe(true)
expect(beforeScheduleFinished).toBeUndefined()
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
})
it('lets the push deliver if the in-flight local schedule fails', async () => {
let fail!: (error: Error) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
fail = reject
})
)
const receive = await socket()
receive(event)
await vi.waitFor(() => expect(fail).toBeDefined())
const pending = shouldSuppressForegroundPush(push)
fail(new Error('native scheduling failed'))
expect(await pending).toBe(false)
})
it('dismisses the tray while an earlier socket show is still waiting for foreground', async () => {
const { AppState } = await import('react-native')
let activate!: (state: string) => void
Object.assign(AppState, {
currentState: 'background',
addEventListener: (_name: string, callback: typeof activate) => {
activate = callback
return { remove: () => {} }
}
})
const receive = await socket()
receive(event)
await vi.waitFor(() => expect(activate).toBeDefined())
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValueOnce([
{ request: { identifier: 'remote-alert', content: { data: push } } }
] as never)
receive({ ...event, type: 'dismiss', notificationSeq: 2 })
await vi.waitFor(() =>
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('remote-alert')
)
AppState.currentState = 'active'
activate('active')
})
it('rechecks dismissal while push waits behind another socket show', async () => {
const { AppState } = await import('react-native')
AppState.currentState = 'active'
let finish!: (id: string) => void
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const receive = await socket()
receive({ ...event, notificationId: 'other', notificationSeq: 10 })
await vi.waitFor(() => expect(finish).toBeDefined())
const tail = getHostNotificationSession('host').deliveryTail
const pending = shouldSuppressForegroundPush({
orca: { ...event, notificationSeq: 11, hostFingerprint: 'abcdefghijklmnop' }
})
await vi.waitFor(() => expect(getHostNotificationSession('host').deliveryTail).not.toBe(tail))
await shouldSuppressForegroundPush({
orca: { ...event, kind: 'dismiss', notificationSeq: 12, hostFingerprint: 'abcdefghijklmnop' }
})
finish('other-local')
expect(await pending).toBe(true)
})
it('dismiss arriving during socket handoff cannot resurrect local banner', async () => {
const { AppState } = await import('react-native')
AppState.currentState = 'active'
const receive = await socket()
await getHostNotificationSession('host').deliveryTail
let finishTray!: (v: never[]) => void
vi.mocked(Notifications.getPresentedNotificationsAsync).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishTray = resolve
})
)
receive({ ...event, notificationId: 'handoff', notificationSeq: 20 })
await vi.waitFor(() => expect(finishTray).toBeDefined())
const trayReads = vi.mocked(Notifications.getPresentedNotificationsAsync).mock.calls.length
receive({ ...event, type: 'dismiss', notificationId: 'handoff', notificationSeq: 21 })
await vi.waitFor(() =>
expect(Notifications.getPresentedNotificationsAsync).toHaveBeenCalledTimes(trayReads + 1)
)
finishTray([])
await vi.waitFor(() => expect(getHostNotificationSession('host').lastDeliveredSeq).toBe(21))
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()
})
@@ -4,7 +4,7 @@ import { readOrcaPushPayload } from './push-payload'
import { deriveHostFingerprint } from './push-host-fingerprint'
import { dismissPresentedPushNotification } from './push-tray-dismissal'
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
import { wasPushDismissed } from './push-dismissal-watermarks'
import { areLegacySummaryPushesDismissed } from './push-dismissal-watermarks'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -51,10 +51,10 @@ beforeEach(() => {
it('preserves partially handled summaries and clears only fully handled membership', async () => {
await dismissPresentedPushNotification(members[0]!.notificationId, hostFingerprint, members[0])
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
expect(await wasPushDismissed(summary)).toBe(false)
expect(await areLegacySummaryPushesDismissed(summary)).toBe(false)
await dismissPresentedPushNotification(members[1]!.notificationId, hostFingerprint, members[1])
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledExactlyOnceWith('summary')
expect(await wasPushDismissed(summary)).toBe(true)
expect(await areLegacySummaryPushesDismissed(summary)).toBe(true)
})
it('reconciles every summary member, preserving a summary containing a newer unread event', async () => {
const sendRequest = vi.fn(async () => ({
@@ -87,7 +87,9 @@ it('accepts APNs arrays and FCM JSON strings but never treats incomplete or malf
]) {
expect(readOrcaPushPayload({ ...summary, summaryMembers: bad })?.summaryMembers).toBeUndefined()
}
expect(await wasPushDismissed({ ...summary, summaryMembers: members.slice(0, 1) })).toBe(false)
expect(
await areLegacySummaryPushesDismissed({ ...summary, summaryMembers: members.slice(0, 1) })
).toBe(false)
})
it('pages summary identities without replaying history twice or trusting identities from another page', async () => {
@@ -49,6 +49,8 @@ export function readSummaryMembers(
}
export function representedPushes(payload: OrcaPushPayload): OrcaPushPayload[] {
// Gateway summaries are no longer generated; this bounded reader exists only
// for notifications already sitting in a user's tray during the transition.
if ((payload.coalescedCount ?? 0) <= 1) {
return [payload]
}
@@ -2,7 +2,11 @@ import { representedPushes } from './push-summary-members'
import { readNativeNotificationData } from './native-notification-data'
import * as Notifications from 'expo-notifications'
import { readOrcaPushPayload, type OrcaPushPayload } from './push-payload'
import { rememberPushDismissal, wasPushDismissed } from './push-dismissal-watermarks'
import {
areLegacySummaryPushesDismissed,
rememberPushDismissal,
wasPushDismissed
} from './push-dismissal-watermarks'
async function dismissMatchingPresentedPushes(
matches: (payload: OrcaPushPayload) => boolean | Promise<boolean>
@@ -68,7 +72,7 @@ export async function dismissPresentedPushNotification(
return false
}
if ((payload.coalescedCount ?? 0) > 1) {
return wasPushDismissed(payload)
return areLegacySummaryPushesDismissed(payload)
}
return (
payload.notificationId === notificationId &&
@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { sha256 } from '@noble/hashes/sha256'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry } from '../transport/types'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
} from './notification-reconnect-catchup'
import { markPresentedPushesSeen, readPresentedPushSeenKeys } from './push-tray-seen-seed'
vi.mock('expo-notifications', () => ({ getPresentedNotificationsAsync: vi.fn() }))
vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn() }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
const publicKey = Uint8Array.from({ length: 32 }, (_, index) => index)
const publicKeyB64 = Buffer.from(publicKey).toString('base64')
const hostFingerprint = Buffer.from(sha256(publicKey)).toString('base64url').slice(0, 16)
const hosts = [{ id: 'host-1', publicKeyB64 }] as unknown as HostCatalogEntry[]
function presented(orca: Record<string, unknown>): unknown {
const identifier = `tray-${String(orca.notificationId ?? 'bell')}`
return { request: { identifier, content: { data: { orca } } } }
}
beforeEach(() => {
vi.clearAllMocks()
resetHostNotificationSessionsForTests()
vi.mocked(loadHostCatalog).mockResolvedValue(hosts)
})
describe('readPresentedPushSeenKeys', () => {
it('keys the tray entries the gateway pushed for this host', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented({ hostFingerprint, notificationId: 'agent:one', notificationSeq: 6 }),
presented({ hostFingerprint, notificationSeq: 7 })
] as never)
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([
{ key: 'id:agent:one#6', epoch: undefined },
{ key: 'seq:7', epoch: undefined }
])
})
it('ignores a tray entry belonging to another paired host', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented({ hostFingerprint: '0123456789abcdef', notificationId: 'agent:one' })
] as never)
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
})
it('ignores a coalesced summary, whose key names a banner nobody has seen', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
presented({
hostFingerprint,
notificationId: 'agent:one',
notificationSeq: 6,
coalescedCount: 3
})
] as never)
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
})
it('ignores a locally scheduled notification, which the socket path already owns', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockResolvedValue([
{ request: { identifier: 'tray-1', content: { data: { hostId: 'host-1' } } } }
] as never)
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
expect(loadHostCatalog).toHaveBeenCalled()
})
it('stays silent on a native shell that cannot query the tray', async () => {
vi.mocked(Notifications.getPresentedNotificationsAsync).mockRejectedValue(
new Error('unavailable')
)
await expect(readPresentedPushSeenKeys('host-1')).resolves.toEqual([])
})
})
describe('markPresentedPushesSeen', () => {
it('claims the keys without touching the watermark', () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-1'
markPresentedPushesSeen(session, [{ key: 'id:agent:one#9', epoch: 'epoch-1' }])
expect(session.seen.has('id:agent:one#9')).toBe(true)
// A push seq proves one event was shown, not that everything below it was.
expect(session.lastDeliveredSeq).toBe(0)
})
it('drops a key that names no counter lifetime at all', () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-1'
markPresentedPushesSeen(session, [{ key: 'seq:4', epoch: undefined }])
// The desktop always sends an epoch; a key without one cannot be shown to belong
// to this counter, and claiming it would drop the real bell at seq 4.
expect(session.seen.has('seq:4')).toBe(false)
})
it('drops a key from a desktop lifetime that has already been retired', () => {
const session = getHostNotificationSession('host-1')
session.lastDeliveredEpoch = 'epoch-2'
markPresentedPushesSeen(session, [{ key: 'seq:4', epoch: 'epoch-1' }])
// The new counter re-issues seq 4, so the stale key would drop a real bell.
expect(session.seen.has('seq:4')).toBe(false)
})
})
@@ -1,77 +0,0 @@
import { wasPushDismissed } from './push-dismissal-watermarks'
import { readNativeNotificationData } from './native-notification-data'
import * as Notifications from 'expo-notifications'
import { loadHostCatalog } from '../transport/host-store'
import { seenKeyForEvent, type HostNotificationSession } from './notification-reconnect-catchup'
import { resolveHostIdForFingerprint } from './push-host-fingerprint'
import { readOrcaPushPayload } from './push-payload'
/**
* Dedup keys for the pushes the OS has already drawn for one host.
*
* Why this exists: a push shown while Orca was closed never ran through the
* foreground handler, so nothing in this process claimed its key. The reconnect
* catch-up then replays that same event and shows a second banner for it.
*
*/
export type PresentedPushSeenKey = { readonly key: string; readonly epoch: string | undefined }
export async function readPresentedPushSeenKeys(
hostId: string
): Promise<readonly PresentedPushSeenKey[]> {
try {
const presented = await Notifications.getPresentedNotificationsAsync()
if (presented.length === 0) {
return []
}
const hosts = await loadHostCatalog().catch(() => [])
const keys: PresentedPushSeenKey[] = []
for (const notification of presented) {
const payload = readOrcaPushPayload(readNativeNotificationData(notification.request))
// A coalesced summary stands in for N events while carrying only the latest
// one's fields, so its key belongs to a banner the user has NOT seen.
if (!payload || (payload.coalescedCount ?? 0) > 1) {
continue
}
if (resolveHostIdForFingerprint(payload.hostFingerprint, hosts) !== hostId) {
continue
}
if (await wasPushDismissed(payload)) {
await Notifications.dismissNotificationAsync(notification.request.identifier).catch(
() => {}
)
continue
}
const key = seenKeyForEvent(payload)
if (key) {
keys.push({ key, epoch: payload.notificationEpoch })
}
}
return keys
} catch {
// Older native shells lack the tray query; the catch-up replays as it did before.
return []
}
}
/**
* Claim the tray's keys on the session, skipping any that do not name the live
* counter lifetime. A push without an epoch cannot be tied to this counter, and
* the desktop always sends one, so it is left unclaimed rather than allowed to
* swallow a real event at the same seq.
*
* The watermark is deliberately untouched: a push seq proves one event was shown,
* not that everything below it was, and advancing past a gap would make the desktop
* cut the notifications in it forever.
*/
export function markPresentedPushesSeen(
session: HostNotificationSession,
keys: readonly PresentedPushSeenKey[]
): void {
for (const { key, epoch } of keys) {
if (epoch == null || epoch !== session.lastDeliveredEpoch) {
continue
}
session.seen.add(key)
}
}
@@ -1,94 +0,0 @@
import { wasHostPushDismissed } from './push-socket-dismissal'
import { beforeEach, expect, it, vi } from 'vitest'
import { AppState } from 'react-native'
import { waitForSocketPushHandoff } from './socket-push-delivery-handoff'
import { readPresentedPushSeenKeys } from './push-tray-seen-seed'
import { loadRemotePushEnabled } from '../storage/preferences'
import { seenKeyForEvent } from './notification-reconnect-catchup'
vi.mock('./push-socket-dismissal', () => ({ wasHostPushDismissed: vi.fn(async () => false) }))
let active: ((state: string) => void) | undefined
const remove = vi.fn()
vi.mock('react-native', () => ({
AppState: {
currentState: 'background',
addEventListener: vi.fn((_event, callback) => {
active = callback
return { remove }
})
}
}))
vi.mock('../storage/preferences', () => ({
loadRemotePushEnabled: vi.fn(async () => true),
loadRemotePushHostRegistrations: vi.fn(async () => ({ registeredHostIds: ['host'] }))
}))
vi.mock('./push-tray-seen-seed', () => ({ readPresentedPushSeenKeys: vi.fn(async () => []) }))
const event = {
type: 'notification' as const,
source: 'agent-task-complete' as const,
title: 'Done',
body: '',
notificationId: 'done',
notificationSeq: 1,
notificationEpoch: 'epoch'
}
beforeEach(() => {
vi.clearAllMocks()
active = undefined
AppState.currentState = 'background'
})
it('waits for foreground and suppresses a live socket event already delivered by APNs', async () => {
vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([
{ key: seenKeyForEvent(event)!, epoch: 'epoch' }
])
const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
await vi.waitFor(() => expect(active).toBeDefined())
expect(readPresentedPushSeenKeys).not.toHaveBeenCalled()
AppState.currentState = 'active'
active?.('active')
expect(await delivery).toBe(false)
expect(remove).toHaveBeenCalledOnce()
})
it('falls back to local delivery on foreground when no provider notification arrived', async () => {
vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([])
const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
await vi.waitFor(() => expect(active).toBeDefined())
AppState.currentState = 'active'
active?.('active')
expect(await delivery).toBe(true)
})
it('releases the background wait when the subscription is disposed', async () => {
const controller = new AbortController()
const delivery = waitForSocketPushHandoff(event, 'host', controller.signal)
await vi.waitFor(() => expect(active).toBeDefined())
controller.abort()
expect(await delivery).toBe(false)
expect(remove).toHaveBeenCalledOnce()
})
it('keeps local background delivery when remote push is disabled', async () => {
vi.mocked(loadRemotePushEnabled).mockResolvedValueOnce(false)
expect(await waitForSocketPushHandoff(event, 'host', new AbortController().signal)).toBe(true)
expect(active).toBeUndefined()
})
it('leaves hosts without a registered push token on local delivery', async () => {
expect(
await waitForSocketPushHandoff(event, 'unregistered-host', new AbortController().signal)
).toBe(true)
expect(active).toBeUndefined()
})
it('does not resurrect an alert dismissed while its handoff waited for foreground', async () => {
vi.mocked(readPresentedPushSeenKeys).mockResolvedValue([])
const delivery = waitForSocketPushHandoff(event, 'host', new AbortController().signal)
await vi.waitFor(() => expect(active).toBeDefined())
vi.mocked(wasHostPushDismissed).mockResolvedValueOnce(true)
AppState.currentState = 'active'
active?.('active')
expect(await delivery).toBe(false)
})
@@ -1,50 +0,0 @@
import { wasHostPushDismissed } from './push-socket-dismissal'
import { AppState } from 'react-native'
import { loadRemotePushEnabled, loadRemotePushHostRegistrations } from '../storage/preferences'
import { readPresentedPushSeenKeys } from './push-tray-seen-seed'
import { seenKeyForEvent } from './notification-reconnect-catchup'
import type { NotificationEvent } from './local-notification-scheduling'
function waitUntilActive(signal: AbortSignal): Promise<void> {
if (AppState.currentState === 'active' || signal.aborted) {
return Promise.resolve()
}
return new Promise((resolve) => {
const finish = () => {
subscription.remove()
signal.removeEventListener('abort', finish)
resolve()
}
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
finish()
}
})
signal.addEventListener('abort', finish, { once: true })
if (signal.aborted || AppState.currentState === 'active') {
finish()
}
})
}
export async function waitForSocketPushHandoff(
event: NotificationEvent,
hostId: string,
signal: AbortSignal
): Promise<boolean> {
if (!(await loadRemotePushEnabled())) {
return true
}
const registrations = await loadRemotePushHostRegistrations()
if (!registrations.registeredHostIds.includes(hostId)) {
return true
}
// iOS can keep the socket alive while backgrounded; let APNs own that interval.
await waitUntilActive(signal)
if (signal.aborted || (await wasHostPushDismissed(event, hostId))) {
return false
}
const key = seenKeyForEvent(event)
const presented = await readPresentedPushSeenKeys(hostId)
return !presented.some((push) => push.key === key && push.epoch === event.notificationEpoch)
}
+2 -39
View File
@@ -1,15 +1,10 @@
import { notifyNotificationConsentChanged } from '../notifications/notification-consent-events'
import {
loadNotificationDeliveryPreferences,
notificationPreferencesFilter,
saveNotificationDeliveryPreferences
notificationPreferencesFilter
} from '../notifications/notification-delivery-preferences'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
MOBILE_PUSH_AGENT_STATES,
type MobilePushAgentState,
type MobilePushFilter
} from '../../../src/shared/mobile-push-contract'
import type { MobilePushFilter } from '../../../src/shared/mobile-push-contract'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
@@ -45,11 +40,8 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise<vo
// Retained for older mobile builds; the master preference owns both delivery paths.
const REMOTE_PUSH_KEY = 'orca:remotePushEnabled'
const REMOTE_PUSH_AGENT_STATES_KEY = 'orca:remotePushAgentStates'
const REMOTE_PUSH_HOST_REGISTRATIONS_KEY = 'orca:remotePushHostRegistrations'
// The host and phone share the same source and agent-state vocabulary.
export type RemotePushAgentState = MobilePushAgentState
export type RemotePushFilter = MobilePushFilter
export async function loadRemotePushEnabled(): Promise<boolean> {
@@ -68,35 +60,6 @@ export async function saveRemotePushEnabled(enabled: boolean): Promise<void> {
await savePushNotificationsEnabled(enabled)
}
function remotePushAgentStates(value: unknown): RemotePushAgentState[] {
return stringArray(value).filter((state): state is RemotePushAgentState =>
(MOBILE_PUSH_AGENT_STATES as readonly string[]).includes(state)
)
}
// Both states default on; an absent key is a device that never opened the section.
export async function loadRemotePushAgentStates(): Promise<readonly RemotePushAgentState[]> {
try {
const raw = await AsyncStorage.getItem(REMOTE_PUSH_AGENT_STATES_KEY)
return raw === null ? MOBILE_PUSH_AGENT_STATES : remotePushAgentStates(JSON.parse(raw))
} catch {
return MOBILE_PUSH_AGENT_STATES
}
}
export async function saveRemotePushAgentStates(
states: readonly RemotePushAgentState[]
): Promise<void> {
const current = await loadNotificationDeliveryPreferences()
await saveNotificationDeliveryPreferences({
...current,
followDesktop: false,
taskFinished: states.includes('finished'),
needsInput: states.includes('needs-input')
})
await AsyncStorage.setItem(REMOTE_PUSH_AGENT_STATES_KEY, JSON.stringify([...states]))
}
export async function loadRemotePushFilter(): Promise<RemotePushFilter> {
return notificationPreferencesFilter(await loadNotificationDeliveryPreferences())
}
@@ -2,92 +2,46 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const removeHostMock = vi.hoisted(() => vi.fn())
const unregisterPushMock = vi.hoisted(() => vi.fn(async () => {}))
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
// Why removeItem is here: clearWatermark() swallows its own failures, so a mock
// missing this method turns the persisted-watermark cleanup into a caught
// TypeError — the assertion below would pass even if the call were deleted.
removeItem: vi.fn(async () => undefined)
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('./host-store', () => ({
removeHost: (hostId: string) => removeHostMock(hostId)
}))
// Why mocked: the real module reaches expo-notifications for the device token, which
// no node test environment can load.
vi.mock('../notifications/push-registration', () => ({
unregisterPushForRemovedHost: (hostId: string) => unregisterPushMock(hostId)
}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
} from '../notifications/notification-reconnect-catchup'
describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
unregisterPushMock.mockClear()
asyncStorage.removeItem.mockClear()
resetHostNotificationSessionsForTests()
})
it('closes the client only after metadata removal commits', async () => {
let commitRemoval: (() => void) | null = null
removeHostMock.mockReturnValue(
new Promise<void>((resolve) => {
commitRemoval = resolve
})
)
removeHostMock.mockReturnValue(new Promise<void>((resolve) => (commitRemoval = resolve)))
const closeHostClient = vi.fn()
const removal = removeHostAndCloseClient('host-1', closeHostClient)
expect(closeHostClient).not.toHaveBeenCalled()
commitRemoval?.()
await removal
expect(closeHostClient).toHaveBeenCalledWith('host-1')
})
it('keeps the client open when metadata removal fails', async () => {
removeHostMock.mockRejectedValue(new Error('storage unavailable'))
const closeHostClient = vi.fn()
await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow(
'storage unavailable'
)
expect(closeHostClient).not.toHaveBeenCalled()
})
it('retires the notification session so a removed host leaves nothing behind', async () => {
// Round-1 review finding: the session lives at module scope (it must survive the
// subscription teardown a reconnect performs), so removal is the only thing that
// can retire it. Left behind, each remove/re-pair cycle strands a session plus up
// to 512 seen keys, and a re-paired host inherits a watermark it never earned.
removeHostMock.mockResolvedValue(undefined)
const session = getHostNotificationSession('host-1')
session.lastDeliveredSeq = 42
session.lastDeliveredEpoch = 'epoch-A'
await removeHostAndCloseClient('host-1', vi.fn())
// A fresh session for the same id — not the retained one.
const afterRemoval = getHostNotificationSession('host-1')
expect(afterRemoval).not.toBe(session)
expect(afterRemoval.lastDeliveredSeq).toBe(0)
expect(afterRemoval.lastDeliveredEpoch).toBeNull()
})
it('drops the gateway push registration before the credentials it needs are gone', async () => {
removeHostMock.mockResolvedValue(undefined)
await removeHostAndCloseClient('host-1', vi.fn())
expect(unregisterPushMock).toHaveBeenCalledWith('host-1')
expect(unregisterPushMock.mock.invocationCallOrder[0]).toBeLessThan(
removeHostMock.mock.invocationCallOrder[0]
@@ -98,23 +52,7 @@ describe('host removal lifecycle', () => {
removeHostMock.mockResolvedValue(undefined)
unregisterPushMock.mockRejectedValueOnce(new Error('socket closed'))
const closeHostClient = vi.fn()
await removeHostAndCloseClient('host-1', closeHostClient)
expect(closeHostClient).toHaveBeenCalledWith('host-1')
})
it('erases the persisted watermark, not just the in-memory session', async () => {
// Why separately from the test above: the session is process-local, the
// watermark is not. Retiring only the session lets a re-pair of the same host
// read the old seq off disk and resume against a counter it never saw — the
// catch-up would then start above the real cut and drop everything below it.
removeHostMock.mockResolvedValue(undefined)
await removeHostAndCloseClient('host-1', vi.fn())
// clearWatermark is fire-and-forget; let its microtask land.
await Promise.resolve()
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
})
})
@@ -1,7 +1,3 @@
import {
clearWatermark,
forgetHostNotificationSession
} from '../notifications/notification-reconnect-catchup'
import { unregisterPushForRemovedHost } from '../notifications/push-registration'
import { removeHost } from './host-store'
@@ -16,9 +12,4 @@ export async function removeHostAndCloseClient(
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
forgetHostClient(hostId)
// Why: the notification session outlives the socket by design (it must survive
// reconnects), so removal is the only thing that can retire it. Left behind, a
// re-pair of the same host would inherit a watermark for a counter it never saw.
forgetHostNotificationSession(hostId)
void clearWatermark(hostId)
}
@@ -103,7 +103,7 @@ describe('PushDispatcher', () => {
expect(harness.sends).toHaveLength(0)
})
it('applies each device filter independently', async () => {
it('ignores obsolete category filters on existing registrations', async () => {
const harness = createHarness({
devices: [
{
@@ -127,10 +127,10 @@ describe('PushDispatcher', () => {
harness.dispatcher.enqueue(notification({ agentState: 'blocked' }))
await flush()
expect(harness.sends[0]?.registrationIds).toEqual(['reg-needs', 'reg-all'])
expect(harness.sends[0]?.registrationIds).toEqual(['reg-needs', 'reg-bell', 'reg-all'])
})
it('pushes a bell to a device that filtered agent states out', async () => {
it('pushes a desktop-eligible bell despite an obsolete empty agent-state filter', async () => {
const harness = createHarness({
devices: [
{
+30 -9
View File
@@ -3,11 +3,14 @@ import { reserveNotificationCooldown } from '../../../shared/notification-burst-
// already went to connected sockets is offered to the push gateway so a phone
// with Orca closed still hears about it. Fire-and-forget by construction: the
// socket fan-out must never wait on, or fail because of, a push.
import type { MobilePushRegistration } from '../../../shared/mobile-push-contract'
import { PushOutcomeCounters } from './push-outcome-counters'
import { MOBILE_PUSH_SOURCES } from '../../../shared/mobile-push-contract'
import {
MOBILE_PUSH_SOURCES,
type MobilePushAgentState,
type MobilePushRegistration
} from '../../../shared/mobile-push-contract'
import type { MobileNotificationEvent } from '../runtime-mobile-notification-controller'
import type { PushGatewayClient, PushSendNotification } from './push-gateway-client'
import { PushOutcomeCounters } from './push-outcome-counters'
const PUSH_RETRY_DELAY_MS = 2_000
// The gateway rejects a whole request above this, so a host with more paired
@@ -35,11 +38,29 @@ function clip(value: string, maxLength: number): string {
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`
}
export { mapPushAgentState } from '../../../shared/mobile-notification-policy'
import {
allowsMobileNotification,
mapPushAgentState
} from '../../../shared/mobile-notification-policy'
export function mapPushAgentState(
source: string,
state: string | undefined
): MobilePushAgentState | null | undefined {
if (source !== 'agent-task-complete') {
return null
}
if (state === 'blocked' || state === 'waiting' || state === 'needs-input') {
return 'needs-input'
}
return state === undefined || state === 'done' || state === 'finished' ? 'finished' : undefined
}
function allowsPushDelivery(
registration: MobilePushRegistration,
event: MobileNotificationEvent
): boolean {
return (
event.type === 'notification' &&
event.desktopAllowed !== false &&
(!registration.filter.onlyWhenDesktopAway || event.desktopAway !== false)
)
}
export class PushDispatcher {
private readonly recentNotifications = new Map<string, number>()
@@ -139,7 +160,7 @@ export class PushDispatcher {
if (
!registration ||
(registration.expiresAt !== undefined && registration.expiresAt <= Date.now()) ||
!allowsMobileNotification(registration.filter, event)
!allowsPushDelivery(registration, event)
) {
return []
}
@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { readDesktopAwayState } from '../../notifications/desktop-away-state'
import { allowsMobileNotification } from '../../../shared/mobile-notification-policy'
import { DeviceRegistry } from '../device-registry'
import { RuntimeMobileNotificationController } from '../runtime-mobile-notification-controller'
import { setRuntimeDesktopSurface } from '../runtime-desktop-surface'
@@ -76,7 +75,7 @@ async function pipeline() {
return { path, registry, device, controller, client, register, dispatch }
}
it('carries the native idle boundary through replay, socket policy and push dispatch', async () => {
it('carries the native idle boundary through replay and push dispatch', async () => {
let idle = 179
setRuntimeDesktopSurface({
isAwayForMobileNotifications: () =>
@@ -90,12 +89,6 @@ it('carries the native idle boundary through replay, socket policy and push disp
removeIpcListener: () => {}
})
const h = await pipeline()
const socketVerdicts: boolean[] = []
h.controller.onDispatched((event) => {
if (event.type === 'notification') {
socketVerdicts.push(allowsMobileNotification(filter, JSON.parse(JSON.stringify(event))))
}
})
h.dispatch()
await flush()
expect(h.client.send).not.toHaveBeenCalled()
@@ -107,12 +100,11 @@ it('carries the native idle boundary through replay, socket policy and push disp
h.dispatch()
await flush()
expect(h.client.send).toHaveBeenCalledTimes(1)
expect(socketVerdicts).toEqual([false, true, false])
const replay = h.controller.getMissedSince(0)
expect(replay).toHaveLength(3)
expect(
replay.map((event) => allowsMobileNotification(filter, JSON.parse(JSON.stringify(event))))
).toEqual([false, true, false])
expect(replay.map((event) => (event.type === 'notification' ? event.desktopAway : null))).toEqual(
[false, true, false]
)
})
it('keeps headless presence unknown and legacy socket events readable', async () => {
@@ -124,12 +116,6 @@ it('keeps headless presence unknown and legacy socket events readable', async ()
await flush()
expect(events[0]).not.toHaveProperty('desktopAway')
expect(h.client.send).toHaveBeenCalledTimes(1)
expect(
allowsMobileNotification(filter, {
source: 'agent-task-complete',
agentState: 'done'
})
).toBe(true)
})
it('expires persisted registration at seven days despite host activity and renews explicitly', async () => {
+17 -9
View File
@@ -1,7 +1,8 @@
import { expect, it } from 'vitest'
import { parseMobilePushRegistration } from '../../../shared/mobile-push-contract'
import { createHarness, notification, registration, flush } from './push-dispatcher.test-fixture'
it('routes a desktop-disabled bell only to a phone that independently permits bells', async () => {
it('applies current desktop category eligibility over every persisted phone filter', async () => {
const filter = registration().filter
const harness = createHarness({
devices: [
@@ -21,17 +22,24 @@ it('routes a desktop-disabled bell only to a phone that independently permits be
},
{
deviceId: 'no-bells',
pushRegistration: registration({
registrationId: 'no-bells',
filter: { ...filter, followDesktop: false, sources: ['agent-task-complete'] }
})
pushRegistration: parseMobilePushRegistration(
registration({
registrationId: 'no-bells',
filter: { ...filter, followDesktop: false, sources: ['agent-task-complete'] }
})
)
}
]
})
harness.dispatcher.enqueue(notification({ source: 'terminal-bell', desktopAllowed: false }))
await flush()
expect(harness.sends).toHaveLength(1)
expect(harness.sends[0]).toMatchObject({
expect(harness.sends).toHaveLength(0)
harness.dispatcher.enqueue(notification({ source: 'terminal-bell', desktopAllowed: true }))
await flush()
expect(harness.sends).toHaveLength(2)
expect(harness.sends[0]).toMatchObject({ registrationIds: ['mirror', 'no-bells'] })
expect(harness.sends[1]).toMatchObject({
registrationIds: ['override'],
notification: { sound: false }
})
@@ -61,7 +69,7 @@ it('keeps sound preferences separate when several phones receive the same event'
})
})
it('applies burst suppression after each phone filters event types', async () => {
it('applies burst suppression independently to each eligible phone', async () => {
const harness = createHarness({
devices: [
{
@@ -83,5 +91,5 @@ it('applies burst suppression after each phone filters event types', async () =>
harness.dispatcher.enqueue(notification({ source: 'terminal-bell', emittedAt: 10000 }))
harness.dispatcher.enqueue(notification({ emittedAt: 10250 }))
await flush()
expect(harness.sends.map((send) => send.registrationIds)).toEqual([['all'], ['no-bells']])
expect(harness.sends.map((send) => send.registrationIds)).toEqual([['all', 'no-bells']])
})
@@ -47,9 +47,8 @@ const NotificationGetMissedSinceParams = z.object({
.optional()
})
// Why: the phone owns which alerts are worth waking it for; the host stores the
// filter per device and applies it before it ever calls the gateway. Native push
// tokens are long (FCM registration strings), so the bound is generous.
// Category fields remain required for older peers, but updated hosts use only
// phone-specific away, expiry, and sound preferences from this filter.
const NotificationPushFilterParams = z.object({
onlyWhenDesktopAway: z.boolean().optional(),
expireAfterInactivity: z.boolean().optional(),
@@ -75,10 +74,8 @@ const NotificationRegisterPushParams = z
message: 'apnsEnvironment is required for ios'
})
// Why: notifications.subscribe streams desktop notification events to mobile
// clients over WebSocket. The mobile client shows a local push notification
// for each event. This avoids requiring Firebase/APNs — the existing
// persistent WebSocket connection doubles as the push channel.
// The socket stream keeps mobile app state and dismissal reconciliation live;
// native push is the only route that presents ordinary mobile OS alerts.
export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
defineStreamingMethod({
name: 'notifications.subscribe',
@@ -1,47 +0,0 @@
import { describe, expect, it } from 'vitest'
import { allowsMobileNotification } from './mobile-notification-policy'
import {
MOBILE_PUSH_SOURCES,
MOBILE_PUSH_AGENT_STATES,
parseMobilePushRegistration
} from './mobile-push-contract'
describe('notification delivery preferences', () => {
const filter = { sources: MOBILE_PUSH_SOURCES, agentStates: MOBILE_PUSH_AGENT_STATES }
it.each(['agent-task-complete', 'terminal-bell', 'plugin'])(
'mirrors desktop settings for %s, but permits an explicit override',
(source) => {
const event = { source, desktopAllowed: false }
expect(allowsMobileNotification(filter, event)).toBe(false)
expect(allowsMobileNotification({ ...filter, followDesktop: true }, event)).toBe(false)
expect(allowsMobileNotification({ ...filter, followDesktop: false }, event)).toBe(true)
expect(allowsMobileNotification(filter, { source })).toBe(true)
}
)
it('keeps bells independent of agent states and supports disabling them', () => {
expect(
allowsMobileNotification({ ...filter, agentStates: [] }, { source: 'terminal-bell' })
).toBe(true)
expect(
allowsMobileNotification(
{ ...filter, sources: ['agent-task-complete'] },
{ source: 'terminal-bell' }
)
).toBe(false)
})
it.each(['working', 'unknown'])('never presents %s agent activity', (agentState) => {
expect(allowsMobileNotification(filter, { source: 'agent-task-complete', agentState })).toBe(
false
)
})
it('preserves independent mode and silence through a desktop restart', () => {
expect(
parseMobilePushRegistration({
registrationId: 'r',
platform: 'ios',
registeredAt: 1,
filter: { ...filter, followDesktop: false, sound: false }
})?.filter
).toEqual({ ...filter, followDesktop: false, sound: false })
})
})
-38
View File
@@ -1,38 +0,0 @@
import type { MobilePushAgentState, MobilePushFilter } from './mobile-push-contract'
export type MobileNotificationPolicyEvent = {
source: string
agentState?: string
desktopAllowed?: boolean
desktopAway?: boolean
}
export function mapPushAgentState(
source: string,
state: string | undefined
): MobilePushAgentState | null | undefined {
if (source !== 'agent-task-complete') {
return null
}
if (state === 'blocked' || state === 'waiting' || state === 'needs-input') {
return 'needs-input'
}
return state === undefined || state === 'done' || state === 'finished' ? 'finished' : undefined
}
export function allowsMobileNotification(
filter: MobilePushFilter,
event: MobileNotificationPolicyEvent
): boolean {
if (filter.onlyWhenDesktopAway && event.desktopAway === false) {
return false
}
if (filter.followDesktop !== false && event.desktopAllowed === false) {
return false
}
if (!filter.sources.some((source) => source === event.source)) {
return false
}
const state = mapPushAgentState(event.source, event.agentState)
return state !== undefined && (state === null || filter.agentStates.includes(state))
}
+2 -1
View File
@@ -18,8 +18,9 @@ export type MobilePushApnsEnvironment = (typeof MOBILE_PUSH_APNS_ENVIRONMENTS)[n
export type MobilePushFilter = {
onlyWhenDesktopAway?: boolean
expireAfterInactivity?: boolean
followDesktop?: boolean
sound?: boolean
/** Legacy category fields retained so independently updated clients and hosts still register. */
followDesktop?: boolean
sources: readonly MobilePushSource[]
agentStates: readonly MobilePushAgentState[]
}