Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(drafting): close remaining Greptile findings on #2878 (concurrent-deletion race, cross-thread rewrite)

merged#2884CedarCopilot

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

Live on prod, 0 of 6 surfaces working, 2 days leftTimeline and evidence
  1. Opened
    Sep 22, 2026, 3:31 PM
  2. Sep 22, 2026, 4:24 PM
  3. Merged
    Sep 22, 2026, 4:32 PM
  4. Live on staging
    Sep 22, 2026, 4:32 PM
  5. Live on prod
    Sep 22, 2026, 4:32 PM
  6. Observed 0 hours, 6 surfaces, 18 requests
    Sep 22, 2026, 4:32 PM
  7. Watching

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

    Sep 22, 2026, 4:32 PM
  8. Pipelines steady after this deploy
    Sep 22, 2026, 4:32 PM
  9. Pipelines steady after this deploy
    Sep 22, 2026, 4:32 PM

Behaviors Libra is checking

When the event fast path contains no message for the requested thread, existing-draft detection must fall back to hydratedConversation instead of creating a duplicate draft.Not checked
prod
Existing-draft detection must ignore drafts from threads other than the thread requested for the rewrite.Not checked
prod
When an age-based cleanup defers Gmail deletion to a live sibling task, it must clear the retiring task's draft pointer so the terminal row cannot remain a stale owner candidate.Not checked
prod
After an age-based cleanup successfully deletes a Gmail draft, it must clear the task's draft pointer while retiring the task.Not checked
prod
Successful draft deletion performed by the age-based sweep must emit the normal draft-deleted analytics record.Not checked
prod
If an age-based task cleanup exhausts Gmail deletion retries, the task must return to todo so a later sweep can retry it.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. 0 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 4:36 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. 1 hit · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 6:41 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. 1 hit · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 6:41 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. 0 hits · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

staging, first seen Sep 22, 2026, 7:11 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
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. 1 hit · no retained affected-user count · no retained trace sample.Introducedmedium confidence

internal_only

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

Libra found 6 production surfaces on prod but could not judge any of them yet. 4 surfaces had under 20 requests, so Libra has not judged them; 2 surfaces had no requests at all. Libra checks hourly for 3 days after each deploy.

Context

PR #2878 (merged) fixed the original post-meeting draft detach/delete bug and the first round of Greptile findings. Greptile's review continued after that merge and found 3 more real issues that were fixed on this same branch , but since #2878 had already merged by the time these landed, they were never actually included in an open PR. This PR carries just those remaining commits.

What's in this PR

1. Closes the concurrent-independent-deletion race properly (apps/server/src/services/user-tasks/tasks.ts)

The shared-draftId guard added in #2878 (findLiveTaskSharingDraft) is only race-safe when the caller serializes access. The bulk functions (deleteTasksByDraftIds, deleteAllTasksOlderThan) serialize the tasks they discover together, but two fully independent deleteTask calls (e.g. a chat-driven single delete racing an unrelated cron sweep) had no such protection , both could see the other task as still todo and both defer, orphaning the Gmail draft with no task pointing to it.

Fixed with two changes, both provably correct without a lock or transaction:

  • deleteTask/deleteAllTasksOlderThan now mark a task's own status terminal before running the guard, not after , this makes the "both defer" interleaving logically impossible (see deleteTask's doc comment for the argument).
  • That reordering opens the opposite case (both proceed → double-delete). Closed with getDraftOwnerTaskId, a deterministic tie-break (lowest task id among every task naming the draftId) that only runs once the guard has confirmed no task is live, at which point the set is stable and every caller agrees on the same owner.

New test in shared-draft-cleanup.integration.test.ts runs two fully independent deleteTask calls through Promise.all against real SQL (PGlite) and asserts the Gmail draft is deleted exactly once , verified locally it fails (2 calls) with the reorder alone and passes once the tie-break is added.

2. Stops accepting a draft from an unrelated thread as a rewrite target (apps/server/src/services/drafting/drafting.ts)

checkAndHandleExistingDraft's event.newMessages fast path took the first draft it found with no check that its own threadId matched the requested thread. event.newMessages is scoped to the triggering event's own thread, which isn't necessarily the thread a rewrite targets (e.g. replying on an older thread while a meeting event, which has no thread of its own, triggered the run). The hydratedConversation fallback path already scopes correctly; this brings the fast path in line with it.

Consequence before this fix: the returned draft was reported back under the requested threadId regardless of which thread it actually came from, so a caller could rewrite an unrelated conversation's draft while also skipping the post-meeting new-thread rule for it.

New test (check-existing-draft-thread-scoping.test.ts) , no prior test exercised the real function at all; every caller-side test mocked it away. Verified locally it fails on both cases without the filter and passes with it.

3. Removes the two banned type casts flagged in review (test files only, no behavior change)

Both as any / as unknown as T casts were copied from pre-existing precedent elsewhere in the codebase, but the project rule against them is absolute, and both turned out removable once actually tried , see commit for the specifics of each.

How to test

All new/existing tests pass (360 across the touched directories, verified locally). The two race-closing tests were verified to fail without their respective fixes and pass with them, confirming they're not just accidentally green.

🤖 Generated with Claude Code

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_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 → 17616 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
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
/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.
model_chunk Next Steps and Tasks Manager8 → 00 → 0 (0%)1 ms → not measured0No traffic
No requests recorded since this deploy.
model_step Next Steps and Tasks Manager3 → 00 → 0 (0%)71504 ms → 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.
model_inference Next Steps and Tasks Manager3 → 00 → 0 (0%)71504 ms → 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_step Next Steps and Tasks Managermodel_inference Next Steps and Tasks Manager/api/trpc/userTasks.listUserTasksPOST /api/trpc/userTasks.listUserTasksinvoke_agent Next Steps and Tasks Manager+6
  • apps/server/src/mastra/tools/event-execution/__tests__/draft-email-post-meeting-rewrite-thread.test.tsno production surface mapped
  • apps/server/src/services/drafting/__tests__/check-existing-draft-thread-scoping.test.tsno production surface mapped
  • apps/server/src/services/drafting/drafting.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