mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
00a4f047af56f3e889cf1818a00dc009fcf495f3
29
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f78483ec29 |
refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6, migration 1) (#20954)
* test(mobile): record the three step-6 families at the pin, and record a stream listener that dies Step 6 migrates the requests step 4 left behind because they share an effect with a `client.subscribe`. This records them first, from the pinned baseline, so the refactor that follows has a parity oracle. Three new families, one adapter module each: - `session.native-chat-page` — the older-history page. The read is a callback, but only the mount effect's `nativeChat.subscribe` arms what it pages against, so the frames are the setup: the snapshot's `beforeOffset` decides whether the request carries a cursor or asks for a growing tail. A cutover and a second snapshot pin the reconnect replay merging into paged-in history instead of collapsing the window. - `notifications.desktop-stream` — the desktop notification socket: the subscribe, the catch-up read its `ready` arms, the tray dismissals its events drive, and the server unsubscribe the disposer sends. Split in two so the base scenario's matrix sites all have partition-stable params: a variant that answers the second `ready` differently leaves the unsubscribe carrying the first subscription id, which the base's scripted params could not assert. - `session.terminal-gesture-input` — the debounced gesture flush and the menu's clear-buffer. Neither rides a subscription; a mount holding no terminal ref reaches both. The engine change is what makes the first two recordable at all. `ScriptedRpcTransport.frame` now returns what the product listener threw instead of throwing it on, and the runner records it as a `stream-listener-crash` effect. Only the two `runtime.clientEvents` listeners check that a frame payload is an object before reading its `type`; every other subscribing family took the matrix's `result-absent` and `result-null` partitions as an uncaught TypeError, which failed the suite rather than recording what a malformed frame does to a subscription. That is the same rule the crash boundary already holds for a screen and the unhandled-rejection window for a detached effect. The scenario's own faults stay loud: a missing subscribe payload, a params mismatch and a closed stream are all raised outside the caught region. `recorderSha256` therefore moves, so all 679 pre-existing goldens are re-recorded from the pin with this branch's recorder laid over it. Every one of them moves exactly one line and that line is `recorderSha256`: no `adapterSha256`, no `scenarioSha256` and no observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6) Five references over four files leave the raw request port. Each was held out of step 4 because a request-only recorder could not mount it; the recordings landed in the previous commit and no golden moves here. - `use-live-worktree-name.ts` — `worktree.show` inside the focus effect that opens `runtime.clientEvents`. It reuses `sessionWorktreeRecordRead`, which is the diff-comment loader's reader renamed: both consumers read the `worktree` member whole and narrow their own field off it, so a second family would have been a second name for the same wire. The resolution still comes off the raw reply, because `selector_not_found` is what proves the worktree is gone and no acceptance policy carries a refusal code; the skip that follows is the same verdict main's `!response.ok` reached, since a refusal is the only reply this policy declines. - `use-mobile-native-chat-session.ts` — `nativeChat.readSession` in the paging callback. The payload stays whole because the reply is a union: an older runtime answers `{ error }` in place of a window, and the caller discriminates before reading a message list. - `mobile-notifications.ts` — `notifications.unsubscribe` in the `ready` branch of the subscription callback, in its own module rather than beside the push-route sends: one is the route this device holds with a gateway, the other the socket the paired connection holds. - `use-mobile-session-terminal-input.ts` — the gesture flush reuses `terminalInputSend`, which already carried the four other terminal-input call sites and the same accepted-verdict, and the menu's clear gets `terminalBufferClear` beside it. The clear is a skip because main never read the envelope: it toasted success on any fulfilled reply, so only a transport rejection reached the failure toast. That is preserved, not repaired. `mobile-session-route-parity.test.ts` refreshes three pins with their reason: the callback bodies and the twelve nested-function bodies moved where those send expressions were rewritten, and the runtime-string count drops by two because `terminal.send` and `terminal.clearBuffer` are now fixed at their operation's definition instead of spelled at the call site. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the subscription coverage as a checked inventory instead of a README paragraph Every product `client.subscribe` is now an entry in `mobile/src/transport/rpc-subscription-inventory.ts`, classified as recorded (naming its family), an unwritten scenario, or walled with the wall named. `rpc-subscription-boundary.test.ts` fails on a new site with no entry, an entry whose file no longer subscribes, an entry naming a method the file does not open, and a `recorded` entry whose family the scenario manifest does not have. Both the unlisted-site and unresolved-family gates were checked by removing an entry and by misspelling a family; each fails on its own assertion. The paragraph this replaces said nine sites when there were ten. It counted over `mobile/src`, and the host screen's `accounts.subscribe` lives under `app/` — so the scan here covers both roots, the way the raw-port ratchet next door does. Ten sites today: four recorded, two unwritten scenarios, four walled (two on the webview ref, one on the multi-host client context, one on two unsubstituted view members). Unlike the raw-port inventory this list does not count down to zero. A typed operation fixes one method, one acceptance and one reader for one reply; a stream has many, and replacing a subscribe is not what this is asking for. The question it holds is the other one — which stream a golden actually has, and for the rest, what exactly stops it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the notification stream close, which writes nothing to the wire Deleting `unsubscribeStream()` from the notification cleanup — the local close, not the `notifications.unsubscribe` RPC beside it — survived all 810 tests. Neither unsubscribe builder in the stream registry knows `notifications.subscribe`, so closing that stream sends no frame; the mutant leaks a live subscription record instead, and the leak only surfaces when the logical client replays it onto the next session. `notifications-desktop-stream-closed` stops the stream and then cuts over, where the leak becomes a second `notifications.subscribe` payload. Recorded at the pin. No existing golden moves: the new scenario is appended, so it is not the family's matrix base, and every notification matrix site already had a fulfilled reply to replay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the accounts screen's real wall, which is ScrollView and Alert The entry blamed `expo-router.useFocusEffect`, which is substituted, and the inventory's own `use-live-worktree-name` is recorded while importing it. Probed by mounting the screen through the trap: the first refusal is `Unsubstituted native member: react-native.ScrollView`, and `Alert` refuses too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the terminal-send response reader that lost its last caller `isTerminalSendRpcAccepted` read the verdict off a whole envelope, which is what the raw call site did. Both callers now send through an operation and read the admitted payload, so the response form had only its own test left. The three cases move onto `isTerminalSendResultAccepted`, with the refusal envelope's missing result standing in for the failed response. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): attribute a frame crash to the listener that threw, not to the registry The try wrapped `stream.deliver`, so anything the registry raised on its way to the listener was recorded as a `stream-listener-crash` effect and blamed on the product. A reply like `{ok:false}` with no error object throws reaching for `error.message` before any listener runs, and that is a scenario that stopped matching, not an observation. Only the product's own `onData` is wrapped now. The throw is stashed and rethrown unchanged, so the registry still sees it the way a device's message handler does and what it skips after a dead listener stays recorded rather than invented; `frame` reports it only when the error it caught is the one the listener raised. `FrameListenerCrash` is local to the file again. Engine change, so every golden re-records: 694 files, every changed line the `recorderSha256` header, no body movement. Against main the set is 679 modified header-only and the same 15 added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the frame listener stash through a method, not a narrowed field `this.listenerCrash = null` before the try narrows the property to `null` for the rest of `frame`, so the catch compared against `never` and mobile's own `tsc --noEmit` failed. A private taker returns the declared type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): abort a registry throw that stashed nothing, and fold the last native-chat read module in The frame catch compared `crashed?.error !== error`, which is false when nothing was stashed and the registry threw `undefined`, so that abort was swallowed and `frame` reported a clean delivery. It now asks whether a listener crashed at all. Also: `nativeChatSessionPageRead` moves beside the three other `nativeChat.*` reads and its one-export module goes; the session read header names the whole `worktree.show` record rather than review notes; the guarded-listener count is three, not two; the README names the ten subscribing sites blur is unrecorded across; and the gesture flush reads the send verdict as `=== true` like the other four sites. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): drop an oxlint disable the rule never needed `no-throw-literal` is not enabled here, so the directive read as unused and failed the changed-code quality gate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b8d4cde09f |
refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them Five new mount adapters and six scenarios, recorded against the pinned baseline's product code so the goldens are main's behaviour, not the refactor's. Each site is a screen the recorder could not previously mount: - `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is re-exported through `AccountUsage.tsx`. That module loads under the mount loader, so the inventory's "no recording can load it" was already stale. - `notifications.display-test-screen` mounts the settings push probe and presses its button by reading the handler back off the rendered inert `Pressable`. - `aiVault.history-screen` mounts the history panel, which is where the last `worktree.ps` lives. Split in two: the base stops once the worktree list has seeded the scopes, because a reply partition there changes the scopePaths the downstream `aiVault.listSessions` carries, and a matrix variant cannot assert params it moved. The full chain is a second scenario, driven as a pilot only. - `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own `ensureLoaded`, which is the only thing that fires `repo.list`. - `linear.select-workspace-picker` calls the render helper the tasks surface calls and invokes the `onSelect` on the element it returns. The picker draws inside `BottomDrawer`, whose reanimated timing driver and gesture builder the recorder would have to impersonate for a row to exist; the closure is the same either way, and the workspace a selection carries comes from the scenario. Five substitute members are added, each with the recording that reads it: `react-native-safe-area-context.useSafeAreaInsets` and `expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and `react-native.TextInput`, `.SectionList` and `.RefreshControl` for `aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one pinned route for the same reason the window size is pinned: a screen's own address is not a device reading, and the one screen that reads it sends `repo.list`, which takes no params. Touching the substitute table moves `recorderSha256`, so all 641 existing goldens are re-recorded. Recorded from a detached worktree at the pinned baseline with this branch's recorder laid over it: every pre-existing golden is header-only, verified by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted, one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the linear workspace picker's model fixture `mobile/tsconfig.json` covers the recorder, and the fixture's setters were written with the argument the product happens to pass rather than the `SetStateAction` the model declares. Typing them moves `adapterSha256` on the two goldens recorded through this module, so they are re-recorded here rather than in the refactor commit, which must move none. Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix, header-only, bodies unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send six screen-mounted call sites through typed RpcOperations Nine references off the raw request port, across six files. Every one is proven against the goldens recorded in the previous commit from the pinned baseline's product code: this commit moves no file under mobile/rpc-foundation/goldens. Reused rather than redefined: - `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same question, same acceptance — a refused list leaves the screen on what it holds. - `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`. Its policy raises the host's message and its reader takes `repos` off the payload while preserving the property-read exception a null result used to throw at the cast, which is what this call site did by hand. Its name still says new-tab; a third consumer does not make renaming it this bucket's business. Four operations are new, each because no existing reader on the method takes this consumer's input: - `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the explorer. Both skip, because neither refusal is the operation's to decide: the readDir refusal code selects the legacy fallback and the list refusal supplies the message. The existing `files.list-or-skip` reads the `files` member alone, and the explorer also needs `truncated` for the "Showing first 5000" note. - `accounts.home-snapshot-or-skip` for the Home card, decoded by `decodeAccountsSnapshot` at the call site as before. - `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and `method_not_found` refusals mean "try the next desktop". - `linear.select-workspace-or-skip` for the filter sheet. Two behaviours are preserved rather than repaired, both recorded: - The workspace switch never read its reply. `.then(() => loadLinearContext())` runs on a refusal exactly as on a success, so only a transport rejection reaches the error copy. Interpreting the operation here would surface a refused switch for the first time; that is a product change with its own re-record. - `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off its result, so the value is always undefined. It did not migrate, and the inventory now carries the defect as its own note. Four mutants are added, one per new family that admits a state-only one: the Home snapshot, the push test result and the tasks repo list each decoded one level above the envelope, and the workspace switch with its context reload dropped. `aiVault.history-screen` gets none and says why in the suite: everything `worktree.ps` publishes also moves the `scopePaths` the next scripted completion asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence is the reply matrix at that request. The tasks source-parity ratchet moves with the family it guards: hook, statement, declaration, render and style counts are unchanged, and the semantic source is a pure deletion of four lines — two `rpc:` call signatures and the two method literals they carried. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): matrix the six new screen families' replies One golden per scripted reply, eleven partitions each, recorded at the pinned baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`, `repo.list`, `linear.selectWorkspace`, and all three of the history screen's — `worktree.ps` and the two `status.get` reads its scan chains off the worktree list. The history matrix is also that family's defect evidence in place of a mutant: every partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions` carries, and the sender args are recorded with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct three operation and mutant comments Comment-only, no product behaviour and no golden movement. - `worktreeCatalogRead` says two readers; there are three. Names the third (the agent-history panel's `scopePaths` seed) and drops the stale count from the module header, which described call sites rather than the two operations. - `newTabRepoListRead`'s census counted the two operations over `repo.list`, not its own two callers, and claimed both read a workspace's connection id. The tasks route keeps the whole list for its repo pickers. The split from `nativeChatRepoListRead` stays where it belongs: acceptance. - The `aiVault.history-screen` mutant note pointed at the reply matrix as the accepted-vs-refused oracle. Decoding `matrix-aivault.history-screen-worktree.ps-1.json` through the value pool shows `normal`'s projected state is identical to all seven non-crashing partitions (spinner, two labels, zero rows). The real oracles are the next request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the three `inner-*` partitions land in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): give the second files.list reader its real reason Comment-only, no product behaviour and no golden movement. `legacyFileListRead` claimed "the member reader rejects this consumer's input". Nothing rejects: `rpcUncheckedMemberReader` returns the member, and reusing it here would simply drop `truncated`. The reason the explorer declares its own operation is the other direction. Widening `files.list-or-skip` to a payload reader would split the `workspace-files` variant it shares with `nativeChatFileSearchRead` over `files.searchPaths`, whose only caller feeds both through one `extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member read would move into that hook rather than disappear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): indent the six scenario entries spliced during the merge The conflict on `pilot-scenarios.json` was resolved by id rather than by hunk, splicing this branch's six entries into main's text at the array's close. The splice started at the entry's `{` instead of at its line, so those six lines lost their indentation. oxfmt's only change is those six lines; the parsed document is identical, and the recording suite still matches all 667 goldens, so no scenario digest depends on the raw text. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the merged goldens once at the pin One record for the whole merged tree, at the unchanged baseline |
||
|
|
ea7902cbee |
refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4, wave 3) (#20915)
* test(mobile): record the terminal input surface before migrating it (step 4) Three families the recorder could not reach before, recorded against the pinned baseline's product code so the migration that follows has a parity oracle. The device state these hooks read is real, not declared. The pasteboard is the engine's existing per-recording fixture, so a paste reads the bytes a recorded copy put there one action earlier; the buffered draft store is the product's own useBufferedTerminalDrafts mounted in the same tree. No engine file is touched, so no existing golden moves and no header re-digests: 13 new goldens, 641 unchanged. Only the clipboard's text path is driven. The image path decodes a raster through expo-image-manipulator and stages it on expo-file-system, and recording it would mean inventing image and file-system behaviour. Both paths reach the same send. Two family mutants, one per family whose state() can observe a reply: keeping a refused send's draft cleared, and resolving the first repo's connection instead of the workspace's own. The paste family gets none — the hook returns void and calls onSuccess for an accepted and a refused send alike, so its only reply-dependent behaviour is the takeover report, which lives in the sender list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4) Ten references over six files, the last of the raw-port sites whose blocker was that a recording could not reach them. Zero goldens move: every one of the six was recorded first, and the suite replays them against the rewritten code. Two operations are new and four sites reuse one that already fixes their method: - accounts.consumeCodexResetCredit, throw-message, payload unread — the call site's decodeResetResult is one scope-and-snapshot check and splitting it across a reader would put one refusal rule in two places. - notifications.getMissedSince, skip — a background pass with no screen to raise a host message on. The member read stays where the optional chaining was. - repo.list: the accessory's connection lookup joins the new-tab reader, which already threw the host's message; the new-workspace dialog joins the skip reader, which already left the list it had. Same reader, same policies, no new acceptance rule and no third operation on that method. - terminal.send: the composed send, the live keystroke send and the clipboard paste all join terminal.input-send, which the accessory raw send already used and which reads acceptance the same way isTerminalSendRpcAccepted did. The typed contract is stricter than the client's own scope type on the redeem: the catalog pairs each runtime with the distro it may name, while the shared CodexResetCreditExpectedScope does not. The invariant is real and held by the attempt journal's schema, so the narrowing is asserted at the send with that named; the bytes are unchanged. Widening the catalog would be a wire change. Two source-shape ratchets pinned the old call text and move with it. The route parity suite's runtime strings drop from 540 to 537: the three method literals that became operation definitions, and nothing else. Every hook, callback identity, effect, JSX and style pin is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the terminalInputSend and PTY-mode holdout comments `terminalInputSend`'s doc still claimed two call sites. It now has five non-test consumers, all on the same acceptance: the query-reply responder, the live accessory raw send, the session screen's composed draft send and live keystroke send, and the clipboard paste. That comment is where the next person narrowing `object-result-or-null` learns whose lost-ack meaning they are changing, so it names all five and their files. The session inventory block closed with "opens or rides a subscription, or takes its method as a parameter", which no longer covers every holdout below it: `use-mobile-session-terminal-input.ts` is held out for a webview handle. Its own reason also said PTY mode was unavailable in the runner, which this branch's terminal-input adapter contradicts by fixturing the mode map a paste reads. The sentence is narrowed and the holdout restated: PTY mode is recordable, the live webview handle is what is left. Comments only. No product behaviour, no golden, no parity hash moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pair the draft-restore mutant with the refused send `terminal-send-refusal-restores-draft` documents the harm of a refused send that leaves the composed draft cleared, but it was driven by the accepted scenario, where the kill comes from the inverse (a draft restored after a send that landed). The refused scenario shows the documented harm directly: without the restore the input stays empty after the runtime says no. Still one mutant per family, and it kills there — verified by running the suite, `terminal-input-send-refused: kills terminal-send-refusal-restores- draft`. No golden, no product change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the gesture-input holdout and drop a dead repo type Three round-2 corrections, comments and one dead type; no behaviour. The gesture-input holdout claimed a recorder gap that does not exist. The flush path reads refs only — client, connection state, PTY modes, the gesture buckets, active handle and tab type — and the clear-buffer reference optional-chains the webview ref, so a mount with a null terminal ref puts both sends on the wire. The reason now says what is true: those 2 references are migratable as they stand and were out of this PR's bucket. The session summary sentence no longer offers a webview reason. `RuntimeRepoSummary` in mobile-session-route-types.ts lost its last consumer when the accessory hook moved to `MobileRuntimeRepoSummary`; `git grep RuntimeRepoSummary` now finds only the `Mobile`-prefixed type. Deleted. Both refreshed route-parity hashes still credited the `interpretOrThrowRefusalMessage` refresh for their current value. They now state the invariant they pin and this PR's reason for the move: the sends and repo reads inside those bodies name their `RpcOperation` instead of the raw `sendRequest` port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d130347993 |
refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations (#20702)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from |
||
|
|
131d5ab07e |
fix(mobile): reuse current workspace on notification taps (#20310)
* fix(mobile): reuse the current workspace on notification taps * revert(mobile): restore notification setting hint |
||
|
|
341b13cf67 |
Restore mobile push and fix cold-start dismissals (#20068)
* Restore mobile push for delivery validation * fix(mobile): register push task before headless startup * Add authenticated mobile push test and fix iOS release entitlements * Mock push-test transport in notification consent tests * Fix slept workspace test for structured remount result * Fix mobile notification review findings * Pad Android notification icon to prevent square cropping * fix(mobile): present visible Android data pushes in foreground * test: use deterministic clock for teardown deadline * fix(mobile): present foreground pushes through Expo public APIs * fix(mobile): check push eligibility before foreground scheduling * fix(mobile): register push from shared host connection lifecycle |
||
|
|
e187c82678 | Revert mobile push rollout pending delivery investigation (#20040) | ||
|
|
d33354cfd2 |
feat(mobile): receive native push notifications from paired desktops (#19951)
* feat(mobile): deliver native push notifications from paired desktops * fix(mobile): retry push capability probes * fix(mobile): cancel retired push capability probes * fix(mobile): ignore stale push reconciliations * fix(mobile): type capability probe at its boundary * fix(notifications): route mobile push taps to the originating pane * Require explicit mobile push-service consent on upgrade |
||
|
|
d53cbed43f |
revert: hold mobile push feature for user testing (#19203)
Reverts
|
||
|
|
3160b54c69 |
feat: real background push notifications for the mobile app (#8129) (#18554)
* feat(cloud): add the mobile push gateway and its contract package (#8129) A small open-source service that holds the APNs key and FCM credentials and sends background push to paired phones on the desktop's behalf. Hosts authenticate with a box challenge and HMAC proof on their pairing key, the same shape the relay uses, so signed-in and accountless desktops share one path. Tokens are stored; alert text is held only for the coalescing window. The contract doc in docs/reference is the source of truth for every wire shape. The interop test runs the real desktop answerer against a real gateway-issued challenge so transcript drift fails in CI. * feat(push): register phones and send background push from the desktop (#8129) Adds the notifications.remote-push.v1 capability, the registerPush and unregisterPush RPCs on the mobile allowlist, a gateway client with a cached session and 401 re-auth, a durable unregister outbox, and a dispatcher that offers every mobile notification to the gateway after the socket fan-out. The dispatcher is fire-and-forget with one retry and drops registrations the gateway reports dead. Puts agentState on the mobile frame and fixes the #4375 wording so a working agent is never announced as finished. The relay host-proof code moves onto a shared envelope module with no behaviour change. * feat(mobile): background push registration, receive, and settings (#8129) Fetches the native APNs or FCM token, registers it with every paired host that advertises the capability, and re-registers on token change. Foreground pushes are suppressed inside handleNotification against the same seen set the socket path uses, so nothing shows twice. Taps route by host fingerprint. One Background notifications switch, off by default, with the disclaimer and needs-input / finished sub-switches; hidden until a paired desktop is new enough. Adds google-services.json and the expo-notifications plugin. * chore(cloud): Terraform and deploy workflow for the push gateway (#8129) Declares the Cloud Run service, runtime account, secrets, and orca_push database behind push_gateway_enabled, true only in production. The deploy workflow is gated like the relay's, deploys with no traffic, probes /ready and a validate-only FCM send, then shifts traffic. It runs as the shared production deploy account because the Cloud SQL rollout lease grant is foundation-owned; its extra authority is three bindings on the push service. docs/push-gateway.md carries the import commands for the resources created by hand and the APNs key rotation procedure. * docs: describe background notifications on the phone (#8129) * docs: check in the mobile push contract (#8129) Seven committed files cite it as the source of truth for every wire shape; docs/reference is allowlisted per file, so add the entry. * test(push): replay one checked-in host-proof vector on both sides (#8129) Cloud Verify installs only the cloud workspace, so the gateway suite cannot import the desktop answerer. Replace the cross-workspace import with a fixed challenge vector generated from the contract package; the gateway fixture and the desktop answerer each replay it and must produce the same HMAC. A transcript drift on either side now fails in that side's own suite. * fix(cloud): open the push gateway with invoker_iam_disabled, not an allUsers binding (#8129) The production domain-restricted-sharing policy rejects an allUsers run.invoker member, which the runbook anticipated. Opt the service out of invoker IAM the way the relay director already does; the host proof is the authentication either way. * docs(cloud): the push.onorca.dev record exists and is hand-managed (#8129) * fix(push): close review findings in the gateway (#8129) - Quota reservation takes a per-host advisory lock; READ COMMITTED admitted a whole burst past the cap (80/80 without, 60/80 with, against Postgres 16). - Challenge issuance no longer writes push_hosts; the row lands on proof verification. Stale hosts prune after 30 days. Per-IP token bucket on the two unauthenticated routes. - Streaming body limit via hono bodyLimit; a chunked body bypassed the Content-Length check. - registrationIds deduped in the schema; per-host device cap of 64; list bounded to its schema. - Gateway-side challenge TTL is the specified 10 s, not 40 s. - APNs stream settles on close as well as end/error. * fix(push): close review findings in the desktop client (#8129) - A gateway registration the registry cannot persist is enqueued for delete instead of leaking a live token. - Unregister outbox re-reads pending per pass, honours enqueues during a drain, and retries with backoff instead of waiting for the next launch. - Dispatcher batches registrations by 20 rather than starving the rest. - 401 compare-and-clear; a 401 after re-auth is unreachable; refused handshakes and 429s are cached briefly instead of re-handshaking per event. - Service is stopped on quit. * fix(mobile): close review findings in push registration and receive (#8129) - Consent generation guards a register that finishes after the switch went off; the host is re-queued for unregister instead of recorded live. - Foreground pushes seed the watermark before adopting the epoch, so a push on a never-connected session cannot wipe a valid watermark. - aps-environment follows the build via app.config.js; the iOS release workflow sets it to production. A bare plugin entry wrote development. - Pushes the OS showed while closed are marked seen before catch-up replay. - Token null result is not cached; failed capability probes are retried and never block an unregister; coalesced summaries are shown but not marked. - Unresolvable fingerprint routes nowhere and is suppressed in foreground. - Android channel ensured at boot; capability hook diffs clients by identity. * fix(cloud): harden the push deploy workflow and size the gateway to the budget (#8129) - Roll traffic back on a failed post-shift check; delete a candidate that never took traffic; retry the origin probe and the FCM probe. - Assert Terraform-owned scaling instead of mutating it from the workflow. - Build before taking the Cloud SQL rollout lease. - Declare the database pool in Terraform (2 per instance, max 2 instances) and add the gateway to the connection budget; the previous default put the shared instance 65 connections over its ceiling. - State plainly that the shared deploy identity's relay authority is inherited. * fix(push): read the runtime from shared state at push startup (#8129) Threading the runtime through launchDesktopMode put the launch module one line over the 300-line lint budget after the rebase. * fix(push): key the unauthenticated rate limit on the hop Cloud Run wrote (#8129) Cloud Run appends the connecting peer to x-forwarded-for; the limiter read the left-most value, which the caller controls, so a forged first hop earned a fresh bucket per request. * fix(push): close the final security review findings in the gateway and infra (#8129) - app.onError logs only the error name and answers a bare 500; hono's default handler printed the whole error, and a pg error carries the row in detail - a second per-IP bucket (240/min) runs ahead of the bearer lookup on every authenticated route, so forged bearers cannot spend the two-connection pool - one live session per host: minting deletes the host's earlier row - device-less hosts are pruned after 1 h, not 30 d; any keypair mints one free - notificationId is printable ASCII, since it becomes the APNs collapse header - the impersonated FCM probe token is masked in the workflow log - prevent_destroy on the Apple secrets and the orca_push database * fix(push): close the final security review findings in the desktop client (#8129) - fetch never follows a redirect: a 307 would replay the host proof and the phone's token to whatever origin the redirect named - registerPush params are strict and the paired identity is spread last - a per-device bucket (10/min) bounds a phone looping registerPush, which costs a gateway write and a synchronous registry write each time * fix(mobile): close the final security review findings in push receive (#8129) - a push with no epoch can no longer claim a seq-derived dedup key, in the foreground or from the tray; a forged seq:N could otherwise swallow the real bell at that seq - a provider-delivered push with no host catalog, or no fingerprint at all, stays unrouted instead of falling back to the hostId its raw data carries * docs(push): record the ip buckets, session and host retention, and the token-ownership limit (#8129) * fix(push): apply the schema on an untimed pool and retry statement-timeout aborts (#8129) Ports the relay's #18722 pattern to the gateway: DDL runs on a one-connection pool with statement_timeout 0 that is closed before the serving pool opens, and SQLSTATE 57014 joins the bounded transaction retry path. * fix: harden mobile push delivery and deployment recovery * feat: align mobile notification preferences with desktop delivery * fix: accept variable-length APNs device tokens * fix: deduplicate native APNs and background socket notifications |
||
|
|
b521837481 |
fix(mobile): keep hosts visible when credentials are unavailable (#12775)
* fix(mobile): keep hosts visible when credentials are unavailable * fix(mobile): guard unavailable host recovery * fix(mobile): protect replacement credential writes * fix(mobile): retain superseded cleanup intents * fix(mobile): preserve credential cleanup authority * fix(mobile): make host cleanup crash-safe |
||
|
|
885afb55a9 |
fix(mobile): open host editor from root navigation (#12766)
* fix(mobile): open host editor from root navigation * test(mobile): update task navigation router contract |
||
|
|
738f640428 |
fix(mobile): relay UX overhaul — steady status colors, visible relay dials, coordinated deep links (F1-F10)
* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2) Focus/app-resume nudges probe the active relay instead of suspending it; network-change nudges replace it make-before-break, suspending only after a failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of 'disconnected'; the host list keeps last-known worktrees for every not-connected state and spins instead of rendering nothing. * feat(mobile): surface the pairing relay path in the pairing log (F3) The relay candidate was silent during pairing: dialing, E2EE handshake, director recovery, and the winning path now emit redacted phase lines through the same connectOptions.onLog the direct path already used. * docs(mobile): relay UX investigation findings and F0-F10 fix plan * feat(mobile): name and narrate relay dials while they happen (F5) migrateTo forwards the dialing session's connecting/handshaking/reconnecting phases whenever the client is suspended or disconnected — never downgrading a live session — and exposes getPendingPath so the host card can say '· Orca Relay' during the dial instead of only after it. * feat(mobile): race a relay dial when the direct dial stalls (F6) A 2.5s grace timer starts relay recovery while an unauthenticated direct dial is still inside its 12s connect window; the race gets one attempt through the existing mutex/cooldown machinery, cancels when direct authenticates, and never arms for hosts without a relay endpoint. * fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9) A pending status.get used to swap the mounted HostStack for a spinner at the moment the socket connected, destroying in-flight nested navigation. Once children have rendered for a host they stay mounted under an opaque touch-blocking overlay; first visits and blocked verdicts keep the old behavior. * fix(mobile): keep loaded data through transient connection blips (F10) Git history no longer blanks on reconnect (and commit files refetch instead of caching an offline empty answer), the repo picker keeps its last-good list when an in-flight repo.list rejects, the diff review's ready-state preservation actually runs, and proven host capabilities survive a drop flagged unverified instead of being wiped. * feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8) Notification taps, the Accounts card, and host-edit now use the shared mount-then-replace transition (with a focused-route walker so root-layout scope works); the Resume card renders from the snapshot in a disabled state so its late arrival can't shift Tasks under the thumb; resume targets are validated against proven catalog data, and a session route whose worktree the host proves missing bounces to the host index with a notice banner instead of stranding on a dead screen. * test(mobile): cover the resume-target and notice policies (F7) Key notice dismissal by code so closing one banner cannot swallow a later, different one, and move the visibility rule into host-route-notice.ts where it is testable without a screen. Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing (unproven catalog is silence, synthetic routes exempt), the validating last-visited reader, and the notice visibility rule. * fix(mobile): review-pass hardening for the gate overlay and diff preservation Adversarial review findings: the reader's hunk position now survives a connection blip (reset only on item change), the covered stack is hidden from TalkBack while the gate overlay is up, and the overlay's hit-test comment is scoped honestly to in-tree views (native-Modal drawers present above it — follow-up). * fix(mobile): CI + CodeRabbit review fixes for #12609 Move the findings doc under docs/ (root directory guard), drop two unused eslint-disable directives, and address review findings: an unproven snapshot seed can no longer downgrade a proven worktree catalog; a locally-aborted relay dial skips the director fallback; post-migration bookkeeping failures log instead of masquerading as dial failures (which could suspend the healthy session); the auth wait arms its timeout before subscribing; forwarded dial phases stop at close(); the legacy selector_not_found fallback requires runtime_error; the diff-loading effect depends on the fields it reads; and host-edit auto-cancellation is now pinned by a test. * fix(mobile): second review round — queued replacements, race fence, confirmed bounces A network-change replacement now survives the recovery mutex and cooldowns as a queued intent instead of being dropped or suspending a healthy session — only a failed dial or a dead probe tears one down. The happy-eyeballs migration withdraws when direct authenticated during the relay dial (first-authenticated-wins). A worktree bounce requires two consecutive host-proven misses, since a transient desktop repo-scan rejection answers selector_not_found for a live worktree. Background network flaps no longer wake a billed relay splice, the lifecycle foreground flag stays in sync, a screen unmount cancels only its own pending host-stack transition, and diff review keeps the loaded review when its reconnect refresh rejects. Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible pass, and split the supervisor nudge tests, to stay under max-lines. * fix(mobile): satisfy the React Doctor changed-code gate Render-phase ref writes move into effects: the protocol gate's resolved/mounted latches now record committed outcomes only (a discarded children render can no longer count as mounted), and the bounce hook syncs its callback ref in an effect. Array<T> annotations become T[] in the extracted modules. * fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10) The diff-loading hook's catch was the one path still erasing a ready diff — the same keepLoadedDiff guard its disconnect and loading branches already use, now pinned by a reject-after-ready test. * fix(mobile): process foreground revival nudges --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
3a67186623 |
fix: stop notification loss, credentialed cache reuse, and clipboard clobber (#11230)
* 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> |
||
|
|
d547e278f9 |
fix(mobile): deliver the notifications a reconnect missed, and never persist a watermark past them (#10816)
* 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> |
||
|
|
aab112933e |
Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
8f40ddf328 | fix(memory): bound OOM-prone accumulators (#10179) | ||
|
|
c6f0ac4040 |
refactor(comments): slim verbose comments in mobile (#9547)
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>
|
||
|
|
d9f7fd08f7 | fix(mobile): harden notification opt-in onboarding (#8792) | ||
|
|
53c8a55833 |
Add mobile notification opt-in onboarding (#8780)
* feat(mobile): add notification opt-in onboarding * fix(mobile): deliver alerts despite desktop focus |
||
|
|
8e0977d295 |
fix(mobile): resync worktree list + idempotent notification replay on reconnect (#8498 #8129)
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). |
||
|
|
54c4959830 |
perf(mobile): cap the scheduled-notification map + tap-dedup set (#7646)
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. |
||
|
|
c9ab6018da |
Fix duplicate mobile notifications (#5206)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
8959535f1b |
Auto-dismiss agent notifications on acknowledgement (#4518)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
0bb62b9d29 | Add braces to mobile control flow (#4351) | ||
|
|
be2dfe4bac | fix: drop mobile notification stream on cleanup (#2887) | ||
|
|
9562d9776f |
Fix mobile notification tap routing (#1871)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
9c24425504 |
fix(mobile): handle notification permission changes (#1506)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
fc578f5ea9 |
feat(mobile): Expo companion app [beta] (#1245)
Co-authored-by: Orca <help@stably.ai> |