Fix tasks disappearing on Dashboard click (empty API guard)#5815
Fix tasks disappearing on Dashboard click (empty API guard)#5815
Conversation
… paths The v135 fix (b984e07) only guarded forceReconcileOnLoad() against empty API responses. The same vulnerability existed in refreshTasksIfNeeded() and reconcileWithAPIIfNeeded() — when API returns 0 tasks (transient error or empty 200), hardDeleteAbsentTasks with an empty set deletes ALL synced tasks, leaving only local-only items. This is triggered when user clicks Dashboard (isActive=true fires both methods). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…re query failures get_action_items and get_memories_filtered had a pattern where Firestore query errors would `break` from the pagination loop and return Ok(vec![]). This meant the handler's Err branch was never reached, so the HTTP 500 fix (commit 35a8d4d) was bypassed — clients received 200 OK with empty items. Changed `break` to `return Err(...)` so Firestore errors propagate to the handler and result in proper HTTP 500 responses. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hardDeleteAbsentTasks and markAbsentTasksAsStaged now reject empty apiIds sets, preventing all synced tasks from being wiped. This is the last line of defense if an empty API response somehow reaches the storage layer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR fixes a data-loss bug where switching to the Dashboard caused all locally-cached tasks to be silently hard-deleted. The root cause was a Rust Firestore pagination loop that Key changes:
The Rust fix is the correct primary repair; the Swift guards are defense-in-depth for future regressions. One minor edge case to be aware of: the Swift empty guards also fire when a user genuinely has 0 incomplete tasks (all completed/deleted on mobile), preventing stale SQLite records from being cleaned up until the user adds a new task. Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant UI as Dashboard (Swift)
participant TS as TasksStore
participant API as Rust Backend
participant FS as Firestore
participant DB as Local SQLite
UI->>TS: onAppear / page activation
TS->>TS: refreshTasksIfNeeded()
TS->>API: GET /action_items (incomplete)
API->>FS: runQuery (action_items)
alt BEFORE FIX — Firestore error
FS-->>API: HTTP 4xx/5xx
API-->>API: tracing::error + break loop
API-->>TS: HTTP 200, items: []
TS->>DB: hardDeleteAbsentTasks(apiIds: {})
DB-->>DB: DELETE all synced incomplete tasks 💥
TS-->>UI: incompleteTasks = []
else AFTER FIX — Firestore error
FS-->>API: HTTP 4xx/5xx
API-->>API: tracing::error + return Err(...)
API-->>TS: HTTP 500
TS-->>TS: catch block — log error, skip reconcile ✅
TS-->>UI: incompleteTasks unchanged
else AFTER FIX — Success, user has tasks
FS-->>API: HTTP 200, documents
API-->>TS: HTTP 200, items: [...]
TS->>DB: syncTaskActionItems(items)
TS->>DB: hardDeleteAbsentTasks(apiIds: {...}) if !empty
DB-->>TS: deleted count
TS-->>UI: incompleteTasks updated
else AFTER FIX — Success, 0 tasks (edge case)
FS-->>API: HTTP 200, 0 documents
API-->>TS: HTTP 200, items: []
TS->>TS: empty guard fires — skip reconcile ⚠️
Note over TS,DB: Stale SQLite records not cleaned up
TS-->>UI: incompleteTasks unchanged
end
Last reviewed commit: "fix(desktop): add em..." |
| if allApiIds.isEmpty { | ||
| log("TasksStore: Periodic reconciliation skipped — API returned zero task IDs (possible backend error)") | ||
| return | ||
| } |
There was a problem hiding this comment.
Empty guard silently breaks reconciliation for users with 0 legitimate tasks
The guard correctly defends against a stale backend error returning an empty 200. However, now that the Rust fix properly propagates failures as Err (returning HTTP 500), an empty 200 is also the correct response for a user who has genuinely deleted or completed all their tasks on mobile.
For that user, allApiIds will be empty, the guard fires, lastReconciliationDate is not updated (because the function returns early), so reconciliation is retried every 5 minutes — but it always hits the guard, meaning stale SQLite records (incomplete tasks that were completed/deleted on mobile) will persist in local storage and remain visible in the UI indefinitely.
The root fix (Rust return Err) is the right primary mechanism; the Swift guard is intended as a fallback for future regressions. Consider adding a warning log that makes the distinction explicit, or check whether this is actually a user with 0 legitimate tasks vs. a potential error — for example by checking lastReconciliationDate == nil (first run) before skipping:
// Safety guard: skip if API returned zero task IDs (possible backend error / empty 200).
// Note: also skips for users who genuinely have 0 incomplete tasks; stale local records
// will not be cleaned up in that case until they add a new task.
if allApiIds.isEmpty {
log("TasksStore: Periodic reconciliation skipped — API returned zero task IDs (possible backend error or user has 0 tasks)")
return
}The existing comment on line 345 implies this is only for errors, but it also silently applies to legitimate empty states.
| func markAbsentTasksAsStaged(apiIds: Set<String>) async throws { | ||
| // Safety guard: never wipe all tasks if the API set is empty (backend error) | ||
| guard !apiIds.isEmpty else { | ||
| log("ActionItemStorage: markAbsentTasksAsStaged skipped — empty API set") | ||
| return | ||
| } |
There was a problem hiding this comment.
markAbsentTasksAsStaged actually hard-deletes records — misleading function name
This is pre-existing code, but the guard added by this PR draws attention to it. The function is named markAbsentTasksAsStaged (suggesting a soft/staged deletion), yet its implementation calls try record.delete(database) — a hard delete. Additionally, unlike hardDeleteAbsentTasks, this function does not filter by backendSynced == true, meaning it can delete locally-created tasks that have never been pushed to the backend.
Since this PR adds an identical empty-set guard to both functions, clarifying the naming and the backendSynced gap would reduce confusion about which callers should use which function.
…dware#5815) ## Summary - **Root cause**: Rust backend's `get_action_items` Firestore query silently swallowed errors (`break` instead of `return Err`), returning `Ok(vec![])`. The handler's HTTP 500 fix (commit 35a8d4d) was bypassed because it only caught `Err` results. - When user clicked Dashboard, `refreshTasksIfNeeded()` and `reconcileWithAPIIfNeeded()` ran reconciliation against the empty response, hard-deleting all synced tasks from SQLite. - Same `break` pattern existed in `get_memories_filtered`. ## Changes 1. **Rust backend** (`firestore.rs`): Changed `break` to `return Err(...)` on Firestore query failures in `get_action_items` and `get_memories_filtered`, so errors propagate to handlers and result in HTTP 500 2. **Swift** (`TasksStore.swift`): Added `!response.items.isEmpty` guard to `refreshTasksIfNeeded()` and `allApiIds.isEmpty` guard to `reconcileWithAPIIfNeeded()` — matches existing guard in `forceReconcileOnLoad()` 3. **Swift** (`ActionItemStorage.swift`): Added empty-set guards to `hardDeleteAbsentTasks()` and `markAbsentTasksAsStaged()` as last line of defense ## Test plan - [x] Swift app builds clean - [x] Rust backend compiles clean - [x] Verified affected user (Salman, UID `6HbrDL1WZ4PYqwa6O7N4uWT0nPj1`) has 17 incomplete tasks in Firestore and macOS FCM token - [ ] After release, verify Salman's tasks persist across Dashboard navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
get_action_itemsFirestore query silently swallowed errors (breakinstead ofreturn Err), returningOk(vec![]). The handler's HTTP 500 fix (commit 35a8d4d) was bypassed because it only caughtErrresults.refreshTasksIfNeeded()andreconcileWithAPIIfNeeded()ran reconciliation against the empty response, hard-deleting all synced tasks from SQLite.breakpattern existed inget_memories_filtered.Changes
firestore.rs): Changedbreaktoreturn Err(...)on Firestore query failures inget_action_itemsandget_memories_filtered, so errors propagate to handlers and result in HTTP 500TasksStore.swift): Added!response.items.isEmptyguard torefreshTasksIfNeeded()andallApiIds.isEmptyguard toreconcileWithAPIIfNeeded()— matches existing guard inforceReconcileOnLoad()ActionItemStorage.swift): Added empty-set guards tohardDeleteAbsentTasks()andmarkAbsentTasksAsStaged()as last line of defenseTest plan
6HbrDL1WZ4PYqwa6O7N4uWT0nPj1) has 17 incomplete tasks in Firestore and macOS FCM token🤖 Generated with Claude Code