Remove device heartbeat polling to reduce Vercel costs#2904
Conversation
Replace 30s heartbeat polling with a single device registration on app startup. This eliminates ~2,880 requests/device/day to Vercel while keeping MCP command routing and ownership checks intact. - Remove heartbeat interval from desktop and mobile, register once on mount - Rename `device.heartbeat` to `device.registerDevice` (single-fire) - Remove `device.listOnlineDevices` (unused) - Remove online-check gate from MCP `executeOnDevice` (timeout handles offline) - Remove Devices settings page and sidebar entry - Remove `devicePresence` Electric collection (no longer needed client-side) - Simplify `list_devices` MCP tool (no online/offline status) - Remove online indicator from DevicePicker UI
📝 WalkthroughWalkthroughReplaced periodic device heartbeat polling with a one-time registerDevice mutation; removed device presence collections, online-status computation/fields and UI indicators, deleted the Devices settings page/route, and updated API/tools to stop filtering or returning Changes
Sequence DiagramsequenceDiagram
participant App as Application
participant Hook as useDevicePresence Hook
participant Client as API Client
participant Server as tRPC Server
participant DB as Database
rect rgba(100,200,150,0.5)
Note over App,DB: Old Flow — Periodic Heartbeat
App->>Hook: mount with deviceInfo & org
Hook->>Hook: start setInterval (heartbeat)
loop every N ms
Hook->>Client: heartbeat.mutate()
Client->>Server: heartbeat mutation
Server->>DB: update lastSeenAt
DB-->>Server: ack
Server-->>Client: success
end
end
rect rgba(150,180,220,0.5)
Note over App,DB: New Flow — One-Time Registration
App->>Hook: mount with deviceInfo & org
Hook->>Hook: check registeredScopeRef
alt not registered
Hook->>Client: registerDevice.mutate()
Client->>Server: registerDevice mutation
Server->>DB: insert/update device presence
DB-->>Server: success
Server-->>Client: {device, timestamp}
Client-->>Hook: onSuccess -> set registeredScopeRef
else already registered
Hook->>Hook: skip registration
end
end
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
No consumers remain after removing the presence join from useWorkspaceHostOptions. Presence will be reimplemented via WSS.
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
There was a problem hiding this comment.
1 issue found across 15 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/mobile/hooks/useDevicePresence/useDevicePresence.ts">
<violation number="1" location="apps/mobile/hooks/useDevicePresence/useDevicePresence.ts:38">
P1: The global `registeredRef` guard prevents re-registering when `activeOrganizationId` changes, so device presence can stay linked to the wrong organization.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/mcp/src/tools/utils/utils.ts (1)
46-80:⚠️ Potential issue | 🟠 MajorScope the presence lookup to
ctx.userId.This query still assumes
deviceIdis unique within an organization, butregisterDeviceupsertsdevicePresenceby(userId, deviceId). If two users register the same machine in one org,limit(1)can grab the other user's row first and reject a command for the caller's own device.Suggested fix
.where( and( eq(devicePresence.deviceId, deviceId), eq(devicePresence.organizationId, ctx.organizationId), + eq(devicePresence.userId, ctx.userId), ), )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/mcp/src/tools/utils/utils.ts` around lines 46 - 80, The devicePresence lookup in the utils function is not scoped to the caller and can return another user's row because registerDevice upserts by (userId, deviceId); update the db.select() .where(...) used to fetch devicePresence to include eq(devicePresence.userId, ctx.userId) so the query only returns the row for the current user (ensuring the subsequent device and ownership checks are correct) — adjust the where clause around devicePresence/deviceId and organizationId to also require the matching userId from ctx.userId.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts`:
- Around line 13-30: The current registration guard (registeredRef in the
useEffect) prevents re-registering when the active organization or user changes
for the session; change the guard to be scoped to the current org/user (for
example track lastRegisteredScopeRef = useRef<string | null> storing a
concatenated key like `${userId}:${orgId}`) and in the useEffect compare the
current scope key (derived from deviceInfo,
session?.session?.activeOrganizationId and session?.session?.userId) to
lastRegisteredScopeRef.current; only skip registration when they match, set
lastRegisteredScopeRef.current when registration succeeds, and reset it to null
on failure (catch) so future org/user changes will trigger a new registerDevice
call via apiTrpcClient.device.registerDevice.
In `@apps/mobile/hooks/useDevicePresence/useDevicePresence.ts`:
- Around line 29-53: The one-shot guard (registeredRef) prevents re-registering
when the user/org changes; replace the boolean guard with a ref that stores the
last registered scope (e.g., lastRegisteredScopeRef) and include the current
scope (activeOrganizationId and optionally session?.session?.userId) in the
check inside the useEffect; only skip registration when
lastRegisteredScopeRef.current strictly equals the current scope, and on
successful apiClient.device.registerDevice.mutate set
lastRegisteredScopeRef.current to the current scope, while on catch clear it
(set to null/undefined) so future org/account switches will trigger upsert;
ensure this uses the same useEffect([deviceId, activeOrganizationId,
session?.session?.userId]) and referenced symbols (registeredRef ->
lastRegisteredScopeRef, deviceId, activeOrganizationId,
apiClient.device.registerDevice.mutate).
---
Outside diff comments:
In `@packages/mcp/src/tools/utils/utils.ts`:
- Around line 46-80: The devicePresence lookup in the utils function is not
scoped to the caller and can return another user's row because registerDevice
upserts by (userId, deviceId); update the db.select() .where(...) used to fetch
devicePresence to include eq(devicePresence.userId, ctx.userId) so the query
only returns the row for the current user (ensuring the subsequent device and
ownership checks are correct) — adjust the where clause around
devicePresence/deviceId and organizationId to also require the matching userId
from ctx.userId.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4bba1502-ab44-4988-b024-33cf79e6fca4
📒 Files selected for processing (15)
apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.tsapps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsxapps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.tsapps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.tsapps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsxapps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsxapps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.tsapps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsxapps/desktop/src/renderer/routes/_authenticated/settings/layout.tsxapps/desktop/src/renderer/stores/settings-state.tsapps/mobile/hooks/useDevicePresence/useDevicePresence.tspackages/mcp/src/tools/devices/list-devices/list-devices.tspackages/mcp/src/tools/utils/index.tspackages/mcp/src/tools/utils/utils.tspackages/trpc/src/router/device/device.ts
💤 Files with no reviewable changes (7)
- apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts
- apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx
- apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx
- apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
- apps/desktop/src/renderer/stores/settings-state.ts
- apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx
- apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx
| const registeredRef = useRef(false); | ||
|
|
||
| const sendHeartbeat = useCallback(async () => { | ||
| useEffect(() => { | ||
| if (!deviceInfo || !session?.session?.activeOrganizationId) return; | ||
| if (registeredRef.current) return; | ||
| registeredRef.current = true; | ||
|
|
||
| try { | ||
| await apiTrpcClient.device.heartbeat.mutate({ | ||
| apiTrpcClient.device.registerDevice | ||
| .mutate({ | ||
| deviceId: deviceInfo.deviceId, | ||
| deviceName: deviceInfo.deviceName, | ||
| deviceType: "desktop", | ||
| }) | ||
| .catch(() => { | ||
| // Registration can fail when offline — will retry on next app launch | ||
| registeredRef.current = false; | ||
| }); | ||
| } catch { | ||
| // Heartbeat can fail when offline - ignore | ||
| } | ||
| }, [deviceInfo, session?.session?.activeOrganizationId]); |
There was a problem hiding this comment.
Scope the desktop registration guard to the current user/org.
This has the same lifetime bug as the mobile hook: after the first successful call, later org/account changes are ignored for the rest of the session. That leaves the device_presence row in the old scope until the app is restarted, which breaks MCP routing in the new org.
Suggested fix
- const registeredRef = useRef(false);
+ const registeredScopeRef = useRef<string | null>(null);
...
useEffect(() => {
- if (!deviceInfo || !session?.session?.activeOrganizationId) return;
- if (registeredRef.current) return;
- registeredRef.current = true;
+ const registrationScope =
+ session?.user?.id && session?.session?.activeOrganizationId
+ ? `${session.user.id}:${session.session.activeOrganizationId}`
+ : null;
+ if (!deviceInfo || !registrationScope) return;
+ if (registeredScopeRef.current === registrationScope) return;
+ registeredScopeRef.current = registrationScope;
apiTrpcClient.device.registerDevice
.mutate({
deviceId: deviceInfo.deviceId,
deviceName: deviceInfo.deviceName,
deviceType: "desktop",
})
.catch(() => {
- registeredRef.current = false;
+ registeredScopeRef.current = null;
});
- }, [deviceInfo, session?.session?.activeOrganizationId]);
+ }, [deviceInfo, session?.session?.activeOrganizationId, session?.user?.id]);📝 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.
| const registeredRef = useRef(false); | |
| const sendHeartbeat = useCallback(async () => { | |
| useEffect(() => { | |
| if (!deviceInfo || !session?.session?.activeOrganizationId) return; | |
| if (registeredRef.current) return; | |
| registeredRef.current = true; | |
| try { | |
| await apiTrpcClient.device.heartbeat.mutate({ | |
| apiTrpcClient.device.registerDevice | |
| .mutate({ | |
| deviceId: deviceInfo.deviceId, | |
| deviceName: deviceInfo.deviceName, | |
| deviceType: "desktop", | |
| }) | |
| .catch(() => { | |
| // Registration can fail when offline — will retry on next app launch | |
| registeredRef.current = false; | |
| }); | |
| } catch { | |
| // Heartbeat can fail when offline - ignore | |
| } | |
| }, [deviceInfo, session?.session?.activeOrganizationId]); | |
| const registeredScopeRef = useRef<string | null>(null); | |
| useEffect(() => { | |
| const registrationScope = | |
| session?.user?.id && session?.session?.activeOrganizationId | |
| ? `${session.user.id}:${session.session.activeOrganizationId}` | |
| : null; | |
| if (!deviceInfo || !registrationScope) return; | |
| if (registeredScopeRef.current === registrationScope) return; | |
| registeredScopeRef.current = registrationScope; | |
| apiTrpcClient.device.registerDevice | |
| .mutate({ | |
| deviceId: deviceInfo.deviceId, | |
| deviceName: deviceInfo.deviceName, | |
| deviceType: "desktop", | |
| }) | |
| .catch(() => { | |
| // Registration can fail when offline — will retry on next app launch | |
| registeredScopeRef.current = null; | |
| }); | |
| }, [deviceInfo, session?.session?.activeOrganizationId, session?.user?.id]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts`
around lines 13 - 30, The current registration guard (registeredRef in the
useEffect) prevents re-registering when the active organization or user changes
for the session; change the guard to be scoped to the current org/user (for
example track lastRegisteredScopeRef = useRef<string | null> storing a
concatenated key like `${userId}:${orgId}`) and in the useEffect compare the
current scope key (derived from deviceInfo,
session?.session?.activeOrganizationId and session?.session?.userId) to
lastRegisteredScopeRef.current; only skip registration when they match, set
lastRegisteredScopeRef.current when registration succeeds, and reset it to null
on failure (catch) so future org/user changes will trigger a new registerDevice
call via apiTrpcClient.device.registerDevice.
- Replace boolean registeredRef with org-scoped ref so switching orgs triggers re-registration on both desktop and mobile - Scope executeOnDevice ownership query to ctx.userId to prevent returning another user's device row
Existing desktop/mobile clients still call device.heartbeat on a 30s interval. Keep it around as a deprecated alias so they don't error out until users update.
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/trpc/src/router/device/device.ts">
<violation number="1" location="packages/trpc/src/router/device/device.ts:92">
P2: `heartbeat` duplicates `registerDevice`'s upsert logic. Extract a shared helper so both endpoints stay behaviorally consistent.
(Based on your team's feedback about avoiding duplicated business logic in multiple places.) [FEEDBACK_USED]</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| * @deprecated Kept for backwards compat with shipped desktop/mobile clients | ||
| * that still call heartbeat on a 30s interval. Same logic as registerDevice. | ||
| */ | ||
| heartbeat: protectedProcedure |
There was a problem hiding this comment.
P2: heartbeat duplicates registerDevice's upsert logic. Extract a shared helper so both endpoints stay behaviorally consistent.
(Based on your team's feedback about avoiding duplicated business logic in multiple places.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/trpc/src/router/device/device.ts, line 92:
<comment>`heartbeat` duplicates `registerDevice`'s upsert logic. Extract a shared helper so both endpoints stay behaviorally consistent.
(Based on your team's feedback about avoiding duplicated business logic in multiple places.) </comment>
<file context>
@@ -85,6 +85,54 @@ export const deviceRouter = {
+ * @deprecated Kept for backwards compat with shipped desktop/mobile clients
+ * that still call heartbeat on a 30s interval. Same logic as registerDevice.
+ */
+ heartbeat: protectedProcedure
+ .input(
+ z.object({
</file context>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/trpc/src/router/device/device.ts (1)
88-133:⚠️ Potential issue | 🟠 MajorKeep
heartbeatAPI-compatible, not just route-compatible.Line 133 changes the deprecated alias to return
{ success: true }. Since this endpoint is being kept specifically for shipped clients, changing its response contract can still break released callers that read the old payload even if the mutation itself succeeds. Preserve the legacy response shape here, or have both mutations share one helper while adaptingheartbeatback to the old contract.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/trpc/src/router/device/device.ts` around lines 88 - 133, The heartbeat mutation (protectedProcedure.heartbeat) currently returns { success: true } which breaks legacy clients that depend on the original response shape; change heartbeat to preserve the legacy response contract (match the original registerDevice/old heartbeat payload) — either by returning the same object structure as registerDevice or by extracting the shared DB insert/upsert logic into a helper (e.g., upsertDevicePresence) and calling that helper from both registerDevice and heartbeat, then map the heartbeat's return value to the legacy shape expected by shipped clients.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@packages/trpc/src/router/device/device.ts`:
- Around line 88-133: The heartbeat mutation (protectedProcedure.heartbeat)
currently returns { success: true } which breaks legacy clients that depend on
the original response shape; change heartbeat to preserve the legacy response
contract (match the original registerDevice/old heartbeat payload) — either by
returning the same object structure as registerDevice or by extracting the
shared DB insert/upsert logic into a helper (e.g., upsertDevicePresence) and
calling that helper from both registerDevice and heartbeat, then map the
heartbeat's return value to the legacy shape expected by shipped clients.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8de5f61b-03a7-4708-8a3f-85f928d6656c
📒 Files selected for processing (1)
packages/trpc/src/router/device/device.ts
Since PR superset-sh#2904 removed the 30-second heartbeat to cut Vercel costs, devices register only once at startup. The MCP `list_devices` tool still checks a 60-second online window, so every device appears offline after one minute and MCP commands can no longer reach it. - Add a 5-minute periodic re-registration in useDevicePresence (288 calls/device/day vs the old 2,880 — 90% cost reduction kept) - Bump the online window from 60s to 10 minutes (2x the heartbeat interval to tolerate network hiccups) - Update tests and documentation to match Closes superset-sh#3091
PR #2904 deliberately removed device heartbeat polling (desktop calls device.registerDevice once at startup) because executeOnDevice already handles unreachable devices via its command-poll timeout. PR #2988 reintroduced an isOnline field computed from a 60s lastSeenAt window without restoring heartbeats, so every device looked offline ~60s after app start — surfacing in the Slack integration agent as "status: offline". Revert the isOnline reintroduction (keep #2988's schema hardening), drop includeOffline from the input, update the Slack run-agent to stop rendering the removed status, and refresh the docs + stubbed CLI flag. Also fix a pre-existing mock-module bleed in list-devices.test.ts that was stripping executeOnDevice from start-agent-session.test.ts.
PR #2904 deliberately removed device heartbeat polling (desktop calls device.registerDevice once at startup) because executeOnDevice already handles unreachable devices via its command-poll timeout. PR #2988 reintroduced an isOnline field computed from a 60s lastSeenAt window without restoring heartbeats, so every device looked offline ~60s after app start — surfacing in the Slack integration agent as "status: offline". Revert the isOnline reintroduction (keep #2988's schema hardening), drop includeOffline from the input, update the Slack run-agent to stop rendering the removed status, and refresh the docs + stubbed CLI flag. Also fix a pre-existing mock-module bleed in list-devices.test.ts that was stripping executeOnDevice from start-agent-session.test.ts.
…et-sh#3299) PR superset-sh#2904 deliberately removed device heartbeat polling (desktop calls device.registerDevice once at startup) because executeOnDevice already handles unreachable devices via its command-poll timeout. PR superset-sh#2988 reintroduced an isOnline field computed from a 60s lastSeenAt window without restoring heartbeats, so every device looked offline ~60s after app start — surfacing in the Slack integration agent as "status: offline". Revert the isOnline reintroduction (keep superset-sh#2988's schema hardening), drop includeOffline from the input, update the Slack run-agent to stop rendering the removed status, and refresh the docs + stubbed CLI flag. Also fix a pre-existing mock-module bleed in list-devices.test.ts that was stripping executeOnDevice from start-agent-session.test.ts.
The renderer caller was removed in #2904 (2026-03-28) and the MINIMUM_DESKTOP_VERSION floor (1.5.0, 2026-04-11) blocks any client that predates the caller removal from using the app. The endpoint was kept "for backwards compat with shipped clients", but the only clients still polling are stuck behind the UpdateRequiredPage gate and their heartbeat traffic has no downstream consumer — heartbeat only updates devicePresence.lastSeenAt, which is purely cosmetic (MCP list-devices ORDER BY). Removing the route turns those stale-build calls into a silent tRPC NOT_FOUND. registerDevice stays — MCP's executeOnDevice still uses the row for ownership verification. Trims ~10% of /api log volume on Vercel.
Summary
device.listOnlineDevicestRPC procedure (was unused)devicePresenceElectric collectionTest plan
bun run typecheck— 22/22 passbun run lint— cleanbun test— all failures pre-existinglist_devicesandexecuteOnDevicework with registered deviceSummary by cubic
Replaced 30s device heartbeats with org‑scoped, one‑time device registration on app startup to cut ~2,880 requests per device per day and lower Vercel costs. MCP routing is unchanged; offline devices time out, commands only run on the current user’s devices, and the old
device.heartbeatendpoint remains as a deprecated alias for older clients.Refactors
device.heartbeattodevice.registerDevice(single upsert; re-registers on org switch) and keptdevice.heartbeatas a deprecated alias.device.listOnlineDevicestRPC and the Devices settings page/sidebar entry.devicePresenceandv2DevicePresenceElectric collections; removed online indicators in DevicePicker and presence joins in host options.list_devicesreturns registered devices only;executeOnDeviceverifies org and ownership, relying on timeout for offline cases.Migration
device.heartbeattodevice.registerDevice(older clients will keep working via the deprecated alias).list_devicesno longer includesisOnline.Written for commit 76ddd91. Summary will update on new commits.
Summary by CodeRabbit
Features Removed
Changes