Skip to content

ios: back the device list with the cross-device sync DO (durable across updates) - #6496

Closed
lawrencecchen wants to merge 13 commits into
mainfrom
feat-ios-device-list-sync
Closed

lawrencecchen wants to merge 13 commits into
mainfrom
feat-ios-device-list-sync

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor

Problem

After updating the TestFlight build, the saved-devices list went empty. Root cause (traced in code): deviceTreeDevices (MobileShellComposite.swift) is registry XOR local — it returns the in-memory registryDevices list entirely when non-empty, else synthesizes from local paired Macs. registryDevices is refetched from GET /api/devices each launch and is never persisted. So the moment the registry returns any non-empty-but-incomplete set, locally-saved Macs are masked out, and nothing durable backs the list across an update or surfaces it on another device.

Fix: wire the already-built cross-device sync

The server side is live (the cmux-presence DO derives a per-team devices collection from Mac heartbeats and serves it over the presence WebSocket via sync/v1), and the iOS CmuxSyncStore package is built + tested — it was just never imported by the app. This is the deferred "Phase 2: iOS app wiring" from plans/feat-do-device-list/DESIGN.md.

  • CmuxMobileShell + cmuxFeature now depend on CmuxSyncStore; the root scene opens cmux-sync.sqlite3 next to paired-macs.sqlite3 (Application Support → survives updates) and injects it plus the resolved mobileDeviceListLocalFirst flag.
  • New PresenceSyncTransport: a SyncTransport over its own authenticated /v1/presence/subscribe WebSocket, isolated from the live presence stream (online dots / route-push / auto-attach untouched). SyncClient ignores the presence noise on the shared endpoint.
  • MobileShellComposite runs a sync subscription mirroring the presence backoff loop, seeds provisional rows from the existing paired-Mac store via PairedMacMigration on first launch (instant render pre-frame; offline Macs never lost; reconciliation exempts rev == 0), and reads the list from DeviceSyncFacade when the flag is on.

Cross-device: the Mac already publishes itself to the team DO, so a fresh sign-in on another device subscribes and renders it with zero setup.

Safety: gated mobileDeviceListLocalFirst DEBUG-on/Release-off; flag OFF keeps today's /api/devices path verbatim. Production list behavior is unchanged until the flag is flipped after dogfood.

Tests (all green via swift test)

  • Device records survive a fresh store on the same DB file (the app-update simulation), with cursor/epoch persisted.
  • A provisional seed renders via the facade AND survives a DO snapshot that omits it.
  • A devices snapshot from another device streams in over the transport and renders locally.
  • The composite reads the sync store when the flag is on and ignores it when off.
  • (Pre-existing MobileShellRenderGridLivenessTests fail identically on clean main; unrelated.)

Dogfood

Build on dog (mac + iOS) follows. Verify: list survives an over-the-top reinstall, and a Mac appears on a second signed-in device with no setup.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches session-scoped device list, multi-account privacy, and sync cursor semantics; behavior is flag-gated in Release but the wiring paths are complex and race-sensitive.

Overview
Wires the existing CmuxSyncStore into the iOS app so the device tree can load from a durable SQLite cache and sync/v1 over presence, instead of refetching GET /api/devices every launch. mobileDeviceListLocalFirst (DEBUG on, Release off) keeps production on the registry path until dogfood.

The composition root opens cmux-sync.sqlite3 next to paired Macs and injects store, team resolver, and PresenceSyncTransport (separate authenticated presence WebSocket). MobileShellComposite mirrors the presence loop: backoff subscription, PairedMacMigration seed, and loadRegistryDevices reading DeviceSyncFacade when flag + transport are wired—with registry fallback when the store is empty and cursor == 0, and an authoritative empty when cursor > 0 (no stale registry or paired-Mac XOR resurrection).

DeviceSyncFacade.registryDevices now filters rev == 0 provisional rows by provisionalOwnerUserID (nil owner fails closed); rev >= 1 rows stay team-shared. Sign-out tears down the sync task without clearing the on-disk cache; reads enforce account/team guards against stale async results.

Reviewed by Cursor Bugbot for commit 2c79ecf. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Backed the iOS device list with a durable, cross-device sync store so saved Macs survive updates and show on other signed-in devices. Behavior stays unchanged behind mobileDeviceListLocalFirst (DEBUG on, Release off).

  • New Features

    • Wired CmuxSyncStore into CmuxMobileShell/cmuxFeature; opens cmux-sync.sqlite3 in Application Support.
    • Added PresenceSyncTransport on a dedicated authenticated /v1/presence/subscribe for sync/v1 with bufferingOldest and clean reconnects.
    • MobileShellComposite runs an off‑main backoff sync loop, seeds via PairedMacMigration, and reads via DeviceSyncFacade when the flag is on.
  • Bug Fixes

    • Coherent gating: local‑first read now also requires a transport; empty store falls back to /api/devices; once cursor > 0, an empty list is authoritative.
    • Team/account safety: provisional (rev==0) rows are owner‑scoped and fail closed on unknown owner; authoritative rows are team‑shared; authoritative‑empty is team‑scoped; restarts on team switch; stale‑team frames cannot overwrite the current list.
    • Robustness: sync runs off‑main; socket no longer retains the shell store; stale‑read guards after awaits prevent blanking on session changes.
    • Build/docs: fixed typed makeSyncTransport closure to unblock cmuxFeature build, added DocC for PresenceSyncTransport, and updated Package.resolved.

Written for commit 2c79ecf. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a durable local-first device list sync path that loads cached devices immediately and applies streamed updates.
    • Introduced presence WebSocket sync transport for incremental device updates with drop-handling to safely reconnect.
    • Updated device list handling so an authoritative empty snapshot won’t fall back to network results.
  • Bug Fixes
    • Fixed provisional device visibility so rev==0 devices only appear for the matching provisional owner, while authoritative devices remain unaffected.
  • Tests
    • Added/expanded coverage for persistence across reopen, cross-device snapshot application, update-gap behavior, legacy non-resurrection, and safe team switching.
  • Chores
    • Wired the shared sync store into the iOS packages and feature targets.

…vives updates)

Wires the already-built-but-dormant CmuxSyncStore into the iOS app so the device
list is a durable, locally-cached, DO-synced collection instead of a volatile
per-launch GET /api/devices read masked by an all-or-nothing fallback.

Root cause of the lost-devices-on-update bug: deviceTreeDevices was registry XOR
local (returns the registry list entirely if non-empty, else local paired Macs),
and the registry result is in-memory and refetched each launch. Any saved Mac the
registry did not currently list was masked out, and nothing durable backed the
list across an update.

This is the deferred Phase-2 wiring from plans/feat-do-device-list/DESIGN.md
(server + package shipped; app integration was the gap):
- CmuxMobileShell + cmuxFeature now depend on CmuxSyncStore; the root scene opens
  cmux-sync.sqlite3 next to paired-macs.sqlite3 (Application Support, survives
  updates) and injects it + the resolved mobileDeviceListLocalFirst flag.
- New PresenceSyncTransport: a SyncTransport over its own authenticated
  /v1/presence/subscribe WebSocket (isolated from the live presence stream), so
  sync/v1 snapshot/delta frames ride alongside presence noise the client ignores.
- MobileShellComposite runs a sync subscription mirroring the presence backoff
  loop, seeds provisional rows from the existing paired-Mac store via
  PairedMacMigration on first launch (so the list renders instantly pre-frame and
  an offline Mac is never lost), and reads the device list from DeviceSyncFacade
  when the flag is on. Flag OFF keeps today's /api/devices path verbatim.

Cross-device: the Mac already publishes its record to the team's DO via presence
heartbeat, so a fresh sign-in on another device subscribes and renders it with no
setup. Gated DEBUG-on/Release-off; production list behavior is unchanged until
the flag is flipped after dogfood.

Tests (swift test, all green): device records survive a fresh store on the same
file (the update simulation); a provisional seed renders and survives a snapshot
that omits it; a snapshot from another device streams in and renders; the
composite reads the sync store when the flag is on and ignores it when off.
(Pre-existing MobileShellRenderGridLivenessTests fail identically on clean main.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 20, 2026 11:51am
cmux-staging Building Building Preview, Comment Jun 20, 2026 11:51am

@coderabbitai

coderabbitai Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a local-first device-list sync path on iOS. It adds a new PresenceSyncTransport WebSocket actor, wires CmuxSyncStore as a dependency into two package manifests, extends MobileShellComposite with a full sync subscription lifecycle and a modified loadRegistryDevices branch, and threads these through CMUXMobileRootScene.makeStore. Tests cover flag gating, store survival across reopens, and cross-device snapshot application.

Changes

Local-first device-list sync

Layer / File(s) Summary
Package manifests
Packages/iOS/CmuxMobileShell/Package.swift, ios/cmuxPackage/Package.swift
Adds CmuxSyncStore to the CmuxMobileShell and cmuxPackage SPM manifests (package-level dependency, main target, and test target dependencies).
PresenceSyncTransport WebSocket actor
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift
Implements PresenceSyncTransport: an actor that lazily opens an authenticated WebSocket, sends frames as UTF-8 strings, and exposes inbound frames via a bounded AsyncThrowingStream that terminates with updatesDropped on buffer overflow to prevent cursor gaps.
CMUXMobileRootScene: sync store and factory wiring
ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift
Imports CmuxSyncStore, adds a syncStore property initialized via openSyncStore() in both iOS and non-iOS init paths, and extends makeStore() to resolve the deviceListLocalFirst feature flag, build syncTeamIDProvider from the auth coordinator, construct a makeSyncTransport closure backed by PresenceSyncTransport, and thread all four sync parameters into CMUXMobileShellStore (both DEBUG and non-DEBUG branches).
MobileShellComposite: init parameters and lifecycle wiring
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Imports CmuxSyncStore, adds four private stored properties (syncStore, deviceListLocalFirst, syncTeamIDProvider, makeSyncTransport), extends the public initializer with matching parameters, wires evaluateSyncSubscription() into isSignedIn changes and resumeForegroundRefresh(), and cancels syncTask in deinit.
MobileShellComposite: loadRegistryDevices local-first branch and sync loop
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Adds sync state holders (syncTask, syncSubscriptionTeamID, seededSyncTeams). Adds an early-return branch in loadRegistryDevices() for the local-first sync-store read, with cursor-based authoritative-empty logic. Modifies deviceTreeDevices to return empty in local-first mode. Implements evaluateSyncSubscription(), startSyncSubscription() (exponential backoff with one-time paired-Mac seed), makeSyncSubscriptionClient(), syncStoreDevices(teamID:) with sign-in guards and sorting, and reloadDeviceListFromSyncStore(teamID:) to update the device tree after frame application.
DeviceSyncFacade: provisional owner filtering
Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift
Extends registryDevices(teamID:) to accept an optional provisionalOwnerUserID parameter; filters rev==0 (provisional) records by owner and excludes them when provisionalOwnerUserID is nil, while keeping authoritative (rev>=1) records visible to all.
Wiring and store survival tests
Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift, Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift
DeviceListLocalFirstWiringTests validates the composite reads from sync store when the flag is on, falls back to registry when off or empty, respects team boundaries, and does not resurrect legacy paired-mac devices in local-first mode, with test helpers for seeded stores and scripted device registries. DeviceListSyncWiringTests covers cursor/epoch survival across store reopens, migration-seeded provisional device persistence through omitting snapshots, and cross-device snapshot application via a ScriptedTransport test double with owner-scoped visibility validation.

Sequence Diagram(s)

sequenceDiagram
  participant CMUXMobileRootScene
  participant MobileShellComposite
  participant PresenceSyncTransport
  participant SyncClient
  participant CmuxSyncStore
  participant DeviceTree

  CMUXMobileRootScene->>CMUXMobileRootScene: openSyncStore() → SQLite db
  CMUXMobileRootScene->>MobileShellComposite: init(syncStore, deviceListLocalFirst, syncTeamIDProvider, makeSyncTransport)

  rect rgba(70, 130, 180, 0.5)
    Note over MobileShellComposite: isSignedIn = true / resumeForegroundRefresh
    MobileShellComposite->>MobileShellComposite: evaluateSyncSubscription()
    MobileShellComposite->>PresenceSyncTransport: makeSyncTransport(teamID)
    MobileShellComposite->>CmuxSyncStore: seedProvisional(paired-Mac data) [once]
    MobileShellComposite->>SyncClient: run(transport: PresenceSyncTransport)
  end

  PresenceSyncTransport-->>SyncClient: inbound frame (AsyncThrowingStream)
  SyncClient->>CmuxSyncStore: apply frame
  SyncClient-->>MobileShellComposite: frame applied callback
  MobileShellComposite->>CmuxSyncStore: reloadDeviceListFromSyncStore(teamID)
  CmuxSyncStore-->>MobileShellComposite: [SyncedDeviceRecord]
  MobileShellComposite->>DeviceTree: update sorted device list

  Note over MobileShellComposite: loadRegistryDevices() early-returns from CmuxSyncStore when flag on
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#5648: Both PRs touch the same device-tree plumbing in MobileShellComposite—specifically loadRegistryDevices()/deviceTreeDevices—with the retrieved PR adding the registry tree + DeviceRegistryService.listDevices()/parseDeviceList, while the main PR layers local-first durable CmuxSyncStore syncing (and provisional-owner filtering) into that same flow.
  • manaflow-ai/cmux#6120: Main PR extends Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift by changing registryDevices(teamID:) to accept provisionalOwnerUserID and filter rev==0 provisional rows, building directly on the retrieved PR's initial DeviceSyncFacade/registryDevices implementation.

Poem

🐇 Hop hop, the store is durable now,
No more fetching from the cloud somehow!
A WebSocket hums, frames bounce in and out,
The device list lives — without a doubt.
Local-first sync: the rabbit's new art,
SQLite tucked snug in the iOS heart! 🗄️


Important

Pre-merge checks failed

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

❌ Failed checks (6 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error PR introduces ContinuousClock().sleep() for retry backoff in startSyncSubscription(), violating the rule disallowing Task.sleep and timing-based synchronization in production for retry backoff. Replace sleep-based backoff with a timer abstraction, async sequence, notification, or state-transition mechanism as specified in swift-blocking-runtime.md rules.
Cmux Expensive Synchronous Load ❌ Error PR adds openSyncStore() that performs synchronous SQLite3 initialization (with PRAGMA execution) in CMUXMobileRootScene.init(), an implicit @MainActor context, violating rule requirement against ex... Move CmuxSyncStore() initialization off the main thread—open it during app launch (e.g., in Task.detached) or lazily when first accessed, similar to how the sync subscription loop runs off-main. Ensure initialization does not block scene...
Cmux Swift Concurrency ❌ Error PR introduces two fire-and-forget Tasks with meaningful lifecycle: socket cleanup in PresenceSyncTransport.frames() onTermination and database deletion in MobileShellComposite.disconnectAndForgetAc... Store cleanup Tasks or use async let patterns to ensure proper lifecycle management; for socket cleanup, consider using withoutActuallyEscaping or storing the cleanup task reference.
Cmux Swift File And Package Boundaries ❌ Error PR violates the 250-line addition threshold: MobileShellComposite.swift is already 5,834 lines (over 800), and +245 lines exceed the limit. Additionally, independently-testable device-list sync log... Extract device-list sync (evaluateSyncSubscription, startSyncSubscription, syncStoreDevices, reloadDeviceListFromSyncStore) into a small CmuxMobileSyncDevice package with SyncClient wiring, reducing MobileShellComposite below the 250-lin...
Cmux Swift Logging ❌ Error MobileShellComposite.swift (@MainActor class) and CMUXMobileRootScene.swift (View struct) declare file-scoped Loggers as private let instead of nonisolated private let, violating swift-logging.... Add nonisolated keyword to Logger declarations: nonisolated private let mobileShellLog and nonisolated private let mobileRootSceneLog.
Cmux User-Facing Error Privacy ❌ Error Error-level logging in CMUXMobileRootScene exposes SQLite error messages with privacy: .public, making database/migration details accessible via Console.app, violating user-facing error privacy rules. Change mobileRootSceneLog.error() to .debug() or redact SQLite details with privacy: .private for error logs that persist beyond debugger sessions.
Docstring Coverage ⚠️ Warning Docstring coverage is 44.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (16 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: backing the iOS device list with cross-device sync and durability across updates via the sync DO.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Production code introduces no implicit MainActor models, properly isolates sync loop off-main with weak-self pattern and explicit MainActor boundaries, marks new protocols (CmuxSyncStoring, SyncTra...
Cmux Cache Substitution Correctness ✅ Passed PR properly handles cache substitution correctness: cold cache guarded by cursor==0 check with fallback to /api/devices; stale cache prevented by event-driven sync loop that continuously updates st...
Cmux No Hacky Sleeps ✅ Passed This PR modifies only Swift source files; the runtime-no-hacky-sleeps rule applies exclusively to TypeScript, JavaScript, shell, and non-Swift build/runtime scripts. Swift timing primitives are cov...
Cmux Algorithmic Complexity ✅ Passed PR does not violate algorithmic complexity rules. Sorting in deviceTreeDevices is pre-existing code from main branch; new sync-store reads use single-pass compactMap (no nested scans); pairedMacs i...
Cmux Swift @Concurrent ✅ Passed All async functions and actor-crossing work comply with the @concurrent rules: PresenceSyncTransport.frames() is correctly nonisolated; off-main sync work runs in Tasks not marked @MainActor; @Main...
Cmux Swiftpm Lockfiles ✅ Passed CmuxSyncStore is a local path dependency with only local transitive dependencies; adding it to Package.swift files doesn't create new external pins, so Package.resolved updates are not required per...
Cmux Full Internationalization ✅ Passed No user-facing Swift text, web UI, or API response changes found. Production code uses Logger (operational, not user-visible), pre-existing L10n.string() for UI, and test files (exempt). No localiz...
Cmux Swiftui State Layout ✅ Passed No SwiftUI state layout violations found. All new properties are private let dependencies or state management (Task, boolean flags) in non-View contexts. CMUXMobileRootScene properly injects syncSt...
Cmux Architecture Rethink ✅ Passed PR implements required platform bridge (WebSocket subscription with exponential backoff) following established presence pattern; uses weak-self to prevent store retention; state vars manage lifecyc...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR does not add or materially change NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup. Changes are device-list sync infrastructure, test fixtures, and package configuration—all...
Cmux Source Artifacts ✅ Passed All 8 changed files are legitimate hand-written Swift source code, test files, and package configuration—no local tool output, build artifacts, cache files, or scratch directories detected.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No new test or debug seams were added to production source. Pre-existing ForTesting methods in MobileShellComposite were not added by this PR; #if DEBUG blocks gate real product features (diagnosti...
Description check ✅ Passed PR description comprehensively covers the problem statement, implementation approach, testing methodology, and safety mechanisms.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-device-list-sync

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

❤️ Share

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

Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 05bddfeeef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1508 to +1512
let teamID = (await syncTeamIDProvider?()) ?? ""
if !teamID.isEmpty {
await reloadDeviceListFromSyncStore(teamID: teamID)
}
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back when the sync cache is unavailable

When mobileDeviceListLocalFirst is enabled but the DO is unavailable or the cache has not been populated yet (for example, the root scene leaves makeSyncTransport nil because no presence URL resolves), this branch still returns after the local read and never executes the existing /api/devices fallback below. That hides registry-only/cross-device Macs and leaves users with only the local paired-Mac fallback instead of today's durable registry list; fall through to deviceRegistry.listDevices() when the sync cache is empty or no sync transport is available.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

Inline comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1687-1689: The onApplied closure triggers a full sync-store read
and sort via reloadDeviceListFromSyncStore for every applied frame, causing
inefficient repeated device collection rebuilds on the main actor during
snapshot or delta bursts. Replace the immediate reloadDeviceListFromSyncStore
call with a coalesced approach that batches reloads per run-loop tick or
debounces multiple frame applications, or alternatively modify the sync store
facade to return pre-sorted device lists once per committed batch rather than on
every individual frame application event, in accordance with the guideline that
socket-stream event paths should not rebuild large user-owned collections on
every event.
- Around line 1508-1512: The device list reloading logic has a race condition
where stale data from old async operations could be assigned after an
account/team switch. Capture both the expected user and teamID before awaiting
reloadDeviceListFromSyncStore, then verify both match the current state before
assigning any results to registryDevices. Additionally, when teamID resolution
returns empty, explicitly clear or reset registryDevices rather than leaving the
previous state visible. Apply the same guarding pattern to the related onApplied
callbacks and loadRegistryDevices continuation handlers to ensure all
local-first device reads properly validate the captured account and team
context.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift`:
- Around line 88-105: The connectedTask() function can create and cache a stale
websocket if cancellation occurs during the await of tokenSource.accessToken().
To fix this, after creating the URLSessionWebSocketTask with
session.webSocketTask(with: request) and before calling task.resume() and
caching it with self.task = task, add a call to Task.checkCancellation() to
ensure the current task has not been cancelled. This will prevent a stale socket
from being stored when the stream is torn down during token retrieval.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b2b9d72-540b-41bc-ad10-a22368ffcf97

📥 Commits

Reviewing files that changed from the base of the PR and between bbed84b and 05bddfe.

📒 Files selected for processing (7)
  • Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift
  • Packages/iOS/CmuxMobileShell/Package.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift
  • ios/cmuxPackage/Package.swift
  • ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift

Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment on lines +88 to +105
private func connectedTask() async throws -> URLSessionWebSocketTask {
if let task { return task }
guard let url = PresenceClient.subscribeURL(serviceBaseURL: serviceBaseURL) else {
throw PresenceClientError.invalidServiceURL
}
guard let accessToken = await tokenSource.accessToken() else {
throw PresenceClientError.notAuthenticated
}
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
if !teamID.isEmpty {
request.setValue(teamID, forHTTPHeaderField: "X-Cmux-Team-Id")
}
let task = session.webSocketTask(with: request)
task.resume()
self.task = task
return task
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against cancellation before caching the websocket.

If the stream is torn down while connectedTask() is still awaiting the token, the websocket can still be created and stored after onTermination has already run. That leaves a stale socket behind and can poison the next reconnect attempt.

Proposed fix
         let task = session.webSocketTask(with: request)
         task.resume()
+        if Task.isCancelled {
+            task.cancel(with: .goingAway, reason: nil)
+            throw CancellationError()
+        }
         self.task = task
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift`
around lines 88 - 105, The connectedTask() function can create and cache a stale
websocket if cancellation occurs during the await of tokenSource.accessToken().
To fix this, after creating the URLSessionWebSocketTask with
session.webSocketTask(with: request) and before calling task.resume() and
caching it with self.task = task, add a call to Task.checkCancellation() to
ensure the current task has not been cancelled. This will prevent a stale socket
from being stored when the stream is torn down during token retrieval.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Wires the already-built CmuxSyncStore into the iOS app so the device list is backed by a durable SQLite cache (cmux-sync.sqlite3 in Application Support) populated via a dedicated sync/v1 presence WebSocket, fixing the TestFlight regression where the list went empty after an update. The behavior is fully flag-gated (mobileDeviceListLocalFirst DEBUG-on/Release-off), leaving production on the existing /api/devices path until dogfood.

  • MobileShellComposite gains a sync subscription loop mirroring the presence backoff pattern: PairedMacMigration seeds provisional rows on first launch, DeviceSyncFacade reads them with owner-scoping for provisional (rev==0) rows, and the authoritative-empty gate suppresses the legacy paired-Mac XOR fallback once the DO has synced (cursor > 0).
  • PresenceSyncTransport opens its own authenticated presence WebSocket pinned to one team ID, using bufferingOldest(256) to preserve a contiguous frame prefix and triggering a reconnect on any buffer drop.
  • CMUXMobileRootScene opens the store at the composition root, resolves the flag and team provider, and injects them alongside the existing pairedMacStore — with a clean nil-fallback to the registry path if the store fails to open.

Confidence Score: 3/5

Safe to merge with the flag off (Release default), but the sync code path has three known wiring issues that should be resolved before enabling in production.

The MobileShellComposite class is @MainActor-isolated, so syncTask = Task { [weak self] in … try await client.run() } inherits that isolation — contrary to the NOT @mainactor comment. Every received frame drives SQLite writes (SyncFrameApplier.apply) and a read (reloadDeviceListFromSyncStore) on the main actor; the initial snapshot batch can contain many device records and will block the UI event loop for each transaction. Two additional issues — the fire-and-forget Task { await self.close() } in PresenceSyncTransport.onTermination and the untracked sign-out store-clearing task in MobileShellComposite — were flagged in prior review rounds. All three are gated behind mobileDeviceListLocalFirst (Release-off), so today's production behavior is unaffected, but they need to be addressed before the flag is flipped in dogfood.

MobileShellComposite.swift — the sync task executor and sign-out clearing path; PresenceSyncTransport.swift — the onTermination cleanup task.

Important Files Changed

Filename Overview
Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift registryDevices now filters provisional (rev==0) rows by provisionalOwnerUserID, failing closed on nil owner; authoritative rows remain team-shared. Logic is correct and well-tested.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Wires sync store + transport into the shell. The sync Task is created in a @mainactor context so client.run() holds the main actor — contrary to the NOT @mainactor comment. Sign-out store clearing is fire-and-forget. Both issues flagged in prior reviews.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift New SyncTransport actor over a dedicated authenticated presence WebSocket. bufferingOldest(256) policy is correctly reasoned. The Task { await self.close() } fire-and-forget in onTermination was flagged in a prior review thread.
ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift Composition root opens cmux-sync.sqlite3, resolves the flag, builds the transport factory, and injects all three into the composite with clean nil-fallback.
Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift Comprehensive scenario coverage using @testable import for internal state access. No production seams introduced.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Scene as CMUXMobileRootScene
    participant Composite as MobileShellComposite
    participant Transport as PresenceSyncTransport
    participant Store as CmuxSyncStore
    participant Facade as DeviceSyncFacade
    participant DO as cmux-presence DO

    Scene->>Store: openSyncStore()
    Scene->>Composite: inject(syncStore, makeSyncTransport, deviceListLocalFirst)
    Note over Composite: isSignedIn -> evaluateSyncSubscription()
    Composite->>Composite: startSyncSubscription()
    Composite->>Store: "PairedMacMigration seed rev==0 rows"
    Composite->>Transport: makeSyncTransport(teamID)
    loop backoff reconnect 1s..60s
        Transport->>DO: WSS /v1/presence/subscribe
        DO-->>Transport: sync.snapshot / sync.delta
        Transport-->>Composite: frames()
        Composite->>Store: applySnapshot / applyDelta
        Composite->>Facade: registryDevices(teamID, owner)
        Facade-->>Composite: [RegistryDevice]
    end
    Note over Composite: loadRegistryDevices()
    alt store non-empty
        Composite->>Composite: return sync devices
    else "cursor > 0 authoritative empty"
        Composite->>Composite: "registryDevices = []"
    else "cursor == 0"
        Composite->>Composite: fall through to /api/devices
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Scene as CMUXMobileRootScene
    participant Composite as MobileShellComposite
    participant Transport as PresenceSyncTransport
    participant Store as CmuxSyncStore
    participant Facade as DeviceSyncFacade
    participant DO as cmux-presence DO

    Scene->>Store: openSyncStore()
    Scene->>Composite: inject(syncStore, makeSyncTransport, deviceListLocalFirst)
    Note over Composite: isSignedIn -> evaluateSyncSubscription()
    Composite->>Composite: startSyncSubscription()
    Composite->>Store: "PairedMacMigration seed rev==0 rows"
    Composite->>Transport: makeSyncTransport(teamID)
    loop backoff reconnect 1s..60s
        Transport->>DO: WSS /v1/presence/subscribe
        DO-->>Transport: sync.snapshot / sync.delta
        Transport-->>Composite: frames()
        Composite->>Store: applySnapshot / applyDelta
        Composite->>Facade: registryDevices(teamID, owner)
        Facade-->>Composite: [RegistryDevice]
    end
    Note over Composite: loadRegistryDevices()
    alt store non-empty
        Composite->>Composite: return sync devices
    else "cursor > 0 authoritative empty"
        Composite->>Composite: "registryDevices = []"
    else "cursor == 0"
        Composite->>Composite: fall through to /api/devices
    end
Loading

Reviews (7): Last reviewed commit: "ios: fix cmuxFeature compile (typed make..." | Re-trigger Greptile

Comment on lines +81 to +84
continuation.onTermination = { _ in
pump.cancel()
Task { await self.close() }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Fire-and-forget Task for actor cleanup in onTermination

Task { await self.close() } is an unstructured, fire-and-forget task spawned from a @Sendable closure with no cancellation token and no structured lifetime. The cmux rule flags fire-and-forget Tasks with meaningful lifecycle, and close() is meaningful: it cancels the live URLSessionWebSocketTask and nils the stored reference on the actor. If the PresenceSyncTransport actor is deallocated before the pump task fires, self is still captured strongly by the closure, preventing deallocation until the task completes. Additionally, if multiple stream terminations overlap (e.g., rapid reconnects), each spawns an independent close task with no coordination. The canonical fix is to have the pump task itself call await self.close() after its loop exits (the pump is already Task-tracked via continuation.onTermination = { _ in pump.cancel() }) — just add await self.close() at the bottom of the pump's do block after the loop.

…h safety

Autoreview found two real holes:
1. The local-first read swap gated on (flag && syncStore) but the sync
   subscription gates on makeSyncTransport != nil; enabling the flag without a
   presence URL bypassed /api/devices with nothing populating the store. Now the
   read swap requires makeSyncTransport too, and an empty store falls through to
   the registry instead of showing an empty tree.
2. The sync task pinned a team for a long-lived socket; a team switch could let
   an old-team frame overwrite the current list, and the seed never re-ran for
   the new team. reloadDeviceListFromSyncStore now drops a result whose team no
   longer matches the resolved team; the seed is tracked per-team; and
   loadRegistryDevices restarts the subscription when the resolved team changes.

Tests: flag-on-without-transport falls back to registry; empty store falls back
to registry; a stale team-A frame does not overwrite team-B's list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ated tree

The empty-store fallback called reloadDeviceListFromSyncStore (which assigns
registryDevices = [] for an empty store) before falling through to the registry.
A transient registry failure is a deliberate no-op meant to KEEP the current
tree, so the pre-clear surfaced as a blank tree on a blip. Split the read into a
non-mutating syncStoreDevices() the launch path uses (assign only when non-empty,
else fall through without clearing); reloadDeviceListFromSyncStore still assigns
unconditionally for the apply-callback path, where an empty result is
authoritative (a delta tombstoned the last device).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

1630-1758: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Move the device-list sync loop out of the composite.

This adds WebSocket lifecycle, migration seeding, sync applier setup, persistence reads, and list projection to a >800-line store that already owns connection, terminal, draft, presence, and pairing state. Extract this block into a small same-package coordinator that reports ordered device snapshots back to MobileShellComposite.

As per coding guidelines, production Swift files over 800 lines and files mixing state ownership, persistence, networking, and UI projection should be flagged.

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

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1630 - 1758, Extract the device-list sync implementation out of
MobileShellComposite into a separate coordinator class within the same package.
Move the private properties syncTask, syncSubscriptionTeamID, and
seededSyncTeams, along with the methods evaluateSyncSubscription(),
startSyncSubscription(), and reloadDeviceListFromSyncStore() into a new
coordinator that manages WebSocket lifecycle, migration seeding, sync applier
setup, and persistence reads. Update MobileShellComposite to instantiate and
delegate to this coordinator, with the coordinator reporting ordered device
snapshots back to the composite via a callback or binding, allowing
MobileShellComposite to remain focused on its core responsibilities without
mixing state ownership, networking, and persistence concerns.

Source: Coding guidelines

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

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1630-1758: Extract the device-list sync implementation out of
MobileShellComposite into a separate coordinator class within the same package.
Move the private properties syncTask, syncSubscriptionTeamID, and
seededSyncTeams, along with the methods evaluateSyncSubscription(),
startSyncSubscription(), and reloadDeviceListFromSyncStore() into a new
coordinator that manages WebSocket lifecycle, migration seeding, sync applier
setup, and persistence reads. Update MobileShellComposite to instantiate and
delegate to this coordinator, with the coordinator reporting ordered device
snapshots back to the composite via a callback or binding, allowing
MobileShellComposite to remain focused on its core responsibilities without
mixing state ownership, networking, and persistence concerns.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9ec691b6-5b57-4bec-b0aa-67daf90cd50a

📥 Commits

Reviewing files that changed from the base of the PR and between 05bddfe and 3d324db.

📒 Files selected for processing (2)
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift

The subscription loop did guard let self at the top, holding a strong self across
the long client.run() await, so an in-flight socket retained the whole composite
past its owner's lifetime. Extract makeSyncSubscriptionClient() (resolve team +
seed + build, with a weak-self apply callback) and call it via self?. so self is
released before client.run(); the socket holds no strong self (transport/applier
hold only the sync store, onApplied is weak). Mirrors the presence loop's
per-frame weak-self pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyDelta advances the cursor to a frame's rev with no contiguity guard, so a
frame dropped from the MIDDLE of the stream silently loses a rev. The transport's
bufferingNewest(256) drops the OLDEST on overflow and delivers a non-contiguous
tail, which the consumer would commit past the gap. Switch to bufferingOldest:
overflow drops the NEWEST frames, so the delivered prefix stays contiguous and
the cursor never steps over a gap; the dropped recent frames are re-sent from the
persisted cursor on reconnect/re-hello. Still end the stream on any drop so that
reconnect is prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
syncTask = nil
syncSubscriptionTeamID = nil
evaluateSyncSubscription()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Team switch skips sync restart

Medium Severity

Changing the selected team only restarts the local-first device sync when loadRegistryDevices runs (for example opening or refreshing the device tree). Until then the sync WebSocket stays pinned to the old team via PresenceSyncTransport, and registryDevices can keep showing that team’s devices because onApplied reloads are rejected when syncTeamIDProvider no longer matches the captured team id.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9b295aa. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

1636-1772: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the new device-sync lifecycle out of this composite.

This adds durable-store reads, paired-Mac migration seeding, WebSocket sync/backoff, and device-list mutation to an already 5,763-line production store that also owns pairing, terminal I/O, drafts, feedback, network recovery, and rendering sync. Please move this sync loop/read layer into a small same-package coordinator and leave MobileShellComposite responsible for start/stop and applying registryDevices.

As per coding guidelines, “Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility” and “Flag files that mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one place.”

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

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1636 - 1772, MobileShellComposite is already 5,763 lines and the
new sync lifecycle code violates single-responsibility principles by mixing
persistence, networking, and state management with rendering and platform bridge
code. Extract the sync loop and read layer into a new same-package coordinator
class by moving the properties syncTask, syncSubscriptionTeamID, and
seededSyncTeams, along with the methods evaluateSyncSubscription,
startSyncSubscription, and syncStoreDevices into the new coordinator. Leave only
the start/stop control and the reloadDeviceListFromSyncStore apply callback in
MobileShellComposite to integrate the coordinator's results into
registryDevices.

Source: Coding guidelines

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

Inline comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1522-1524: Replace the optional array return type
`[RegistryDevice]?` from the `syncStoreDevices` method with a result enum that
explicitly represents the two cases: either a successful read (with a possibly
empty array) or a failed/discarded read. Create a new enum type (or use Swift's
Result type) and update the `syncStoreDevices` method to return this enum
instead of an optional collection. Then update all call sites of
`syncStoreDevices`, including the conditional check at lines 1522-1524, to
handle the new enum cases using appropriate pattern matching instead of the
current optional binding check.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1636-1772: MobileShellComposite is already 5,763 lines and the new
sync lifecycle code violates single-responsibility principles by mixing
persistence, networking, and state management with rendering and platform bridge
code. Extract the sync loop and read layer into a new same-package coordinator
class by moving the properties syncTask, syncSubscriptionTeamID, and
seededSyncTeams, along with the methods evaluateSyncSubscription,
startSyncSubscription, and syncStoreDevices into the new coordinator. Leave only
the start/stop control and the reloadDeviceListFromSyncStore apply callback in
MobileShellComposite to integrate the coordinator's results into
registryDevices.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: df3efb85-7ec2-48bb-9937-7aeeee80bfb4

📥 Commits

Reviewing files that changed from the base of the PR and between 3d324db and 2ad2ec6.

📒 Files selected for processing (1)
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift

Comment on lines +1522 to +1524
if let devices = await syncStoreDevices(teamID: teamID), !devices.isEmpty {
registryDevices = devices
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift'

if command -v swiftlint >/dev/null 2>&1; then
  output="$(swiftlint lint --path "$file" || true)"
  if printf '%s\n' "$output" | rg -n 'discouraged_optional_collection|syncStoreDevices'; then
    exit 1
  fi
else
  if rg -n 'syncStoreDevices\(teamID: String\) async -> \[RegistryDevice\]\?' "$file"; then
    exit 1
  fi
fi

Repository: manaflow-ai/cmux

Length of output: 191


🏁 Script executed:

# First, check if the file exists and get its size
file='Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift'
if [ -f "$file" ]; then
  lines=$(wc -l < "$file")
  echo "File exists: $lines lines"
  echo "---"
  
  # Read the relevant sections around lines 1522-1524 and 1741-1770
  echo "=== Context around lines 1522-1524 ==="
  sed -n '1515,1535p' "$file" | cat -n
  echo ""
  echo "=== Context around lines 1741-1770 ==="
  sed -n '1735,1780p' "$file" | cat -n
else
  echo "File not found"
fi

Repository: manaflow-ai/cmux

Length of output: 4115


🏁 Script executed:

# Search for the syncStoreDevices method definition
rg -n 'syncStoreDevices' 'Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift' -A 3

Repository: manaflow-ai/cmux

Length of output: 678


🏁 Script executed:

# Check for discouraged_optional_collection pattern in the file
rg -n '\[RegistryDevice\]\?' 'Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift'

Repository: manaflow-ai/cmux

Length of output: 144


🏁 Script executed:

# Find all calls to syncStoreDevices to ensure the diff covers all sites
rg -n 'syncStoreDevices' 'Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift'

Repository: manaflow-ai/cmux

Length of output: 312


🏁 Script executed:

# Check if the file has any existing result enum patterns or similar
rg -n 'private enum.*{' 'Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift' | head -20

Repository: manaflow-ai/cmux

Length of output: 154


Replace [RegistryDevice]? with a result enum to avoid SwiftLint's discouraged_optional_collection warning while preserving semantics.

The method syncStoreDevices uses nil to mean "read failed or discarded" and a non-nil array to mean "valid list (possibly empty)". SwiftLint flags optional collections, but the distinction is necessary here. Use a tiny result enum to keep both cases clear without the lint warning.

Proposed shape
+    private enum SyncStoreDeviceRead {
+        case valid([RegistryDevice])
+        case invalid
+    }
+
...
-                if let devices = await syncStoreDevices(teamID: teamID), !devices.isEmpty {
+                if case .valid(let devices) = await syncStoreDevices(teamID: teamID), !devices.isEmpty {
                     registryDevices = devices
                     return
                 }
...
-    private func syncStoreDevices(teamID: String) async -> [RegistryDevice]? {
-        guard deviceListLocalFirst, let syncStore else { return nil }
+    private func syncStoreDevices(teamID: String) async -> SyncStoreDeviceRead {
+        guard deviceListLocalFirst, let syncStore else { return .invalid }
         let requestingUserID = identityProvider?.currentUserID
         let loaded: [RegistryDevice]
         do {
             loaded = try await DeviceSyncFacade(store: syncStore).registryDevices(teamID: teamID)
         } catch {
             mobileShellLog.debug(
                 "device sync facade read failed: \(String(describing: error), privacy: .public)"
             )
-            return nil
+            return .invalid
         }
-        guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return nil }
-        guard (await syncTeamIDProvider?() ?? "") == teamID else { return nil }
+        guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return .invalid }
+        guard (await syncTeamIDProvider?() ?? "") == teamID else { return .invalid }
         let connectedID = connectedMacDeviceID
-        return loaded.sorted { lhs, rhs in
+        return .valid(loaded.sorted { lhs, rhs in
             let lhsConnected = lhs.deviceId == connectedID
             let rhsConnected = rhs.deviceId == connectedID
             if lhsConnected != rhsConnected { return lhsConnected }
             return lhs.lastSeenAt > rhs.lastSeenAt
-        }
+        })
     }
...
     func reloadDeviceListFromSyncStore(teamID: String) async {
-        if let devices = await syncStoreDevices(teamID: teamID) {
+        if case .valid(let devices) = await syncStoreDevices(teamID: teamID) {
             registryDevices = devices
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let devices = await syncStoreDevices(teamID: teamID), !devices.isEmpty {
registryDevices = devices
return
private enum SyncStoreDeviceRead {
case valid([RegistryDevice])
case invalid
}
// ... elsewhere in file ...
if case .valid(let devices) = await syncStoreDevices(teamID: teamID), !devices.isEmpty {
registryDevices = devices
return
}
// ... function implementations ...
private func syncStoreDevices(teamID: String) async -> SyncStoreDeviceRead {
guard deviceListLocalFirst, let syncStore else { return .invalid }
let requestingUserID = identityProvider?.currentUserID
let loaded: [RegistryDevice]
do {
loaded = try await DeviceSyncFacade(store: syncStore).registryDevices(teamID: teamID)
} catch {
mobileShellLog.debug(
"device sync facade read failed: \(String(describing: error), privacy: .public)"
)
return .invalid
}
guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return .invalid }
guard (await syncTeamIDProvider?() ?? "") == teamID else { return .invalid }
let connectedID = connectedMacDeviceID
return .valid(loaded.sorted { lhs, rhs in
let lhsConnected = lhs.deviceId == connectedID
let rhsConnected = rhs.deviceId == connectedID
if lhsConnected != rhsConnected { return lhsConnected }
return lhs.lastSeenAt > rhs.lastSeenAt
})
}
func reloadDeviceListFromSyncStore(teamID: String) async {
if case .valid(let devices) = await syncStoreDevices(teamID: teamID) {
registryDevices = devices
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1522 - 1524, Replace the optional array return type
`[RegistryDevice]?` from the `syncStoreDevices` method with a result enum that
explicitly represents the two cases: either a successful read (with a possibly
empty array) or a failed/discarded read. Create a new enum type (or use Swift's
Result type) and update the `syncStoreDevices` method to return this enum
instead of an optional collection. Then update all call sites of
`syncStoreDevices`, including the conditional check at lines 1522-1524, to
handle the new enum cases using appropriate pattern matching instead of the
current optional binding check.

Source: Linters/SAST tools

The local→DO seed wrote account-derived provisional rows keyed only by team, and
neither the in-flight race nor sign-out cleared them, so user A's local-only Macs
could leak into a later user B on the same team/device. Two guards:
- After the (non-cancellation-aware) migration await, recheck the account; if the
  user signed out or switched mid-seed, clear the team and abandon rather than
  persist/render A's rows.
- On actual sign-out, clear the sync store for the teams this session seeded, so
  nothing account-derived survives for the next user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-empty handling

Round 7 autoreview:
- The sign-out cache wipe was a fire-and-forget Task that raced the next
  account's sign-in/seed. Replace it with READ-time isolation: the facade
  owner-scopes provisional (rev==0) rows by ownerUserId; authoritative (rev>=1)
  rows stay team-shared. Same model as MobilePairedMacStore persisting across
  sign-out and filtering by stackUserID. No clear -> no race, and consistent
  with the rest of the app.
- The empty-store registry fallback resurrected stale registry rows even when the
  DO had synced an authoritatively-empty team. Gate the fallback on cursor == 0
  (cache not warm yet); once synced, an empty list is authoritative and shown.

Tests: provisional rows owner-scoped while authoritative rows stay shared; an
authoritative empty (cursor>0) does not fall back to a stale registry device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (2)

1507-1524: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track teams rendered from the cold sync-store read.

teamsToClear only includes seededSyncTeams and syncSubscriptionTeamID. If loadRegistryDevices() reads and renders a cached teamID before the subscription task reaches Line 1723, sign-out can miss that team and leave its persisted rows available to the next account. Record every team touched by local-first reads and clear that set on sign-out.

Suggested shape
     private var seededSyncTeams: Set<String> = []
+    private var touchedSyncTeams: Set<String> = []
...
             if !teamID.isEmpty {
+                touchedSyncTeams.insert(teamID)
                 // Team switched while a socket was pinned to the old team: restart
-            let teamsToClear: Set<String> = isSignedIn
+            let teamsToClear: Set<String> = isSignedIn
                 ? []
-                : seededSyncTeams.union(syncSubscriptionTeamID.map { [$0] } ?? [])
+                : touchedSyncTeams
+                    .union(seededSyncTeams)
+                    .union(syncSubscriptionTeamID.map { [$0] } ?? [])
...
             seededSyncTeams = []
+            touchedSyncTeams = []

As per coding guidelines, “verify team/user switching and store clearing/revalidation are handled so a saved device list cursor/seed can’t be trusted across account/team changes.”

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

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1507 - 1524, The `teamsToClear` set does not include teams that are
loaded directly from the sync-store via the `syncStoreDevices(teamID: teamID)`
call in this code block. When devices are successfully retrieved and assigned to
`registryDevices`, the associated `teamID` must be tracked in a separate
collection so it can be included in the sign-out clearing logic. Create a new
property to track all teams touched by local-first reads (including the `teamID`
parameter passed to `syncStoreDevices`), and ensure this collection is cleared
along with `teamsToClear` during sign-out to prevent persisted rows from being
available to the next account.

Source: Coding guidelines


1636-1805: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Move the device-list sync loop out of MobileShellComposite.

This adds persistence, migration, transport subscription, backoff, and cache invalidation into an already 5k+ line composite that also owns terminal I/O, pairing, drafts, presence, feedback, and workspace mutation. Extract the new device-list sync state machine into a focused helper/coordinator in the same package, with closures for reloadDeviceListFromSyncStore/logging, so the composite only starts/stops it.

As per coding guidelines, Swift production files over 800 lines and files mixing UI state, persistence, networking, socket protocol, and platform bridge responsibilities should be flagged.

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

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1636 - 1805, Extract the device-list sync logic from
MobileShellComposite into a new focused helper/coordinator class in the same
package. Move the properties syncTask, syncSubscriptionTeamID, and
seededSyncTeams, along with the methods evaluateSyncSubscription(),
startSyncSubscription(), makeSyncSubscriptionClient(), and syncStoreDevices()
into this new coordinator class. The coordinator should accept closures for
reloadDeviceListFromSyncStore and logging to maintain the callback contract.
Update MobileShellComposite to only start/stop the coordinator instance, keeping
just the minimal interface needed to integrate with the composite's sign-in
state and flag management.

Source: Coding guidelines

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

Inline comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1657-1670: The fire-and-forget clear operation in the sign-out
block can race with fast account switches, causing the next sign-in to read
stale data or delete new session rows. Add a `syncCacheClearTask` property to
serialize this operation, store the cache clearing Task in it instead of using
fire-and-forget, and await this task in the `makeSyncSubscriptionClient()` and
`syncStoreDevices(teamID:)` methods before seeding or reading to ensure the
cache clear completes before any new local-first operations begin.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1507-1524: The `teamsToClear` set does not include teams that are
loaded directly from the sync-store via the `syncStoreDevices(teamID: teamID)`
call in this code block. When devices are successfully retrieved and assigned to
`registryDevices`, the associated `teamID` must be tracked in a separate
collection so it can be included in the sign-out clearing logic. Create a new
property to track all teams touched by local-first reads (including the `teamID`
parameter passed to `syncStoreDevices`), and ensure this collection is cleared
along with `teamsToClear` during sign-out to prevent persisted rows from being
available to the next account.
- Around line 1636-1805: Extract the device-list sync logic from
MobileShellComposite into a new focused helper/coordinator class in the same
package. Move the properties syncTask, syncSubscriptionTeamID, and
seededSyncTeams, along with the methods evaluateSyncSubscription(),
startSyncSubscription(), makeSyncSubscriptionClient(), and syncStoreDevices()
into this new coordinator class. The coordinator should accept closures for
reloadDeviceListFromSyncStore and logging to maintain the callback contract.
Update MobileShellComposite to only start/stop the coordinator instance, keeping
just the minimal interface needed to integrate with the composite's sign-in
state and flag management.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c044a4dc-fb22-4297-a833-bef3a9fa7c23

📥 Commits

Reviewing files that changed from the base of the PR and between 2ad2ec6 and a118f5d.

📒 Files selected for processing (2)
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSyncTransport.swift

Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
lawrencecchen and others added 2 commits June 20, 2026 02:57
The facade owner filter only applied when provisionalOwnerUserID was non-nil, so
a nil owner (signed in but user id momentarily unavailable) showed ALL accounts'
provisional rows — fail open. Invert it: provisional (rev==0) rows require a
present, matching owner; nil owner returns no provisional rows (authoritative
team-shared rows unaffected). Tests updated to seed/read with an owner and to
assert nil-owner yields only the shared authoritative device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…authoritative empty

The earlier authoritative-empty fix set registryDevices=[], but deviceTreeDevices'
legacy XOR fallback then synthesized the tree from the OLD pairedMacs, re-showing
stale local Macs the DO no longer knows about. In local-first mode the sync store
is authoritative (paired Macs are seeded into it as provisional rows), so gate
that pairedMacs fallback off; loadRegistryDevices still falls back to /api/devices
before the first sync, so this only bites once the store is authoritatively empty.

Test: with a local paired Mac loaded and an authoritatively-empty sync store, the
tree stays empty (the Mac is not resurrected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…authoritative

Round 10 autoreview:
- [P1] The sync subscription Task was @mainactor, so every frame parse + apply
  (including shared-socket presence-tick noise) ran on the UI thread. Drop
  @mainactor: makeSyncSubscriptionClient (team/seed/build) and the apply callback
  are @mainactor and reached via await; SyncFrameApplier is its own actor;
  SyncClient/transport are Sendable. The frame loop now parses off-main.
- [P2] Gating deviceTreeDevices' paired-Mac fallback purely on local-first hid
  local Macs before the store had synced (offline first launch -> empty). Gate on
  a new deviceSyncAuthoritative flag (set when cursor>0 / a frame commits, reset
  on team change + sign-out), so the fallback persists until an empty list is
  authoritative.

Tests: before sync the tree shows the local paired Mac; once authoritatively
empty it does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift (1)

203-216: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the stale-team test exercise a non-empty old-team payload

On Line 206, the composite is created without an identity provider, so provisionalOwnerUserID is nil and the seeded rev == 0 rows are filtered out before the stale-team guard is exercised. This test can pass even if stale-team overwrite protection regresses.

Suggested fix
         let composite = MobileShellComposite(
             isSignedIn: true,
             syncStore: store,
             deviceListLocalFirst: true,
             syncTeamIDProvider: { "team-B" }, // user is now on team-B
             makeSyncTransport: makeTransportFactory(),
+            identityProvider: FakeIdentity(userID: Self.owner),
             deliveredNotificationClearer: NoopDeliveredNotificationClearer()
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift`
around lines 203 - 216, The staleTeamFrameDoesNotOverwriteCurrentTeam test does
not provide an identityProvider when creating the MobileShellComposite, which
causes the seeded team-A rows to be filtered out before the stale-team overwrite
protection code is even exercised. Add an identityProvider closure to the
MobileShellComposite initialization so that the provisionalOwnerUserID is set
and the seeded rows with rev == 0 are not filtered out, ensuring the test
actually exercises the stale-team guard with a non-empty payload.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

1522-1535: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Separate invalid sync-store reads from authoritative empty before clearing.

syncStoreDevices returns nil for read errors and stale user/team guards, but Line 1531 still checks the cursor and can clear registryDevices when cursor > 0. That turns a failed or stale cache read into an authoritative empty list. Return an explicit result and only run the cursor/clear path after a validated empty read; also re-check user/team after the cursor await.

Suggested shape
+    private enum SyncStoreDeviceRead {
+        case valid([RegistryDevice])
+        case invalid
+    }
+
@@
-                if let devices = await syncStoreDevices(teamID: teamID), !devices.isEmpty {
+                switch await syncStoreDevices(teamID: teamID) {
+                case .valid(let devices) where !devices.isEmpty:
                     registryDevices = devices
                     return
-                }
+                case .valid:
+                    let requestingUserID = identityProvider?.currentUserID
+                    if let syncStore,
+                       let cursor = try? await syncStore.cursor(teamID: teamID, collection: devicesSyncCollection),
+                       cursor > 0 {
+                        let currentTeamID = (await syncTeamIDProvider?()) ?? ""
+                        guard isSignedIn,
+                              identityProvider?.currentUserID == requestingUserID,
+                              currentTeamID == teamID else { return }
+                        registryDevices = []
+                        return
+                    }
+                case .invalid:
+                    break
+                }
@@
-                if let syncStore,
-                   let cursor = try? await syncStore.cursor(teamID: teamID, collection: devicesSyncCollection),
-                   cursor > 0 {
-                    registryDevices = []
-                    return
-                }
-    private func syncStoreDevices(teamID: String) async -> [RegistryDevice]? {
-        guard deviceListLocalFirst, let syncStore else { return nil }
+    private func syncStoreDevices(teamID: String) async -> SyncStoreDeviceRead {
+        guard deviceListLocalFirst, let syncStore else { return .invalid }
@@
-            return nil
+            return .invalid
         }
-        guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return nil }
-        guard (await syncTeamIDProvider?() ?? "") == teamID else { return nil }
+        guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { return .invalid }
+        guard (await syncTeamIDProvider?() ?? "") == teamID else { return .invalid }
         let connectedID = connectedMacDeviceID
-        return loaded.sorted { lhs, rhs in
+        return .valid(loaded.sorted { lhs, rhs in
             let lhsConnected = lhs.deviceId == connectedID
             let rhsConnected = rhs.deviceId == connectedID
             if lhsConnected != rhsConnected { return lhsConnected }
             return lhs.lastSeenAt > rhs.lastSeenAt
-        }
+        })
     }
@@
-        if let devices = await syncStoreDevices(teamID: teamID) {
+        if case .valid(let devices) = await syncStoreDevices(teamID: teamID) {
             registryDevices = devices
         }

As per coding guidelines, “When the diff replaces a fresh authoritative read with a cached value in a persistence, history, undo, or snapshot path, require explicit handling of cold (never-loaded) and stale (older-than-source) caches.”

Also applies to: 1786-1803

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

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1522 - 1535, The syncStoreDevices function currently returns nil
for both read errors and valid empty results, causing the cursor check on line
1531 to incorrectly treat failed or stale reads as authoritative empty lists.
Modify syncStoreDevices to return an explicit result type that distinguishes
between errors/stale results and valid empty reads. Update the logic to only
proceed with the cursor check and registryDevices clearing when syncStoreDevices
returns a valid empty result (not when it returns nil due to an error or stale
user/team guard). Additionally, re-validate the user and team after the cursor
await operation to ensure they haven't changed since the syncStoreDevices check.

Source: Coding guidelines

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

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1522-1535: The syncStoreDevices function currently returns nil for
both read errors and valid empty results, causing the cursor check on line 1531
to incorrectly treat failed or stale reads as authoritative empty lists. Modify
syncStoreDevices to return an explicit result type that distinguishes between
errors/stale results and valid empty reads. Update the logic to only proceed
with the cursor check and registryDevices clearing when syncStoreDevices returns
a valid empty result (not when it returns nil due to an error or stale user/team
guard). Additionally, re-validate the user and team after the cursor await
operation to ensure they haven't changed since the syncStoreDevices check.

In
`@Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift`:
- Around line 203-216: The staleTeamFrameDoesNotOverwriteCurrentTeam test does
not provide an identityProvider when creating the MobileShellComposite, which
causes the seeded team-A rows to be filtered out before the stale-team overwrite
protection code is even exercised. Add an identityProvider closure to the
MobileShellComposite initialization so that the provisionalOwnerUserID is set
and the seeded rows with rev == 0 are not filtered out, ensuring the test
actually exercises the stale-team guard with a non-empty payload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e7bcfb7-b422-4d04-b97b-8774165ba8fd

📥 Commits

Reviewing files that changed from the base of the PR and between a118f5d and a78422e.

📒 Files selected for processing (4)
  • Packages/Shared/CmuxSyncStore/Sources/CmuxSyncStore/DeviceSyncFacade.swift
  • Packages/Shared/CmuxSyncStore/Tests/CmuxSyncStoreTests/DeviceListSyncWiringTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceListLocalFirstWiringTests.swift

lawrencecchen and others added 2 commits June 20, 2026 03:22
deviceSyncAuthoritative was a single Bool, so a synced (empty) team-A left the
gate set when switching to an unsynced team-B, hiding team-B's paired-Mac
fallback. Track the synced team (deviceSyncAuthoritativeTeamID) and the rendered
team (deviceListTeamID); gate only when they match. Reset on sign-out; a team
switch updates deviceListTeamID so the gate naturally disengages until the new
team syncs.

Test: team-A authoritative-empty gates; after switching to unsynced team-B the
tree falls back to the local paired Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er await

The cursor-read branch blanked registryDevices after an await without re-checking
the session, so a stale read could clear the current device tree. Capture the
requesting user, re-guard after the team-resolve await, and re-guard (user +
team) after the cursor await before blanking — mirroring syncStoreDevices' own
guards. A stale read now returns without mutating the new session's list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… + lockfile

- CMUXMobileRootScene: the makeSyncTransport closure via .map was type-ambiguous
  ('type of expression is ambiguous'); build it with an explicit @sendable typed
  closure so cmuxFeature compiles (the iOS build CI caught this; local package
  builds didn't exercise cmuxFeature).
- PresenceSyncTransport: add DocC docs on the public init/send/frames (Aziz docs
  policy).
- Regenerate ios/cmuxPackage/Package.resolved (originHash) for the CmuxSyncStore
  dependency (SwiftPM lockfile policy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2c79ecf. Configure here.

if deviceListLocalFirst, syncStore != nil, makeSyncTransport != nil,
let synced = deviceSyncAuthoritativeTeamID, synced == deviceListTeamID {
return []
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New pair hidden after empty sync

Medium Severity

With mobileDeviceListLocalFirst on, after the sync store has an authoritative empty snapshot for the team (cursor > 0), pairing a new Mac only updates pairedMacStore. PairedMacMigration does not run again, so no new provisional sync row is written. deviceTreeDevices then returns an empty list instead of synthesizing from pairedMacs, so the new Mac never appears in the device tree until the DO publishes it.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c79ecf. Configure here.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 2c79ecf2 Deployed Jun 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants