pending-task-resolutions.tsfix(tasks): write the completion on the click, and clean up the draft on every check-mark Two defects on the task-completion path, reported independently by Zach (#cedar-concentrate) and Mihir (#cedar-invoicebutler). **Completion was lost on a reload within 5s.** The server write sat inside a `setTimeout` behind the undo toast, so a reload, tab close or crash in that window took it down with the JS context: `completeTask` never fired, the mask did not survive either, and the row came back on the next fetch with no `completed_at` and no server-side trace the user had ever closed it. The deferral was guarding a side effect that did not exist — `completeTask` writes a status and cancels a KV entry; draft deletion lives on the delete path. The write now goes out on the click. Undo becomes a real reopen (`updateTask status: 'todo'`), which means the server has to restore what completing cancelled: `updateTask` now re-schedules the agent run on a reopen (gated on `shouldScheduleTask`, so a past due date is left alone) and clears `completedAt`. Without that, tick-then-undo silently killed an agent task's execution. The pending-resolution mask is kept, not deleted. The write is immediate but not instantaneous, and a refetch landing mid-flight would still re-hydrate the row — the window shrinks from five seconds to a round trip rather than closing. **Ticking a task off now deletes its Gmail draft, the way deleting it always has.** 9,754 `done` tasks across 79 users still pointed at a live draft. All five check-off surfaces are wired, not just the one with an undo toast: - conversation Overview / timeline / CRM canvas — cleanup deferred behind the undo window, since it is the one irreversible half - task list, kanban (checkbox, `e`, drag-to-Done), execution list — no undo window, so cleanup rides with the status write - agenda checkbox and conversation-inbox row, which call the mutation directly `optimisticCompleteTask` served both check-offs AND next-steps TaskBlock, which closes a task right after SENDING its draft. It now takes a required cause rather than a default, because that ambiguity is what let this get missed. `completeTask` gains an opt-in `cleanupDraft` for the same reason: the send path reaches it via `completeTaskByDraftId`, where the draft is already a sent message. Deleting the draft also clears the draft pointer, keeping the threadId. `deriveAgendaRightSlot` renders "Open draft" off `draftId` alone and already withholds it when there is none, precisely so users do not click and find nothing — a surviving pointer recreated that state, newly visible because done tasks render and can be reopened. Written as a paired axes write (`taskOutput: producedOutput(...)`, stripped to the bare kind rather than nulled) per TASK_CLEANUP_DESIGN.md 1.2. Also: the delete path was missing its `crm.getConversation` invalidation, so the Overview's Due Tasks list could serve a deleted task back once the mask lifted. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 4, 2026, 12:28 AM1 defectresolve-tasks-on-send.tsfeat(tasks): close a task the moment you send the thing it asked for Every server path that closes a task on send is late. `completeTaskByDraftId` fires inside the send request, and `handleExecuteFromClientSend`'s thread sweep only starts once the client fires a follow-up call, then fetches a conversation and an AOP before it gets there — all long after the composer has closed. The `taskCompleted` it eventually streams writes the Zustand stores, but the empty chat's Tasks card renders straight off the `crm.getConversation` query, which nothing on that path touches. So the user sent the follow-up and kept looking at a sidebar still counting it. Resolve it on the client instead, on the click. `task-send-match` mirrors the server's own two predicates — draftId closes any output kind, threadId closes email-kind tasks only, both COALESCE task_output over the legacy task_action_data — so this closes exactly the rows the server is about to close and nothing else. A rule that ran ahead of the server would buy seconds of the right answer followed by the task popping back, which is worse than the lag. `resolve-tasks-on-send` applies that answer to all four places the browser keeps tasks at once: the pending-resolution mask, the slice and its date buckets, the conversation store, and the query caches. It searches all four for candidates too, so a task the slice never listed — thread open, /tasks never visited — is still found. Undo-send and every failure path roll it back. Wired into the email composer, `sendSlackDraftFromTask` (which had no optimistic update at all, and backs TaskTicketView / TaskBlock / ConversationTaskCard), and the chat's Slack draft renderer. Scheduled sends and free-text channel replies are deliberately excluded — the server closes nothing for those either. `taskCompletedProcessor` now reaches the query cache as well, closing the same gap for completions that arrive from the server rather than from a local send. Also carries an in-flight draft-identity fix in email-composer from a concurrent session: the optimistic write now matches an existing draft row three ways (session id, draft id, message id) instead of by session id alone, so it stops pushing a duplicate row that `mergeDraftsByUserEdited` then deduped away — which is how the store kept serving a pre-edit body. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 9:08 PMtask-send-match.tsfeat(tasks): close a task the moment you send the thing it asked for Every server path that closes a task on send is late. `completeTaskByDraftId` fires inside the send request, and `handleExecuteFromClientSend`'s thread sweep only starts once the client fires a follow-up call, then fetches a conversation and an AOP before it gets there — all long after the composer has closed. The `taskCompleted` it eventually streams writes the Zustand stores, but the empty chat's Tasks card renders straight off the `crm.getConversation` query, which nothing on that path touches. So the user sent the follow-up and kept looking at a sidebar still counting it. Resolve it on the client instead, on the click. `task-send-match` mirrors the server's own two predicates — draftId closes any output kind, threadId closes email-kind tasks only, both COALESCE task_output over the legacy task_action_data — so this closes exactly the rows the server is about to close and nothing else. A rule that ran ahead of the server would buy seconds of the right answer followed by the task popping back, which is worse than the lag. `resolve-tasks-on-send` applies that answer to all four places the browser keeps tasks at once: the pending-resolution mask, the slice and its date buckets, the conversation store, and the query caches. It searches all four for candidates too, so a task the slice never listed — thread open, /tasks never visited — is still found. Undo-send and every failure path roll it back. Wired into the email composer, `sendSlackDraftFromTask` (which had no optimistic update at all, and backs TaskTicketView / TaskBlock / ConversationTaskCard), and the chat's Slack draft renderer. Scheduled sends and free-text channel replies are deliberately excluded — the server closes nothing for those either. `taskCompletedProcessor` now reaches the query cache as well, closing the same gap for completions that arrive from the server rather than from a local send. Also carries an in-flight draft-identity fix in email-composer from a concurrent session: the optimistic write now matches an existing draft row three ways (session id, draft id, message id) instead of by session id alone, so it stops pushing a duplicate row that `mergeDraftsByUserEdited` then deduped away — which is how the store kept serving a pre-edit body. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 9:08 PMtask-status-cache.tsfeat(tasks): close a task the moment you send the thing it asked for Every server path that closes a task on send is late. `completeTaskByDraftId` fires inside the send request, and `handleExecuteFromClientSend`'s thread sweep only starts once the client fires a follow-up call, then fetches a conversation and an AOP before it gets there — all long after the composer has closed. The `taskCompleted` it eventually streams writes the Zustand stores, but the empty chat's Tasks card renders straight off the `crm.getConversation` query, which nothing on that path touches. So the user sent the follow-up and kept looking at a sidebar still counting it. Resolve it on the client instead, on the click. `task-send-match` mirrors the server's own two predicates — draftId closes any output kind, threadId closes email-kind tasks only, both COALESCE task_output over the legacy task_action_data — so this closes exactly the rows the server is about to close and nothing else. A rule that ran ahead of the server would buy seconds of the right answer followed by the task popping back, which is worse than the lag. `resolve-tasks-on-send` applies that answer to all four places the browser keeps tasks at once: the pending-resolution mask, the slice and its date buckets, the conversation store, and the query caches. It searches all four for candidates too, so a task the slice never listed — thread open, /tasks never visited — is still found. Undo-send and every failure path roll it back. Wired into the email composer, `sendSlackDraftFromTask` (which had no optimistic update at all, and backs TaskTicketView / TaskBlock / ConversationTaskCard), and the chat's Slack draft renderer. Scheduled sends and free-text channel replies are deliberately excluded — the server closes nothing for those either. `taskCompletedProcessor` now reaches the query cache as well, closing the same gap for completions that arrive from the server rather than from a local send. Also carries an in-flight draft-identity fix in email-composer from a concurrent session: the optimistic write now matches an existing draft row three ways (session id, draft id, message id) instead of by session id alone, so it stops pushing a duplicate row that `mergeDraftsByUserEdited` then deduped away — which is how the store kept serving a pre-edit body. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 9:08 PM