* fix: stop notification loss, credentialed cache reuse, and clipboard clobber
Mobile catch-up (#8591): fetchMissed swallowed the RPC failure while
deliverLive kept advancing and persisting lastDeliveredSeq, so the next
successful catch-up asked from above the abandoned range and the desktop
cut it. Sessions are module-scope, so an unchanged epoch never resets it.
Quarantine the watermark at the last contiguously-delivered seq and hold
it there until some later catch-up actually drains — not just one retry.
A batch cut short by a teardown quarantines at the last event it settled.
Jira attachment cache: currentEpoch summed two independent counters, so a
site at siteEpoch 1 read the same value before and after a global clear.
The mid-flight guard passed and re-inserted credentialed image bytes that
"disconnect all" had just purged — resident for the process lifetime since
pruneExpired has no timer. One monotonic ticker, compared by max.
Web copy fallback: the handler registered in the capture phase, so xterm's
bubble-phase listener overwrote text/plain with the terminal selection
afterwards; served was already true, so the copy reported success. Every
Orca copy affordance over plain HTTP (Copy Pane ID, Copy Path, commit SHA,
PR URL) pasted the terminal selection. Bubble phase with
stopImmediatePropagation. Covers the secure-context retry branch too,
which shares the same helper.
* fix: roll back the persisted watermark on catch-up failure; cover stopImmediatePropagation
Adversarial review of a98d7f4d5d found two gaps.
1. The quarantine clamped only writes made AFTER the failure. getMissedSince
waits up to 30s, so a live event routinely persists a higher seq while the
request is still outstanding; that value stayed on disk, and the next launch
read it back and resumed past the abandoned range -- the original bug,
reached through a restart. quarantineCatchUpWatermark now re-persists the
clamped seq, so the stored value never outlives the gap it guards.
2. web-clipboard-copy-terminal-selection's second test registered its "late"
document handler BEFORE the fallback's, so it lost on registration order
alone and stopImmediatePropagation was never exercised -- the test passed
with that line deleted. Bubbling reaches the document before the window, so
a window-level listener is what actually requires it.
* fix(mobile): mark a notification seen only once its show lands
A pre-marked seen key made a rejected show unrecoverable: the next
catch-up re-fetched the seq and the dedup guard dropped it, and the
first later event to drain the batch lifted the quarantine past it.
Also contains the rejection so it does not escape the un-awaited
'ready'/live handlers as an unhandled rejection.
Co-authored-by: Orca <help@stably.ai>
* test(web-clipboard): pin stopImmediatePropagation with a same-target handler
Both existing cases passed with plain stopPropagation, and with the listener
back in the capture phase — neither half of the fix was actually pinned. The
window-level clobber is on a different target, so stopPropagation suppresses
it too. Registering the clobber on the document, ordered after the fallback's
own listener, is the only shape stopPropagation cannot cover.
Addresses the review comment posted after the last commit.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): keep the reconnect watermark alive across the app's own teardown
The catch-up added in #8690 could never run. app/index.tsx unsubscribes the
notification stream on every non-'connected' state and builds a fresh
subscription on reconnect, so the closure holding the ready-counter, the
delivered watermark and the seen-set is destroyed exactly when a reconnect
needs them. Every reconnect looked like a cold open, `reconnectReadyCount`
was always 1, and notifications dispatched while the socket was down were
never fetched.
Move that state to a per-host module-scope session so it survives the
teardown.
Refs #8591
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): tag the notification watermark with a counter epoch so a desktop restart can't kill catch-up
The desktop's notification `seq` is a per-process in-memory counter that starts
at 0 on every launch. The mobile client's watermark is persisted in AsyncStorage
and monotonic. After a desktop restart the two index different counters, so a
client holding seq 57 meets a fresh counter at 2, `57 >= 2` cuts everything, and
reconnect catch-up dies silently until the new process out-dispatches the old
watermark — 57 notifications later. Users see nothing and get no error (#8591).
Stamp every dispatched notification with an epoch identifying the counter
lifetime, ride it on the `ready` frame and the getMissedSince response, and
persist it beside the watermark. A watermark whose epoch doesn't match the live
counter is void: the client resets to 0 and the desktop returns its retained
buffer instead of nothing.
The epoch param is optional on the wire in both directions, so a client or
daemon that predates it degrades to today's seq-only cut rather than erroring.
Also extracts the OS-permission helpers to notification-permissions.ts (re-
exported, so no importer changes) to keep mobile-notifications.ts under its
max-lines budget.
Mutation-tested: 3 mutations applied to the epoch logic, 3 killed — including
the storage-seed race guard, whose first mutant survived until the deferred-read
test was added.
* fix(mobile): make the notification watermark atomic and counter-scoped
Round-1 review found four ways the epoch fix could still lose notifications.
All four are addressed here.
1. Seen-set survived an epoch change. Seen-keys are seq-derived, and terminal
bells carry no notificationId (they key on `seq:N` alone). After a restart
the fresh counter re-issues low seqs, so a replayed post-restart bell was
dropped as a duplicate of a bell from the previous counter. The dedup window
belongs to one counter lifetime, so it is cleared on epoch change.
2. Legacy watermarks were trusted. Pre-upgrade installs stored a bare seq with
no epoch. Adopting the first observed epoch as "nothing changed" left that
unprovenanced seq cutting a counter it was never measured against — #8591
through the upgrade path. An epoch-less seq no longer survives adoption.
3. seq and epoch were separate storage keys. A process death between the two
writes left epoch-B beside seq-57-from-A: a pair that looks internally valid
on the next launch and is therefore trusted. They are now one JSON value,
which cannot tear, with a read-only migration from the legacy key.
4. Sessions were never retired. They live at module scope so they survive the
subscription teardown a reconnect performs, so host removal is the only
thing that can drop them. Removal now retires the session and its watermark.
Mutation-tested: 3 mutations, 3 killed. The first version of the bell test
passed with the fix removed — it exercised the live path, which only adds to
the seen-set; only the replay path consults it. Rewritten against the replay
path, it fails with `expected 1 to be 2`: the literal lost notification.
Mobile notifications + transport: 355 passed. Desktop replay: 11/11.
* fix(mobile): catch up on the first connection after a cold open
Catch-up hung off 'has this process connected before', which is false on the
first ready of a fresh launch — exactly the post-upgrade / post-eviction case
that loses everything between the stored watermark and the next live seq. Wait
for the persisted read, then catch up whenever this device has delivered for
the host before; a first-ever pairing still gets no replay.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): serialize live delivery behind the watermark seed, and key catch-up on the record
Co-authored-by: Orca <help@stably.ai>
* test(mobile): pin the two catch-up mechanisms mutation testing found unguarded
Mutating each mechanism of the #8591 fix in turn showed two survived with the
suite still green: the seed's epoch-provenance check, and the host session
outliving the subscription teardown. Both are load-bearing, so pin them.
- seen-set survives teardown: the desktop's retained buffer replays a
notification already delivered live, and only the session-scoped seen-set
stops a duplicate banner.
- a seed resolving after a live epoch was adopted must not reinstate the dead
watermark. Not reachable through subscribeToDesktopNotifications today
('ready' awaits the seed first), so it asserts on the exported pair and says
so.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): serialize notification delivery per host so the watermark can't outrun what was shown
Addresses two MAJOR findings from review of this branch.
MAJOR #1 — the watermark could be persisted past a notification the user never
saw. `deliverLive` advanced `lastDeliveredSeq` before awaiting the local show,
and replay + live delivery ran concurrently, so a live seq 11 handled while
catch-up was still showing seq 6 persisted 11. A process death before 7..10 were
shown lost them permanently: the next launch asks the desktop for seq > 11.
This predates the branch — `origin/main` advances the watermark at the same point
— so it is a residual this fix closes, not a regression the branch introduced. It
is fixed here because the branch is what makes the watermark load-bearing.
Three changes:
- the advance moves AFTER the show/dismiss await, so the watermark means
"everything up to here reached the user" rather than "was dispatched"
- a per-host `deliveryTail` promise chain (`enqueueHostDelivery`) serializes
deliveries, so a monotonic advance is also an in-order one
- the catch-up batch is ONE queue entry, not one per event. Awaiting per event
returns to the event loop between replays and let a live event slot in
between seq 6 and 7 — which is exactly the interleave being fixed. The RPC
stays outside the queue: `sendRequest` waits up to 30s and holding the chain
for that would stall live delivery on a slow link.
MAJOR #2 — every delivery awaits the persisted read, so an AsyncStorage read that
never settled disabled the host's notifications for the whole app lifetime, with
no error and nothing to see. The seed is now bounded at 3s; a late seed still
applies when it lands. Proceeding unseeded is strictly better: the watermark
stays 0, so catch-up over-fetches and the seen-set de-duplicates.
Serializing removed an overlap the duplicate-suppression relied on:
`showLocalNotification` deduped two same-id events by observing the first still
pending when the second arrived. With deliveries serialized the first completes
first, so the second saw no pending state and scheduled a second banner for the
same notification. The claim moves to enqueue time, where the overlap is still
observable. Dismisses are deliberately not claimed — a dismiss for a shown id is
what retires it.
Evidence — each mechanism disabled individually against the unchanged suite:
- batch-as-one-entry -> reverted to per-item enqueue: ordering test fails
- watermark advance -> moved back before the await: ordering test fails
- seed timeout -> removed: wedged-read test fails
- live-path claim -> removed: concurrent-dedup test fails
- replay-path claim -> removed: cross-path dedup test fails
Each kills exactly one test, so no mechanism is unguarded and none is redundant.
`mobile-notifications.test.ts`'s local `flushAsync` drained 10 microtask ticks.
Deliveries are now several awaits deeper, so a fixed tick count under-drains; it
yields to the macrotask queue instead. Verified with real timers that the
behavior it asserts is unchanged — only the drain depth was wrong.
Full mobile suite: 344 files, 2499 passed, 2 skipped. tsc clean, oxlint clean.
---------
Co-authored-by: Orca <help@stably.ai>
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.
Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.
Area: mobile. 11 files changed, 339 insertions(+), 1137 deletions(-).
Co-authored-by: Orca <help@stably.ai>
Take-over of #8605 (issue #8591). Ships #8498 (worktree resync + pull-to-refresh + cache write-through) and #8129 (idempotent notification replay on reconnect). Fixes the original PR's field mismatch (seq vs notificationSeq) and adds the missing notifications.getMissedSince mobile RPC allowlist entry. #6784 and #4500 held back to avoid conflicting with the relay work (#8536). Co-authored-by: Brandon Bennett (@branben).
scheduledNotificationsByHostAndNotificationId (mobile-notifications.ts)
retained one entry per scheduled desktop notification. The key embeds
notificationId, which carries a per-completion timestamp
(buildAgentNotificationId), so every agent-task-complete inserts a new,
never-reused key. Entries are removed only when the desktop sends a
matching dismiss — which a remote mobile user (not sitting at the
desktop) frequently never receives — so the module-level map grew for
the app's whole lifetime. Small per entry, but genuinely unbounded.
Fix: bound the map to the 256 most-recent SETTLED entries (never evict
one mid-schedule). A settled entry only retains a small identifier used
for later programmatic dismissal, which is unnecessary for long-past
completions, so eviction has no user-visible effect.
Also FIFO-cap RootLayout's handledNotificationIdsRef tap-dedup Set
(RootLayout never unmounts, so it otherwise grew one id per tapped
notification forever).
Test (red->green): with the cap at 1, scheduling a second notification
evicts the first, so a later dismiss for the evicted id is a no-op while
the retained one still dismisses; without the cap the old entry survives.