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