Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(drafting): stop post-meeting draft rewrites from being detached and deleted

merged#2878CedarCopilot

CedarCopilot wants to merge 3 commits into staging from fix/sherlock-post-meeting-draft-thread-and-cleanup-20260922-084120

What Libra verified

  • Non-post-meeting thread selection remains unchanged

    Libra ran this behavior against the change and confirmed the check detects when it breaks.

    Technical evidence
    • a non-post-meeting draft keeps a non-empty agent threadId as the selected thread
    • a non-post-meeting draft with an empty agent threadId produces a new thread
    • a non-post-meeting draft with no agent threadId uses the event thread fallback
    • a non-post-meeting draft with no agent threadId or event thread produces a new thread

    Changed code: threadMetadataUtils.ts.

Live on prod, 0 of 7 surfaces working, 2 days leftTimeline and evidence
  1. Opened
    Sep 22, 2026, 8:46 AM
  2. Sep 22, 2026, 9:37 AM
  3. Merged
    Sep 22, 2026, 10:39 AM
  4. Live on staging
    Sep 22, 2026, 10:39 AM
  5. Live on prod
    Sep 22, 2026, 10:39 AM
  6. Observed 0 hours, 7 surfaces, 18 requests
    Sep 22, 2026, 10:39 AM
  7. Watching

    Live on prod, 0 of 7 surfaces working, 2 days left

    Sep 22, 2026, 10:39 AM
  8. Pipelines steady after this deploy
    Sep 22, 2026, 10:39 AM
  9. Pipelines steady after this deploy
    Sep 22, 2026, 10:39 AM

Behaviors Libra is checking

A first-time post-meeting draft continues to be created without a thread so it starts a new conversation.Not checked
prod
A confirmed rewrite of an existing post-meeting draft preserves the resolved agent thread when updating the draft instead of moving it to a new disconnected thread.Not checked
prod
When cleaning up a task, Cedar avoids deleting a Gmail draft that is still referenced by another live todo or recommended task for the same user, including references stored in task output, while clearing the cleaned task's stale draft pointer.Not checked
prod
Only another same-user live task blocks draft deletion, so completed tasks and tasks belonging to another user do not prevent cleanup from deleting an otherwise unreferenced draft.Not checked
prod
The older-task sweep processes tasks sharing a draftId sequentially so one task's terminal status is committed before the next guard runs, while unrelated draftIds continue to process concurrently.Not checked
prod
The older-task sweep applies the shared-draft guard before its direct Gmail deletion and still retires the old task when a newer live task owns the shared draft.Not checked
prod

Failures attributed to this change

No prod customers are affected while this is only in staging. If promoted, prod impact is unknown because Libra still needs a concrete exception, route, and failed user action before assigning severity. 5 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 11:27 AM
No prod customers are affected while this is only in staging. If promoted, prod impact is unknown because Libra still needs a concrete exception, route, and failed user action before assigning severity. 3 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 12:02 PM
No prod customers are affected while this is only in staging. If promoted, prod impact is unknown because Libra still needs a concrete exception, route, and failed user action before assigning severity. 3 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 12:10 PM
No prod customers are affected while this is only in staging. If promoted, customer impact is not proven from the retained evidence. 0 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

single_user

staging, first seen Sep 22, 2026, 8:18 PM
No prod customers are affected while this is only in staging. If promoted, this likely touches a customer-facing path; Libra should verify the failed user action before escalating. 0 hits · 2 users · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 8:18 PM
No prod customers are affected while this is only in staging. If promoted, this likely touches a customer-facing path; Libra should verify the failed user action before escalating. 0 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

single_user

staging, first seen Sep 22, 2026, 8:18 PM

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.

What was wrong

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:

  1. 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.
  2. Because that rewrite is in-place, the old task (still pointing at the original thread) and the new task Cedar creates for the reassigned thread end up naming the same Gmail draft id. 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.

What changed

  • 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.

How to test

  • New unit tests: 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).
  • All pre-existing tests in cleanup-draft-pointer.test.ts, create-draft-rewrite-in-place.test.ts, and draft-tools-sibling-guard.test.ts still pass unchanged.
  • In prod: watch for a post-meeting draft that gets rewritten a second time (e.g. daily-agenda's next-morning re-dispatch) , confirm the returned 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

RetriggerConfidence Score: 3/5

Fix the concurrent cleanup race and the bulk-deletion bypass before merging.

Findings

  1. <img alt="P1" src="https://greptile
Show production surfaces and changed-file mapping

Production surfaces

SurfaceRequestsErrorsp95UsersVerdict
model_chunk Next Steps and Tasks Manager6 → 110 → 0 (0%)1 ms → 1 ms0Insufficient traffic
11 requests, under the 20 Libra needs
model_chunk Next Steps and Tasks Manager0 → 80 → 0 (0%)not measured → 1 ms0Insufficient traffic
8 requests, under the 20 Libra needs
model_step Next Steps and Tasks Manager0 → 30 → 0 (0%)not measured → 71504 ms0Insufficient traffic
3 requests, under the 20 Libra needs
/api/trpc/userTasks.listUserTasks7 → 30 → 0 (0%)378 ms → 203 ms0Insufficient traffic
3 requests, under the 20 Libra needs
model_step Next Steps and Tasks Manager0 → 30 → 0 (0%)not measured → 17622 ms0Insufficient traffic
3 requests, under the 20 Libra needs
model_inference Next Steps and Tasks Manager0 → 30 → 0 (0%)not measured → 71504 ms0Insufficient traffic
3 requests, under the 20 Libra needs
model_inference Next Steps and Tasks Manager0 → 30 → 0 (0%)not measured → 17616 ms0Insufficient traffic
3 requests, under the 20 Libra needs
POST /api/trpc/userTasks.listUserTasks4 → 20 → 0 (0%)378 ms → 203 ms0Insufficient traffic
2 requests, under the 20 Libra needs
invoke_agent Next Steps and Tasks Manager0 → 10 → 0 (0%)not measured → 29048 ms0Insufficient traffic
1 request, under the 20 Libra needs
userTasks.listUserTasks2 → 00 → 0 (0%)279 ms → not measured0No traffic
No requests recorded since this deploy.
userTasks.listUserTasks0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
invoke_agent Next Steps and Tasks Manager0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/userTasks.listUserTasks0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.
POST /api/trpc/userTasks.listUserTasks0 → 00 → 0 (0%)not measured0No traffic
No requests recorded since this deploy.

Changed files → surfaces

  • apps/server/src/services/user-tasks/tasks.tsmodel_chunk Next Steps and Tasks Managermodel_chunk Next Steps and Tasks Managermodel_step Next Steps and Tasks Manager/api/trpc/userTasks.listUserTasksmodel_step Next Steps and Tasks Managermodel_inference Next Steps and Tasks Manager+8
  • apps/server/src/mastra/tools/event-execution/__tests__/draft-email-post-meeting-rewrite-thread.test.tsno production surface mapped
  • apps/server/src/mastra/tools/event-execution/onEventExecutionDraftEmailTool.tsno production surface mapped
  • apps/server/src/mastra/utils/__tests__/determine-thread-id.test.tsno production surface mapped
  • apps/server/src/mastra/utils/threadMetadataUtils.tsno production surface mapped
  • apps/server/src/services/user-tasks/__tests__/cleanup-draft-pointer.test.tsno production surface mapped
  • apps/server/src/services/user-tasks/__tests__/shared-draft-cleanup.integration.test.tsno production surface mapped