CedarCopilot wants to merge 3 commits into staging from fix/sherlock-post-meeting-draft-thread-and-cleanup-20260922-084120
Libra ran this behavior against the change and confirmed the check detects when it breaks.
Changed code: threadMetadataUtils.ts.
Live on prod, 0 of 7 surfaces working, 2 days left
internal_only
internal_only
internal_only
single_user
internal_only
single_user
Libra found 7 production surfaces on prod but could not judge any of them yet. 4 surfaces had under 20 requests, so Libra has not judged them; 3 surfaces had no requests at all. Libra checks hourly for 3 days after each deploy.
Traced via a Sherlock investigation for (Warp): he reported "a few email drafts not coming up." 14 of his post-meeting follow-up drafts had been silently created and then deleted within ~10 minutes, never reaching his Gmail drafts folder. The pattern goes back to 2026-08-20, the day the first bug shipped , live-verified against 15+ users org-wide.
Two compounding bugs:
determineThreadId() (threadMetadataUtils.ts) unconditionally forced threadId = null for every post-meeting draft , including a rewrite of a draft that checkAndHandleExistingDraft had already confirmed exists on a specific thread. With no thread pinned, Gmail's drafts.update silently reassigned the same draft object to a brand-new, disconnected thread.cleanupDraftForTask() deletes a task's draft by id with no check that another live task still depends on it , so when the old, now-superseded task gets cleaned up (e.g. daily-agenda marking it agent_deleted), it deletes the only copy of the draft the new task also needs.Net effect: draft generated → silently detached to an orphan thread → deleted by cleanup, usually all within the same morning cron cycle, before the rep ever sees it.
determineThreadId() gains an isRewrite param. The post-meeting "always new thread" rule now only fires when this is not a confirmed rewrite. isRewrite is derived from existingDraftId !== undefined in onEventExecutionDraftEmailTool.ts's persistDraft() , i.e. only true once checkAndHandleExistingDraft has independently verified a live, Cedar-authored draft sits on that thread. The agent's raw threadId is never trusted directly, so this can't reintroduce the failure mode the original rule guarded against (hallucinating a reply onto an unrelated old thread).cleanupDraftForTask() now checks, before calling Gmail's delete, whether any other non-terminal task (todo/recommended) for the same user still names the same draftId (via the existing matchesOutputKey predicate). If so, it skips the Gmail delete and just clears this task's own pointer , the draft stays alive for the task that still needs it.apps/server/src/mastra/utils/__tests__/determine-thread-id.test.ts (post-meeting + isRewrite interaction, 6 cases) and two new cases in apps/server/src/services/user-tasks/__tests__/cleanup-draft-pointer.test.ts (shared-draftId guard).cleanup-draft-pointer.test.ts, create-draft-rewrite-in-place.test.ts, and draft-tools-sibling-guard.test.ts still pass unchanged.threadId on the new/updated task now matches the thread the original draft was on, and that the old task's cleanupDraftForTask run (if it fires) logs "still referenced by live task" rather than deleting.Does not recover the drafts already lost since 2026-08-20 , those are gone in Gmail and need separate remediation (out of scope here).
🤖 Fixed by Sherlock
Fix the concurrent cleanup race and the bulk-deletion bypass before merging.
| Surface | Requests | Errors | p95 | Users | Verdict |
|---|---|---|---|---|---|
| model_chunk Next Steps and Tasks Manager | 6 → 11 | 0 → 0 (0%) | 1 ms → 1 ms | 0 | Insufficient traffic 11 requests, under the 20 Libra needs |
| model_chunk Next Steps and Tasks Manager | 0 → 8 | 0 → 0 (0%) | not measured → 1 ms | 0 | Insufficient traffic 8 requests, under the 20 Libra needs |
| model_step Next Steps and Tasks Manager | 0 → 3 | 0 → 0 (0%) | not measured → 71504 ms | 0 | Insufficient traffic 3 requests, under the 20 Libra needs |
| /api/trpc/userTasks.listUserTasks | 7 → 3 | 0 → 0 (0%) | 378 ms → 203 ms | 0 | Insufficient traffic 3 requests, under the 20 Libra needs |
| model_step Next Steps and Tasks Manager | 0 → 3 | 0 → 0 (0%) | not measured → 17622 ms | 0 | Insufficient traffic 3 requests, under the 20 Libra needs |
| model_inference Next Steps and Tasks Manager | 0 → 3 | 0 → 0 (0%) | not measured → 71504 ms | 0 | Insufficient traffic 3 requests, under the 20 Libra needs |
| model_inference Next Steps and Tasks Manager | 0 → 3 | 0 → 0 (0%) | not measured → 17616 ms | 0 | Insufficient traffic 3 requests, under the 20 Libra needs |
| POST /api/trpc/userTasks.listUserTasks | 4 → 2 | 0 → 0 (0%) | 378 ms → 203 ms | 0 | Insufficient traffic 2 requests, under the 20 Libra needs |
| invoke_agent Next Steps and Tasks Manager | 0 → 1 | 0 → 0 (0%) | not measured → 29048 ms | 0 | Insufficient traffic 1 request, under the 20 Libra needs |
| userTasks.listUserTasks | 2 → 0 | 0 → 0 (0%) | 279 ms → not measured | 0 | No traffic No requests recorded since this deploy. |
| userTasks.listUserTasks | 0 → 0 | 0 → 0 (0%) | not measured | 0 | No traffic No requests recorded since this deploy. |
| invoke_agent Next Steps and Tasks Manager | 0 → 0 | 0 → 0 (0%) | not measured | 0 | No traffic No requests recorded since this deploy. |
| /api/trpc/userTasks.listUserTasks | 0 → 0 | 0 → 0 (0%) | not measured | 0 | No traffic No requests recorded since this deploy. |
| POST /api/trpc/userTasks.listUserTasks | 0 → 0 | 0 → 0 (0%) | not measured | 0 | No traffic No requests recorded since this deploy. |