fix(desktop): reduce API polling frequency (#6500)#6512
fix(desktop): reduce API polling frequency (#6500)#6512
Conversation
Greptile SummaryThis PR reduces desktop API polling frequency across all four polling timers (conversations, tasks, memories, chat) from 15–30s to 120s, and adds a 60-second cooldown on the
Confidence Score: 4/5Safe to merge after fixing the cooldown guard scope — one P1 regression blocks screen analysis auto-start in a specific but documented user flow All four timer interval changes and the skipCount optimization are correct. One P1 issue: the guard/return in the activation handler gates the screen analysis auto-start alongside the conversation refresh, breaking the grant-permission-then-switch-back flow when it happens within the 60s window. desktop/Desktop/Sources/MainWindow/DesktopHomeView.swift — activation cooldown guard scope Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[NSApplication.didBecomeActiveNotification] --> B{now - lastActivationRefresh >= 60s?}
B -- No --> RETURN[return — entire handler skipped ⚠️]
B -- Yes --> C[lastActivationRefresh = now]
C --> D[refreshConversations skipCount:false]
C --> E{screenAnalysisEnabled && !isMonitoring?}
E -- Yes --> F[refreshScreenRecordingPermission]
F --> G{hasScreenRecordingPermission?}
G -- Yes --> H[startMonitoring]
G -- No --> IDLE1[no-op]
E -- No --> IDLE2[no-op]
TIMER120[Timer every 120s] --> I[refreshConversations skipCount:true]
I --> J[fetch conversations — no count API call]
RETURN -. should reach .-> E
|
Live Test Evidence (CP9A/CP9B)Changed-path coverage checklist
L1 Evidence (Build + Standalone)
L1 SynthesisAll changed paths (P1-P8) are proven via successful compilation and constant verification. The PollingConfig enum provides a single source of truth for all intervals, and unit tests validate all constant values and cooldown boundary logic. L2 Evidence (Integrated)This is a client-side-only change affecting timer intervals and activation guards. No backend changes. Integration is verified by:
L2 SynthesisAll changed paths (P1-P8) are proven at L2. The timer interval changes are compile-verified constant substitutions. The skipCount parameter and activation cooldown are new logic paths verified by reviewer inspection and unit tests. by AI for @beastoin |
The 15s chat poll interval was the single biggest API traffic contributor at 240 req/user/hour. Increasing to 120s cuts chat polling traffic by 87%. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks already have an isActive page-visibility guard, so polling only fires when the tasks page is visible. Increasing interval from 30s to 120s further reduces unnecessary API traffic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Memories already have an isActive page-visibility guard. Increasing the polling interval from 30s to 120s reduces background API traffic without affecting user experience. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ldown (#6500) - Increase periodic conversation refresh from 30s to 120s - Add 60s cooldown on didBecomeActive to prevent cmd-tab spam - Skip getConversationsCount on periodic refreshes (halves timer traffic) - Conversations still refresh immediately on first app activation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow callers to skip the separate getConversationsCount API call during periodic background refreshes. This halves the traffic from the conversation refresh timer without affecting user-triggered refreshes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Chat had no didBecomeActive refresh path, so with the 120s poll interval messages from mobile could be invisible for up to 2 minutes. Adding an activation observer ensures messages sync immediately when the user returns to the app, matching the conversation refresh behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract all polling interval constants into a single PollingConfig enum. This makes intervals testable and provides a single source of truth for all auto-refresh timers across the desktop app. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use PollingConfig.chatPollInterval instead of inline constant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use PollingConfig.tasksPollInterval instead of inline constant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use PollingConfig.memoriesPollInterval instead of inline constant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use PollingConfig.conversationsPollInterval and activationCooldown instead of inline constants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests verify: - All polling intervals are 120s (chat, tasks, memories, conversations) - Activation cooldown is 60s - Cooldown boundary behavior (first activation, within cooldown, at boundary, after cooldown) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…6500) The cooldown guard was blocking the entire didBecomeActive handler, including the screen-analysis recovery path. Now the cooldown only gates refreshConversations() while screen-recording permission checks and monitoring restarts still run on every activation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All periodic polling timers eliminated. PollingConfig now only holds the activation cooldown constant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New notification name that all data providers observe to refresh on demand, replacing periodic polling timers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Global shortcut posts refreshAllData notification, triggering all data providers to fetch fresh data on demand. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace 120s periodic poll with event-driven refresh: app activation observer (already existed) + Cmd+R manual refresh. Eliminates 720 unnecessary API calls per user per day. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…6500) Remove periodic 120s conversation refresh timer. Conversations now refresh on app activation (with 60s cooldown) and Cmd+R only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove periodic 120s task refresh timer. Tasks now refresh on app activation, page visibility, and Cmd+R. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…6500) Remove periodic 120s memories refresh timer. Memories now refresh on app activation, page visibility, and Cmd+R. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
a579414 to
c4e802c
Compare
…+R (#6500) Replace 120s Timer.scheduledTimer with didBecomeActiveNotification and refreshAllData observers. Eliminates the last periodic API polling timer in the desktop app. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevent overlapping fetches when activation and Cmd+R fire back-to-back. The isPolling flag ensures only one fetch runs at a time, avoiding duplicate message insertion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…dback - Replace reimplemented Date arithmetic with production-equivalent comparisons - Add rapid activation throttling test (10 activations 1s apart) - Add cooldown reset-after-expiry sequence test - Add notification deliverability test - Add CrispManager lifecycle tests (start idempotency, stop cleanup, markAsRead) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nfig (#6500) Per reviewer feedback: - Add PollingConfig.shouldAllowActivationRefresh(now:lastRefresh:) as the single source of truth for the >=activationCooldown check. - DesktopHomeView now calls the helper instead of inlining the comparison, so a >= → > regression in production is caught by the unit tests. - Remove race-prone CrispManager singleton lifecycle tests that asserted on state that was already zero before the call. - Add backward-clock-skew test and tighten the boundary test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@beastoin No issues found in the current diff. I rechecked the event-driven refresh path across Please merge when ready. by AI for @beastoin |
|
Required fixes before merge:
Test results:
Please add coverage for the uncovered branches and rerun when the unrelated desktop test blockers are out of the way. by AI for @beastoin |
…6500) Dead code after polling timer removal — all 7 call sites use the default. Simplifies the API to match the event-driven phase-1 scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extracts the single-entry guard pattern into a named, testable type. Used by ChatProvider.pollForNewMessages to prevent overlapping fetches when didBecomeActive + Cmd+R fire back-to-back. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the ad-hoc isPolling bool + guard/defer pattern with ReentrancyGate for clearer semantics and unit-testability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers: first enter, overlap blocking, enter-after-exit, repeated cycles, 3-way overlap → single entry, and spurious-exit safety. Addresses CP8 tester feedback that the isPolling in-flight guard had no regression coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers start() idempotency (no observer replacement on second call), stop() nils both observers, stop() idempotency, markAsRead() advances persisted timestamp + clears unreadCount, and safe behavior with empty timestamps. Addresses CP8 tester feedback that the event-driven Crisp branch had no regression coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@beastoin Addressed CP8 tester feedback with 4 new commits:
Verification: by AI for @beastoin |
|
Required fixes before merge:
Test results:
Please push a follow-up when those are fixed. by AI for @beastoin |
Reviewer round 2: prior doc claimed exit() was safe after a failed tryEnter(), but exit() unconditionally clears isInFlight — a spurious call while another caller holds the gate would reopen it and allow overlapping operations. Tighten the doc to spell out that only the caller that got `true` from tryEnter() owns the gate, show the canonical `guard`/`defer` usage, and explicitly note that exit() does not validate ownership.
…#6500) Reviewer round 2: the old testExitWithoutEnterIsSafe asserted a contract the implementation does not enforce — exit() unconditionally clears isInFlight, so a stray call really would reopen the gate. Swap in testGuardDeferPatternOnlyExitsWhenOwnerEntered, which models the canonical ChatProvider.pollForNewMessages() usage where the guard early-returns before the defer is registered, so only owning callers ever hit exit().
#6500) Reviewer round 2: lifecycle tests called start() which unconditionally fired pollForMessages(), hitting APIClient.shared and depending on local auth/network state. Add an opt-out parameter (defaults to true for real production callers) so CrispManagerLifecycleTests can exercise the observer-registration path hermetically without touching the network or firing real macOS notifications.
Reviewer round 2: tests invoked start() which fired pollForMessages() against APIClient.shared and depended on local auth state, so they were really integration tests. Pass performInitialPoll: false so each test only exercises observer registration/removal and timestamp advancement — no network, no auth, no real notifications.
CP7 round 2 — addressed reviewer feedbackTwo follow-up issues from the CP7 re-review on the CP8 test additions: 1.
2.
Build verified clean ( by AI for @beastoin |
…6500) Reviewer round 3: the prior testGuardDeferPatternOnlyExitsWhenOwnerEntered only called criticalSection() sequentially, so every invocation hit the happy path. A regression that put `defer { gate.exit() }` above `guard gate.tryEnter()` in production would still pass the test. Rewrite as testGuardDeferPatternNonOwnerDoesNotCallExit: the test itself holds the gate as caller A, then invokes criticalSection() as caller B while A is still in-flight. Asserts B registers no exit, the gate is still held by A (not reopened), and caller C can acquire normally after A releases.
CP7 round 3 — fixed regression guard on
|
…bservability (#6500) CP8 tester round 1 gap: CrispManagerLifecycleTests verifies observer token idempotency but never proves that posting didBecomeActive or .refreshAllData actually reaches pollForMessages(). If a future edit subscribed to the wrong notification name or dropped the wiring, the current lifecycle suite would not catch it. Add a @published private(set) counter that increments at the top of pollForMessages() (before the auth-backoff guard and the network task), so lifecycle tests can post each notification and assert the counter advances. The counter has no runtime cost beyond a single integer write per poll and no production subscribers.
…ver tests (#6500) CP8 tester round 1 gap: the PR replaces the 30s Timer.publish inside TasksStore.init() with didBecomeActive + .refreshAllData sinks, but there is no test coverage proving the new observer subscriptions actually fire refreshTasksIfNeeded(). A regression (wrong notification name, dropped .store(in: &cancellables)) would ship undetected. Add a @published counter that increments at the top of refreshTasksIfNeeded() before any early-exit guards. TasksStoreObserverTests can then post each notification and assert the counter advanced, proving the observer wiring without needing auth state, the network, or the singleton's page-visibility state.
…r observer tests (#6500) CP8 tester round 1 gap: the PR replaces the 30s Timer.publish inside MemoriesViewModel.init() with didBecomeActive + .refreshAllData sinks, but there is no test coverage proving the new subscribers actually fire refreshMemoriesIfNeeded() when the notifications post. Add a @published counter that increments at the top of refreshMemoriesIfNeeded() before any early-exit guards. Because MemoriesViewModel is not a singleton, MemoriesViewModelObserverTests can construct a fresh instance, post each notification, and assert the counter advanced — proving the observer wiring without touching the network, auth state, or the page-visibility guard.
…6500) CP8 tester round 1 gap: prior lifecycle tests checked observer token idempotency but not that the observers actually routed to the poll method. Adds three tests that post each notification and assert the new pollInvocations counter advances: - testDidBecomeActiveNotificationTriggersPoll: proves activation observer is wired to NSApplication.didBecomeActiveNotification and reaches pollForMessages(). - testRefreshAllDataNotificationTriggersPoll: proves refresh observer is wired to .refreshAllData (the Cmd+R notification) and reaches pollForMessages(). - testStoppedManagerDoesNotRespondToNotifications: proves stop() fully detaches both observers — neither notification advances the counter after the manager is stopped.
CP8 tester round 1 gap: the PR rewired TasksStore from a 30s Timer.publish to didBecomeActive + .refreshAllData sinks, but there was no coverage at all for that rewire. A regression in either subscription would ship undetected. Add three tests that each post a notification and assert the baseline-diffed refreshInvocations counter advances: - testDidBecomeActiveNotificationTriggersRefresh: proves the activation sink reaches refreshTasksIfNeeded(). - testRefreshAllDataNotificationTriggersRefresh: proves the Cmd+R sink reaches refreshTasksIfNeeded(). - testBothNotificationsTriggerIndependentRefreshes: proves the two sinks are independent subscriptions, not a single multiplexed one. Uses baseline diffing because TasksStore is a singleton — the counter persists across tests, but each test reads its own baseline first.
CP8 tester round 1 gap: the PR rewired MemoriesViewModel from a 30s Timer.publish to didBecomeActive + .refreshAllData sinks, but there was no coverage at all for that rewire. MemoriesViewModel is not a singleton, so each test constructs a fresh instance (which runs init() and registers the subscribers) and posts each notification: - testDidBecomeActiveNotificationTriggersRefresh: proves activation subscription reaches refreshMemoriesIfNeeded(). - testRefreshAllDataNotificationTriggersRefresh: proves Cmd+R subscription reaches refreshMemoriesIfNeeded(). - testDeallocatedViewModelDoesNotLeakObservers: proves the `[weak self]` capture in both sinks lets the view model deallocate cleanly — if the capture misbehaved, posting the notifications after the instance is gone would crash.
CP8 round 2 — coverage for observer wiringTester round 1 flagged 5 coverage gaps. Addressed 3 at the unit level with a lightweight Commits (
Pushed back on 2 gaps as disproportionate:
Build verified clean ( by AI for @beastoin |
…6500) Reviewer round 5: the test-only counter was declared @published, so every activation / Cmd+R refresh emitted objectWillChange on CrispManager — invalidating any SwiftUI view observing it even though the counter never drives UI. Make it plain `private(set) var`. Tests still read it directly via @testable import; production pays zero SwiftUI invalidation cost beyond a single integer write per call.
…#6500) Reviewer round 5: the test-only counter was declared @published, so every activation / Cmd+R refresh emitted objectWillChange on TasksStore — invalidating any SwiftUI view observing it. Make it plain `private(set) var`. Production pays zero SwiftUI invalidation cost beyond a single integer write per call.
…cations (#6500) Reviewer round 5: the test-only counter was declared @published, so every activation / Cmd+R refresh emitted objectWillChange on MemoriesViewModel — invalidating its SwiftUI observers. Make it plain `private(set) var`. Production pays zero SwiftUI invalidation cost beyond a single integer write per call.
CP7 round 5 — dropped
|
Summary
Eliminates all data-sync polling timers from the desktop app, replacing with event-driven refresh (app activation + Cmd+R). Instead of polling every 15-120s whether data changed or not, the app only fetches data when the user actually needs it.
Architecture: Polling → Event-Driven
What triggers data refresh now
didBecomeActiveNotification) — user switches to the app, all visible data refreshes immediatelyisActiveguards)Out of scope
getConversationsto check if a stuck local recording was already uploaded. Removing it would cause lost transcriptions.Expected impact
Review cycle changes
Trade-off
If the user is staring at the desktop app without interacting, and data changes on another device, they won't see it until they press Cmd+R or switch away and back. Acceptable because the old model caused 800 daily 504s.
Closes #6500 (Phase 1)
by AI for @beastoin