TASK_OPTIMISTIC_RENDERING_DESIGN.md42.2 KBView on GitHub # Task optimistic rendering — server-authoritative writes
## 1) Introduction — goal, present state, future state
We want a task card that disappears when you delete, complete, or snooze it to *stay* disappeared, without the client having to run a consistency protocol to make that true. Today the three task surfaces render solely from the `userTasksSlice` Zustand mirror, which `hydrateTodoTasks` reconciles unconditionally against every `listUserTasks` response — while `optimisticDeleteTask` deliberately withholds the server write for five seconds, so any refetch that lands in that window legitimately re-inserts the card, and a reload in that window loses the delete outright. The change is to stop lying to the server: dispatch every mutation on click, make Undo a real inverse mutation against the soft-delete that already exists server-side, move the one slow destructive side effect (the Gmail draft delete) off the request path behind a 30-second grace period that a restore cancels, and take the task feed off focus/mount refetching so nothing but an explicit post-mutation invalidation can contradict the slice.
## 2) Present state
### 2.1 Architecture diagram
```text
┌──────────────────────────────────────┐
│ React Query cache │
│ [['userTasks','listUserTasks'], │
│ {status:'todo',withConversation, │
│ limit:500,sortDueDate:'asc'}] │
│ ONE entry, 2-3 observers │
└───────────────┬──────────────────────┘
refetch triggers │
───────────────── │ data.tasks
refetchOnWindowFocus:true ▼
refetchOnMount:true ┌──────────────────────┐
staleTime:60s │ useHydrateTasksSlice │
9 stray invalidations └──────────┬───────────┘
│ hydrateTodoTasks(incoming)
│ · upsert every incoming
│ · DELETE every slice task
│ with status==='todo' not
│ in incoming ◄── unconditional
▼
┌────────────────┐ ┌────────────────────────┐
│ AgendaDocument │──setTasks─►│ userTasksSlice.tasks │◄──setTasks── Cedar-OS SSE
│ (date-ranged │ (upsert, │ Record<id, Task> │ taskCreated /
│ listUserTasks)│ no └───────────┬────────────┘ taskUpdated
└────────────────┘ reconcile) │ useTodoTasks()
▼
┌────────────────────────────────────────┐
│ TaskKanbanBoard · TaskListView · │
│ TaskGroupsAccordion │
└────────────────────────────────────────┘
▲
│ removeTask / setTasks / restoreTask
┌────────────────────┴───────────────────┐
│ useOptimisticTaskActions (1315 LOC) │
│ delete: remove now, POST in 5000ms │
│ complete: remove now, POST now │
│ snooze: patch now, POST now │
└────────────────────────────────────────┘
```
### 2.2 Step-by-step walkthrough
The delete path, which is where the defect is sharpest.
1. **User clicks delete on a card** — `onDelete` at [TaskKanbanBoard.tsx:293](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx), [TaskListView.tsx:355](apps/mail/modules/userTasks/components/TaskListView.tsx) or [TaskGroupsAccordion.tsx:254](apps/mail/modules/userTasks/components/TaskGroupsAccordion.tsx). All three call `optimisticDeleteTask(id)` with `showUndo` defaulted to `true`.
2. **`optimisticDeleteTask` resolves the task** — [use-optimistic-task-actions.ts:93](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Reads `getTask(taskId)` from the slice, falling back to `getConversation(conversationId).data.userTasks` when the task is not in the slice (the conversation-timeline callers).
- Snapshot state captured for the undo path:
```ts
const savedDateBuckets = { ...useCedarStore.getState().taskIdsByDateKey };
const savedConversationData = getConversation(taskConversationId)?.data;
```
3. **Marks the conversation as updating** — `setUpdatingTasks(taskConversationId, true)` at [use-optimistic-task-actions.ts:138](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Cleared only on the undo path ([:198](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)), the error path ([:273](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)), and asynchronously by the agent-execution completion event at [clientExecutionResponseProcessors.ts:510](apps/mail/modules/cedar-os/src/store/agentConnection/responseProcessors/clientExecutionResponseProcessors.ts) — so on the success path the flag is only cleared if that execution actually completes.
4. **Optimistic removal from the slice** — `removeTask(taskId)` at [userTasksSlice.ts:523](apps/mail/modules/userTasks/slice/userTasksSlice.ts) deletes the key and bumps `lastTasksFetchedAt`. The card vanishes. This is the only step that makes the UI change.
5. **`cancelQueries` band-aid** — [use-optimistic-task-actions.ts:146](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Cancels `listUserTasks` fetches *in flight at this instant*. A fetch started 50ms later is unaffected — this is the core inadequacy.
6. **Date-bucket bookkeeping** — [use-optimistic-task-actions.ts:149-155](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) filters the id out of every `taskIdsByDateKey` bucket. Nothing renders from these buckets (see Phase 1).
7. **Conversation fan-out** — [use-optimistic-task-actions.ts:158-176](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) writes the same intent into `conversationsSlice` via `setConversations` *and* into the `crm.getConversation` React Query cache via `setQueryData`. Third write of one intent.
8. **Undo toast** — [use-optimistic-task-actions.ts:189-225](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). Its `onClick` flips a closure variable `undoClicked`, restores the three snapshots, and clears the pending timeout from `pendingDeletionsRef`.
9. **Deferred server write** — `setTimeout(..., showUndo ? 5000 : 0)` at [use-optimistic-task-actions.ts:228](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts). **For 5000ms the server has not been told anything.**
- State of the world during the window:
```json
{ "client": { "sliceHasTask": false, "cardVisible": false },
"server": { "status": "todo" },
"rqCache": { "containsTask": true } }
```
10. **Any refetch lands** — triggered by `refetchOnWindowFocus` / `refetchOnMount` ([query-provider.tsx:50-56](apps/mail/providers/query-provider.tsx), never overridden by a task surface) or one of the nine stray `listUserTasks` invalidations. The response correctly still contains the task.
11. **`useHydrateTasksSlice` fires** — [use-hydrate-tasks-slice.ts:25](apps/mail/modules/userTasks/hooks/use-hydrate-tasks-slice.ts). Its `useEffect` depends on the `tasks` array identity. Because `structuralSharing: true` ([query-provider.tsx:56](apps/mail/providers/query-provider.tsx)) preserves the array reference when the payload is byte-identical, the effect only re-runs when *some* field actually changed — a `withConversation` join field, an agent write, another task arriving. **This is precisely why the bug is intermittent, and why it will not reproduce on demand.**
12. **`hydrateTodoTasks` re-inserts the task** — [userTasksSlice.ts:486-518](apps/mail/modules/userTasks/slice/userTasksSlice.ts). It upserts every incoming task and deletes only those absent from the response. The deleted task is present, so it comes back.
```json
{ "sliceHasTask": true, "cardVisible": true, "elapsedSinceDelete": "1400ms" }
```
13. **The timeout finally fires** — `deleteTaskServer({ taskId })` at [use-optimistic-task-actions.ts:234](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts), followed by an `invalidateQueries` that eventually removes it again. Net user experience: the card disappears, returns, and disappears a second time.
14. **Server-side `deleteTask`** — [user-tasks.ts:1542](apps/server/src/trpc/routes/user-tasks.ts). Order of operations:
- Loads the task ([:1564](apps/server/src/trpc/routes/user-tasks.ts)).
- Extracts `draftId` / `threadId` / `emailHeaderMessageId` from `taskActionData` ([:1583](apps/server/src/trpc/routes/user-tasks.ts)).
- **Awaits `getActiveConnectionForUserId` then `deleteDraft`** ([:1598-1636](apps/server/src/trpc/routes/user-tasks.ts)) — an irreversible Gmail call, on the request path, before anything is written.
- Only then writes `status: 'deleted'` ([:1638-1651](apps/server/src/trpc/routes/user-tasks.ts)) and `safeReindexConversation`.
- Does **not** call `cancelTaskScheduling`, unlike `updateTask` ([:696](apps/server/src/trpc/routes/user-tasks.ts)) and the due-date path ([:848](apps/server/src/trpc/routes/user-tasks.ts)).
**Reload during the window** is the worst case: the timer dies with the page, so the server is never told; and `listUserTasks` is persisted to IndexedDB — the blacklist at [query-persistence/index.ts:61-73](apps/mail/lib/query-persistence/index.ts) contains only `documents.getDoc` and `aop.getCompositePlaybook` — so the deleted card is restored from disk with its original `dataUpdatedAt`. The delete is lost in both directions at once.
**Corrections to earlier assumptions, recorded so they are not re-litigated:**
- The three surfaces do **not** each mount their own query. [TaskKanbanBoard.tsx:158-164](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx), [TaskListView.tsx:159-165](apps/mail/modules/userTasks/components/TaskListView.tsx) and [TaskGroupsAccordion.tsx:61-67](apps/mail/modules/userTasks/components/TaskGroupsAccordion.tsx) pass byte-identical input, so React Query keeps one cache entry with 2-3 observers and dedupes the network. Consolidation buys fewer `refetchOnMount` triggers, not less traffic — it is not a correctness fix on its own.
- Complete does **not** defer on any task surface. All three call the immediate `optimisticCompleteTask` ([:935](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)). Only [FilterSortConfigurationRow.tsx:1589](apps/mail/modules/crm/components/conversation-canvas/FilterSortConfigurationRow.tsx), [ConversationTaskRow.tsx:149](apps/mail/modules/conversations/components/strategicOverview/ConversationTaskRow.tsx) and [TimelineTaskItem.tsx:174](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) use `optimisticCompleteTaskDelayed`. **Complete still flakes**, which is the evidence that the 5s window is a contributing cause and not the whole story.
- Soft delete already exists server-side ([user-tasks.ts:1638](apps/server/src/trpc/routes/user-tasks.ts) sets `status:'deleted'`; [listUserTasks:91-97](apps/server/src/trpc/routes/user-tasks.ts) filters it out). Restore already works: `updateTaskStatus` ([:1206-1250](apps/server/src/trpc/routes/user-tasks.ts)) constrains the *target* status to `todo|done` but its `WHERE` is only `id + userId` — there is no gate on the current status, so `updateTaskStatus({ status: 'todo' })` un-deletes a `deleted` row today.
- Tasks are the outlier in this repo. Threads already ship immediate-write plus inverse-undo: `optimisticDeleteThreads` at [use-optimistic-actions.ts:428-476](apps/mail/modules/threads/rendering/use-optimistic-actions.ts) calls the server without a timer and pushes an undo entry; `executeUndo` at [:806-885](apps/mail/modules/threads/rendering/use-optimistic-actions.ts) dispatches real inverse mutations; `UNDO_TTL_MS = 30_000` at [threadSlice.ts:217](apps/mail/modules/threads/threadList/store/threadSlice.ts).
**Dead and near-dead code on this path:**
- `executionIdsByDateKey`, `tasksViewSelectedDate`, `navigateTasksViewDateLeft/Right`, `getTasksForDateKey`, `getExecutionsForDateKey`, `getTaskIdsForDateKey`, `getTasksForDate`, `getTasksBeforeDate`, `getTasksAfterDate` ([userTasksSlice.ts:331-345, 757-863, 901-993](apps/mail/modules/userTasks/slice/userTasksSlice.ts)) have **no consumers** outside the slice and [UserTasksDebuggerTab.tsx](apps/mail/modules/debugger/components/UserTasksDebuggerTab.tsx). `setExecutionIdsByDateKey` is never called at all.
- `taskIdsByDateKey` is written by [TaskCommandBar.tsx:185-208](apps/mail/modules/userTasks/components/TaskCommandBar.tsx) and by nine sites in the optimistic hook, and read only by the debugger tab. No rendering surface consumes it.
- [use-organized-tasks.ts](apps/mail/modules/userTasks/hooks/use-organized-tasks.ts) (282 LOC) has zero consumers.
- [messageRenderers.tsx:1102](apps/mail/modules/cedar-os/src/components/renderers/messageRenderers.tsx) invalidates `queryKey: ['userTasks']`, which cannot partial-match the tRPC key shape `[['userTasks','listUserTasks'], input]` — a silent no-op.
**Out of scope, documented because it will otherwise be mistaken for this bug:** every task mutation calls `triggerExecuteFromClientSend` ([use-optimistic-task-actions.ts:250](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and five other sites) with an event summary like `User DELETED task: "..."`. That execution's sub-agent has a `create-task` tool. A near-duplicate task appearing seconds later is indistinguishable to the user from "it popped back in", and **no client-side rendering change fixes it**. Phase 6 measures it.
## 3) Designed state
### 3.1 Architecture diagram
```text
┌──────────────────────────────────────┐
│ React Query cache │
│ listUserTasks({status:'todo',...}) │
│ refetchOnWindowFocus: FALSE │
│ refetchOnMount: FALSE │
│ staleTime: 5 min │
└───────────────┬──────────────────────┘
refetch triggers now ONLY: │
· explicit refetch on /tasks entry │ data.tasks
· post-mutation invalidation ▼
┌──────────────────────┐
│ useTodoTasksQuery() │ ← single owner
│ query + hydration │
└──────────┬───────────┘
│ hydrateTodoTasks
▼
┌────────────────────────┐
│ userTasksSlice.tasks │
│ + taskUndoStack │
└───────────┬────────────┘
│ useTodoTasks()
▼
┌──────────────────────────────────────┐
│ TaskKanbanBoard · TaskListView · │
│ TaskGroupsAccordion │
└──────────────────────────────────────┘
▲
│ removeTask (instant)
┌──────────────────┴───────────────────┐
│ useOptimisticTaskActions │
│ delete: remove + POST *now* │
│ undo: POST updateTaskStatus todo │
└──────────────────┬───────────────────┘
│
═════════════════════════════════════▼══════════════════════════
SERVER
deleteTask: cancelTaskScheduling → UPDATE status='deleted'
→ reindex → RESPOND ◄── ~150-350ms, no Gmail call
└─► void IIFE: wait 30s
→ re-read row
→ status still 'deleted'? deleteDraft
→ status back to 'todo'? skip
```
### 3.2 Step-by-step walkthrough
1. **User clicks delete** — unchanged call sites; `optimisticDeleteTask(id)` in [use-optimistic-task-actions.ts](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
2. **Resolve the task and push an undo entry** — the task object is pushed onto a new `taskUndoStack` in the slice, mirroring `pushUndo` at [use-optimistic-actions.ts:449](apps/mail/modules/threads/rendering/use-optimistic-actions.ts).
```ts
pushTaskUndo({ type: 'delete', taskId, task, timestamp: Date.now() })
```
3. **Optimistic removal** — `removeTask(taskId)`. Unchanged; the card vanishes on the same frame.
4. **Server write dispatched immediately** — `await deleteTaskServer({ taskId })`. No `setTimeout`, no `pendingDeletionsRef`, no `undoClicked` closure, no `cancelQueries`.
- World state ~250ms later:
```json
{ "client": { "cardVisible": false },
"server": { "status": "deleted", "draftDeletePending": "t+30s" } }
```
5. **Server `deleteTask` reordered** — [user-tasks.ts:1542](apps/server/src/trpc/routes/user-tasks.ts):
1. Load the task; capture `draftId` / `threadId` / `emailHeaderMessageId`.
2. `await cancelTaskScheduling(ctx.c.env, task.id)` — **new**, closing the gap versus [:696](apps/server/src/trpc/routes/user-tasks.ts).
3. `UPDATE user_tasks SET status='deleted'`.
4. `safeReindexConversation`.
5. **Respond.**
6. Post-response `void` IIFE schedules the draft cleanup:
```ts
void (async () => {
await new Promise((r) => setTimeout(r, DRAFT_DELETE_GRACE_MS)); // 30_000
const fresh = await db.query.userTasks.findFirst({ where: eq(userTasks.id, taskId) });
if (fresh?.status !== 'deleted') return; // restored — leave the draft alone
await deleteDraft(connectionId, { draftId, emailHeaderMessageId, threadId }, traceCtx);
})();
```
- **Accepted failure mode:** an ECS task restart inside the 30s window orphans one Gmail draft. That is benign, and strictly better than today's failure mode, which is a *lost delete*. It is also more durable than today's client timer, which dies on navigation or reload. This grace period was chosen explicitly over never deleting the draft, deleting it immediately, and a durable SQS delayed message.
6. **Undo toast** — the toast's `onClick` calls a real mutation, not a `clearTimeout`:
```ts
await updateTaskStatusServer({ taskId, status: 'todo' })
```
`updateTaskStatus` ([:1206](apps/server/src/trpc/routes/user-tasks.ts)) has no current-status gate, so this un-deletes. It re-schedules the task when `agentExecutionEnabled` is set, undoing step 5.2. Because the row flips back inside the grace window, the IIFE's re-read at step 5.6 sees `'todo'` and skips the Gmail delete — the draft survives.
7. **Undo is durable.** It survives a reload and works from another tab, because the state lives in Postgres rather than in a closure. The undo *entry* expires from the client stack after `UNDO_TTL_MS`.
8. **The feed never contradicts the slice** — `useTodoTasksQuery()` in the new [use-todo-tasks-query.ts](apps/mail/modules/userTasks/hooks/use-todo-tasks-query.ts) owns the one `queryOptions` with `refetchOnWindowFocus: false`, `refetchOnMount: false`, `staleTime: 5 * 60_000`, and calls `useHydrateTasksSlice` itself. The three surfaces each collapse to a single call.
9. **Freshness is restored explicitly** — [tasks/layout.tsx](apps/mail/app/\(routes\)/tasks/layout.tsx) issues one `refetchQueries` on route entry. Without this, the IndexedDB-persisted cache would render a consistently stale board with no network call, trading an intermittent bug for a constant one.
10. **`hydrateTodoTasks` is unchanged** and stays unconditional. It is now *correct* to be unconditional, because by the time any response can arrive the server already knows about the mutation.
### 3.3 Schema
No database migration. `user_tasks.status` already carries the soft-delete state this design depends on; it is reproduced below because the restore path is defined in terms of it. The changes are entirely in the frontend slice types.
Full schema:
```ts
// ── apps/server/src/db/aop-schema.ts — EXISTING, unchanged, shown for reference ──
// The soft-delete states this design relies on already exist and are already
// filtered out of listUserTasks (user-tasks.ts:91-97).
type UserTaskStatus = 'todo' | 'done' | 'deleted' | 'agent_deleted' | 'recommended';
// ── apps/mail/modules/userTasks/slice/userTasksSlice.ts ──
/** NEW — one entry per undoable task mutation. Mirrors threadSlice's undo entry shape. */
export interface TaskUndoEntry {
type: 'delete' | 'complete'; // NEW — which inverse mutation to dispatch
taskId: string; // NEW
task: HydratedUserTask; // NEW — full row, for instant local restore
threadId?: string; // NEW — set by optimisticDeleteTaskAndThread (compound undo)
timestamp: number; // NEW — TTL basis, UNDO_TTL_MS = 30_000
}
export interface UserTasksState {
tasks: Record<string, HydratedUserTask>;
scheduledExecutions: Record<string, ScheduledExecution>;
taskUndoStack: TaskUndoEntry[]; // NEW
taskLabels: TaskLabel[];
snoozeDialogOpen: boolean;
snoozeDialogTaskId: string | null;
taskSelection: string[];
taskSelectionAnchorId: string | null;
executingTaskIds: string[];
sortingConfiguration: SortingConfiguration;
lastTasksFetchedAt: number;
lastExecutionsFetchedAt: number;
// REMOVED: taskIdsByDateKey — written by 10 sites, rendered by none
// REMOVED: executionIdsByDateKey — no writer at all
// REMOVED: tasksViewSelectedDate — read only by the debugger tab
}
export interface UserTasksSlice extends UserTasksState {
// Task management — unchanged
setTasks: (tasks: Record<string, HydratedUserTask>) => void;
hydrateTodoTasks: (incoming: HydratedUserTask[]) => void;
getTask: (taskId: string) => HydratedUserTask | undefined;
getTasks: () => HydratedUserTask[];
getTasksByStatus: (status: 'todo' | 'done') => HydratedUserTask[];
removeTask: (taskId: string) => void;
removeEmailTasksForConversation: (conversationId: string) => void;
restoreTask: (taskId: string, task: HydratedUserTask) => void;
updateTask: (taskId: string, updates: Partial<HydratedUserTask>) => void;
markTaskAsRead: (taskId: string) => void;
clearTasks: () => void;
pushTaskUndo: (entry: TaskUndoEntry) => void; // NEW
popTaskUndo: () => TaskUndoEntry | undefined; // NEW — drops TTL-expired entries
clearTaskUndoStack: () => void; // NEW
// Snooze dialog, bulk selection, executing state, scheduled executions,
// task labels, sorting configuration — all unchanged.
// REMOVED: setTaskIdsByDateKey, setTaskIdsForDateKey, setExecutionIdsByDateKey,
// getTaskIdsForDateKey, getExecutionIdsForDateKey, getTasksForDateKey,
// getExecutionsForDateKey, getTasksForDate, getTasksBeforeDate,
// getTasksAfterDate, getScheduledExecutionsAfterDate,
// setTasksViewSelectedDate, navigateTasksViewDateLeft,
// navigateTasksViewDateRight
}
```
```ts
// ── apps/server/src/trpc/routes/user-tasks.ts ──
/** NEW — grace period between the delete response and the irreversible Gmail draft delete. */
const DRAFT_DELETE_GRACE_MS = 30_000;
```
Relationship diagram:
```text
┌──────────────────────────────┐
│ user_tasks (pg) │
│──────────────────────────────│
│ id uuid PK │
│ userId text │──FK──► user.id
│ conversationId uuid │──FK──► conversations.id
│ taskGroupId uuid NULL │──FK──► task_groups.id
│ status enum │ 'todo'|'done'|'deleted'|
│ │ 'agent_deleted'|'recommended'
│ dueDate timestamptz │
│ agentExecutionEnabled bool │
│ taskActionData jsonb NULL │
│ ▼ contains (channel='email')
│ ┌────────────────────────┐ │
│ │ threadId string │ │──ref──► Gmail thread
│ │ draftId string? │ │──ref──► Gmail draft ◄─ deleted after
│ │ emailHeaderMessageId? │ │ DRAFT_DELETE_GRACE_MS,
│ └────────────────────────┘ │ skipped if status
└───────────┬──────────────────┘ returned to 'todo'
│
│ 1:1 (hydration; status='deleted' rows filtered out by listUserTasks)
▼
┌──────────────────────────────┐ ┌────────────────────────────┐
│ userTasksSlice.tasks │ │ userTasksSlice. │
│ Record<taskId, │ │ taskUndoStack │
│ HydratedUserTask> │◄─1:N───│ TaskUndoEntry[] │
│ │ taskId │────────────────────────────│
│ id · status · dueDate · │ │ type ('delete'|'complete') │
│ taskActionData · ... │ │ taskId ──────────┘ │
└───────────┬──────────────────┘ │ task (full snapshot) │
│ useTodoTasks() │ threadId? ──FK──► threads │
│ (status === 'todo') │ timestamp (TTL 30s) │
▼ └────────────────────────────┘
┌──────────────────────────────────────────────┐
│ TaskKanbanBoard · TaskListView · │
│ TaskGroupsAccordion │
└──────────────────────────────────────────────┘
```
## 4) Implementation phases
### Phase 1 — Delete the dead date-bucket subsystem
**Goal:** Remove ~550 lines of state that nothing renders, so later phases touch a smaller surface.
- [ ] Remove `taskIdsByDateKey`, `executionIdsByDateKey` and `tasksViewSelectedDate` from `UserTasksState` and `initialUserTasksState` in [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts).
- [ ] Remove the `TaskIdsByDateKey` / `ExecutionIdsByDateKey` types and `EMPTY_DATE_KEY_MAP` from [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts), keeping the `TaskDateKey` type only if a remaining consumer needs it.
- [ ] Remove the date-key actions and getters from `UserTasksSlice` and its implementation in [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts): `setTaskIdsByDateKey`, `setTaskIdsForDateKey`, `setExecutionIdsByDateKey`, `getTaskIdsForDateKey`, `getExecutionIdsForDateKey`, `getTasksForDateKey`, `getExecutionsForDateKey`, `getTasksForDate`, `getTasksBeforeDate`, `getTasksAfterDate`, `getScheduledExecutionsAfterDate`, `setTasksViewSelectedDate`, `navigateTasksViewDateLeft`, `navigateTasksViewDateRight`.
- [ ] Remove all nine `savedDateBuckets` / `setTaskIdsByDateKey` blocks from [use-optimistic-task-actions.ts](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) (lines 129-155, 201, 259, 342, 364-377, 462, 528, 602, 1036-1037, 1073, 1102-1108, 1151) and the now-unused `getDateKeyForDate` helper at [:16](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Remove `addTaskToDateBucket` / `removeTaskFromDateBucket` and their store subscriptions from [TaskCommandBar.tsx:139-210](apps/mail/modules/userTasks/components/TaskCommandBar.tsx), plus the `optimisticDateKeyRef` they feed.
- [ ] Remove the date-bucket panels from [UserTasksDebuggerTab.tsx:49-51, 93-105, 142-144, 182, 195-196](apps/mail/modules/debugger/components/UserTasksDebuggerTab.tsx).
- [ ] Delete [use-organized-tasks.ts](apps/mail/modules/userTasks/hooks/use-organized-tasks.ts) (282 LOC, zero consumers) and any export of it from [modules/userTasks/index.ts](apps/mail/modules/userTasks/index.ts).
- [ ] Fix the no-op invalidation at [messageRenderers.tsx:1102](apps/mail/modules/cedar-os/src/components/renderers/messageRenderers.tsx) — replace `queryKey: ['userTasks']` with `trpc.userTasks.listUserTasks.queryKey()`.
- [ ] Verify no remaining references: `rg "taskIdsByDateKey|executionIdsByDateKey|tasksViewSelectedDate|useOrganizedTasks" apps/mail` returns nothing.
**Tests:**
- [ ] Update [tests/modules/userTasks/userTasksSlice.test.ts](apps/mail/tests/modules/userTasks/userTasksSlice.test.ts) to drop any assertions on the removed state, keeping `hydrateTodoTasks` coverage intact.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks/userTasksSlice.test.ts`
- [ ] `pnpm --filter @cedar/mail types`
### Phase 2 — Single task-feed hook with explicit freshness
**Goal:** Make focus and mount stop refetching the task feed, without letting the persisted cache go stale.
- [ ] Create `apps/mail/modules/userTasks/hooks/use-todo-tasks-query.ts` exporting `TODO_TASKS_INPUT` (`{ status: 'todo', withConversation: true, limit: 500, sortDueDate: 'asc' }`), `todoTasksQueryKey()`, and `useTodoTasksQuery()` which calls `useQuery` with `refetchOnWindowFocus: false`, `refetchOnMount: false`, `staleTime: 5 * 60_000` and then `useHydrateTasksSlice(data?.tasks)`.
- [ ] Replace the inline `queryOptions` + `useHydrateTasksSlice` pair in [TaskKanbanBoard.tsx:138-152](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) with `useTodoTasksQuery()`.
- [ ] Replace the same pair in [TaskListView.tsx:158-166](apps/mail/modules/userTasks/components/TaskListView.tsx) with `useTodoTasksQuery()`, leaving the separate `status: 'done'` query untouched.
- [ ] Replace the same pair in [TaskGroupsAccordion.tsx:60-67](apps/mail/modules/userTasks/components/TaskGroupsAccordion.tsx) with `useTodoTasksQuery()`.
- [ ] Add a single `refetchQueries({ queryKey=[redacted] })` on mount in [tasks/layout.tsx](apps/mail/app/\(routes\)/tasks/layout.tsx), so entering `/tasks/*` always reconciles against the server despite `refetchOnMount: false`.
- [ ] Point `refetchTasks` at [TaskListView.tsx:273](apps/mail/modules/userTasks/components/TaskListView.tsx) at the shared `todoTasksQueryKey()` helper.
**Tests:**
- [ ] New `apps/mail/tests/modules/userTasks/todoTasksQuery.test.ts` asserting the hook's resolved options have `refetchOnWindowFocus === false`, `refetchOnMount === false`, `staleTime === 300_000`.
- [ ] New `apps/mail/tests/modules/userTasks/hydrationDoesNotResurrect.test.tsx`: render a surface, `removeTask(id)`, then deliver a `listUserTasks` response that still contains the task *with a changed field* (so `structuralSharing` cannot suppress the effect), and assert the card is still absent once the mutation has been dispatched. This is the primary regression lock.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks`
### Phase 3 — Server: fast, restore-safe `deleteTask`
**Goal:** `deleteTask` responds in one round trip, and the irreversible Gmail delete becomes cancellable by a restore.
- [ ] Add `const DRAFT_DELETE_GRACE_MS = 30_000;` near the top of [user-tasks.ts](apps/server/src/trpc/routes/user-tasks.ts).
- [ ] In `deleteTask` [user-tasks.ts:1542](apps/server/src/trpc/routes/user-tasks.ts), move the `getActiveConnectionForUserId` + `deleteDraft` block ([:1598-1636](apps/server/src/trpc/routes/user-tasks.ts)) out of the request path.
- [ ] Add `await cancelTaskScheduling(ctx.c.env, task.id)` before the status update in `deleteTask`, matching [:696](apps/server/src/trpc/routes/user-tasks.ts).
- [ ] After the status update and `safeReindexConversation`, schedule the draft cleanup in a post-response `void` IIFE that waits `DRAFT_DELETE_GRACE_MS`, re-reads the row, and returns early unless `status === 'deleted'`.
- [ ] Keep the existing swallow-and-log behavior for `deleteDraft` failures ([:1626-1636](apps/server/src/trpc/routes/user-tasks.ts)) inside the IIFE, and emit a `createStructuredLog` diagnostic for both the skip and the delete so the grace period is observable in Axiom.
- [ ] Ensure the IIFE does not use the request-scoped `db`/`conn` handle after `conn.end()` — open its own `createDb` handle and close it in a `finally`.
- [ ] In `updateTaskStatus` [user-tasks.ts:1206](apps/server/src/trpc/routes/user-tasks.ts), re-schedule the task via `scheduleTask` when the target status is `'todo'`, the previous status was `'deleted'`, and `agentExecutionEnabled` is true — restoring what step 3.2 cancelled.
- [ ] `pnpm deps:check` (this touches a tRPC route that imports task-scheduling services).
**Tests:**
- [ ] New server test: mock `deleteDraft` to never resolve; assert `deleteTask` still resolves and the row is `status: 'deleted'`.
- [ ] New server test: call `deleteTask`, then `updateTaskStatus({ status: 'todo' })` inside the grace window with fake timers; advance past `DRAFT_DELETE_GRACE_MS` and assert `deleteDraft` was never called.
- [ ] New server test: call `deleteTask`, advance past the grace window with no restore, assert `deleteDraft` was called exactly once.
- [ ] New server test: `deleteTask` calls `cancelTaskScheduling`; `updateTaskStatus('todo')` on a previously-deleted `agentExecutionEnabled` task calls `scheduleTask`.
- [ ] Regression lock: `listUserTasks` excludes `status: 'deleted'` rows.
- [ ] `pnpm --filter @cedar/server test`
### Phase 4 — Client: immediate writes and inverse undo
**Goal:** Delete the 5-second lie and the bookkeeping that existed only to serve it.
- [ ] Add `taskUndoStack`, `pushTaskUndo`, `popTaskUndo` and `clearTaskUndoStack` to [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts), with `UNDO_TTL_MS = 30_000` and TTL filtering on pop, mirroring [threadSlice.ts:217, 2501-2561](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [ ] Rewrite `optimisticDeleteTask` [use-optimistic-task-actions.ts:93-298](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) to push an undo entry, remove from the slice, and `await deleteTaskServer` immediately — deleting `pendingDeletionsRef`, the `undoClicked` closure, the `setTimeout`, the `cancelQueries` call at [:146](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and the `savedConversationData` snapshot.
- [ ] Change the undo toast action to dispatch `updateTaskStatus({ taskId, status: 'todo' })` and restore the slice from the undo entry, replacing the snapshot-restore block at [:196-224](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Clear `setUpdatingTasks(conversationId, false)` on the success path of `optimisticDeleteTask`, closing the gap left by [:138](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Delete `optimisticCompleteTaskDelayed` [use-optimistic-task-actions.ts:1001-1179](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and `pendingCompletionsRef`.
- [ ] Repoint [FilterSortConfigurationRow.tsx:1589](apps/mail/modules/crm/components/conversation-canvas/FilterSortConfigurationRow.tsx), [ConversationTaskRow.tsx:149](apps/mail/modules/conversations/components/strategicOverview/ConversationTaskRow.tsx) and [TimelineTaskItem.tsx:174](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) at `optimisticCompleteTask`, and remove `optimisticCompleteTaskDelayed` from the hook's return object at [:1303](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
- [ ] Give `optimisticCompleteTask` an undo entry of `type: 'complete'` whose inverse is `updateTaskStatus({ status: 'todo' })`, so completion gains the undo that the delayed variant used to provide.
- [ ] Make `optimisticDeleteTaskAndThread` [use-optimistic-task-actions.ts:300-313](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) push a single compound undo entry carrying `threadId`, so undo cannot restore the task while leaving its thread in the bin.
- [ ] Remove the now-redundant `cancelQueries` calls at [:359](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts) and [:951](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
**Tests:**
- [ ] New `apps/mail/tests/modules/userTasks/optimisticDelete.test.tsx`: assert `deleteTask` is called **without** advancing fake timers. This fails against the current code and is the regression lock for the whole design.
- [ ] Undo path: after delete, invoking the toast action calls `updateTaskStatus({ status: 'todo' })` and the card returns.
- [ ] Failure path: a rejected `deleteTask` restores the card and surfaces an error toast.
- [ ] Compound undo: `optimisticDeleteTaskAndThread` then undo restores both the task and the thread.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks`
- [ ] `pnpm --filter @cedar/mail types && pnpm --filter @cedar/mail lint`
### Phase 5 — Conditional: task tombstones for the non-React-Query writers
**Goal:** Close the remaining resurrection paths, only if flake survives Phases 1-4.
Do not start this phase without evidence from the Phase 4 instrumentation that a task is still being resurrected. It exists because two writers reach `state.tasks` without going through `listUserTasks`: [AgendaDocument.tsx:686-690](apps/mail/modules/agentCanvas/components/AgendaDocument.tsx) mirrors a date-ranged query in via `setTasks` (a pure upsert with no reconciliation, [userTasksSlice.ts:466-484](apps/mail/modules/userTasks/slice/userTasksSlice.ts)), and the Cedar-OS SSE processors at [clientExecutionResponseProcessors.ts:181, 245](apps/mail/modules/cedar-os/src/store/agentConnection/responseProcessors/clientExecutionResponseProcessors.ts) write directly on `taskCreated` / `taskUpdated`.
- [ ] Add `pendingTaskDeletions: Record<string, number>` to [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts) with `addTaskTombstones` / `clearTaskTombstones`, copying the shape of [threadSlice.ts:229-232](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [ ] Add an `applyTaskTombstones` helper mirroring [threadSlice.ts:770-805](apps/mail/modules/threads/threadList/store/threadSlice.ts), with the same TTL constant as [threadSlice.ts:653](apps/mail/modules/threads/threadList/store/threadSlice.ts).
- [ ] Apply it inside **both** write doors — `hydrateTodoTasks` and `setTasks` in [userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts). Guarding the writes rather than one read is what makes this cover the agenda and SSE paths.
- [ ] Add the tombstone on delete and clear it on undo in [use-optimistic-task-actions.ts](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts).
**Tests:**
- [ ] New `apps/mail/tests/modules/userTasks/taskTombstones.test.ts` modelled on [draftTombstones.test.ts](apps/mail/modules/threads/threadList/store/__tests__/draftTombstones.test.ts): a tombstoned id is suppressed by `hydrateTodoTasks`, suppressed by a raw `setTasks` upsert, expires after the TTL, and is cleared by undo.
- [ ] `pnpm --filter @cedar/mail test tests/modules/userTasks`
### Phase 6 — Measure agent task re-creation
**Goal:** Determine whether the residual "it came back" reports are the agent re-creating tasks rather than a rendering fault.
- [ ] Add a `createStructuredLog` diagnostic in `createTaskTool` recording `conversationId`, `description` and `creationRunId` on every agent-created task.
- [ ] Add a matching diagnostic on the delete path recording `conversationId` and `description`.
- [ ] Query Axiom for tasks created on a conversation within 60s of a user delete on that same conversation, over a two-week window.
- [ ] Record the rate in this doc. If it is non-trivial, open a separate design doc for the server-side fix (constraining `triggerExecuteFromClientSend` on the delete path, or deduping in `createTaskTool` against recently-deleted rows) — it is out of scope here.
**Tests:**
- [ ] Manual: delete a task with an attached draft, confirm via Axiom that the delete log fires and the grace-period skip/delete log follows 30s later.