Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(crm): surface Cedar's own draft-only threads to the drafting agent

merged#2697CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/existing-drafts-thread-enumeration-20260906-141203

Live on prod, 1 of 2 behaviors degradedTimeline and evidence
  1. Opened
    Sep 6, 2026, 2:16 PM
  2. Sep 6, 2026, 2:42 PM
  3. Merged
    Sep 6, 2026, 3:17 PM
  4. Live on prod
    Sep 6, 2026, 3:17 PM
  5. Observed 2 days
    Sep 6, 2026, 3:17 PM
  6. Pipelines steady after this deploy
    Sep 6, 2026, 3:17 PM
  7. Mixed signals

    Live on prod, 1 of 2 behaviors degraded

    Sep 9, 2026, 2:07 PM

Behaviors Libra is checking

getConversation includes Cedar-created unsent draft threads even when those threads have no CRM event, allowing drafting agents to discover draft-only conversations.Degradedmedium confidence

Strict CloudWatch fallback found 23 prod failure log lines for draft.thread_id_unrecoverable (Requested entity was not found.) in the last 24h while checking getconversation, analyticsdraftactions, threadid, emailmessages, conversations.hydrate-slack.test.

prod, checked Sep 9, 2026, 2:07 PM
When getConversation is called with getFullEventContent=false and an environment, it fetches unresolved draft-only threads from the deal owner’s S3 connection and returns their messages without hydrating the full event history.Inconclusivelow confidence

Surface getConversation was already failing at 5/5 (100%) in the 71h before this deploy and 30/57 (52.6%) after, so this is a pre-existing condition, not evidence against PR #2697. The intent could not be verified from failure-only span evidence.

prod, checked Sep 9, 2026, 2:07 PM

Libra has verdicts on 1 of 2 tracked behaviors on prod; 1 is still being checked. Libra checks hourly for 3 days after each deploy.

What was wrong

A draft never becomes a CRM event (drafting.ts writes one on every keystroke-ish save , our own unsent text is not an event). That means a thread holding only a Cedar-created draft, with no prior real correspondence, has no CRM event on it , and getConversation()'s thread enumeration (uniqueThreadIds) is built exclusively from conversation.events. So a draft-only thread was never fetched from S3, never landed in emailMessages, and therefore could never appear in the <existing_drafts> context block a drafting agent reads before writing a new one.

This is deterministic, not flaky , it reproduces on any account, every time, for a draft created on a brand-new thread.

Evidence

Traced via Sherlock (2026-09-06) from two independent duplicate-draft reports:

  • Concentrate (Zach): a review-triggered drafter run created a second draft on an existing thread 86 minutes after the first, outside the dispatch-level REVIEW_SKIP_IF_RUN_WITHIN_HOURS window , and never attempted to check the thread at all.
  • Aspireiq (Zak): a drafter run created a third fresh-thread draft for the same ask ("send the YETI capabilities deck") across three separate days. This run correctly tried to check , calling search-live-emails and read-live-email-thread on both known prior draft threads , and had the thread IDs from the conversation's own nextSteps field. But even setting aside a separate Missing userId in request context bug on that call path, the passive <existing_drafts> context block could never have shown the prior drafts anyway: crm_email_events has zero rows for either prior thread, confirmed both via Postgres and via CloudWatch/Axiom logs showing the general Gmail sync completing successfully while the CRM-enrichment step (which is what actually creates crm_email_events rows) explicitly excludes "gained draft" messages by design.

What changed

services/crm/conversations.ts , getConversation(): after building uniqueThreadIds from CRM events, also pull in thread IDs from analytics_draft_actions (joined to agent_executions for the conversation) where status = 'not_sent'. Every Cedar-created draft gets an unconditional row there (createDraftAnalytics, called from createDraftInProvider) regardless of CRM-event status, so this closes the gap without touching the CRM-event/enrichment design at all , those draft-only threads now flow through the existing S3-fetch/hydration path unchanged, and <existing_drafts> can see them.

Also fixed a test-mock gap this surfaced: conversations.hydrate-slack.test.ts's db stub didn't support .innerJoin(), so it broke on the new query. Added innerJoin alongside the mock's existing from/where/orderBy chain methods.

How to test

  • Full services/crm/__tests__/ suite passes (64 files / 767 tests, no regressions).
  • To verify against the real bug: re-run getConversation() (or a drafter subagent) against a conversation with an unsent, fresh-thread Cedar draft and no other CRM event on that thread , emailMessages and the rendered <existing_drafts> block should now include it. Aspireiq's YETI conversation (368f00a2-4d32-4e0f-b3f1-df260b31bd83) is a live repro case: it has three such orphaned draft threads today.

Scope note

This fixes the passive-context half of the duplicate-drafting problem (both Zach's and Zak's cases share this root gap). Two related things are not covered by this PR and are worth separate follow-ups:

  1. Zach's drafter never attempted any existing-draft check at all , a model-behavior gap this fix doesn't touch.
  2. search-live-emails / read-live-email-thread fail fleet-wide with Missing userId in request context on this same dispatch path , a request-context construction bug, confirmed across at least 3 unrelated orgs in CloudWatch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TdeWk6Xnh5bt6eRLfgvAk7

Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/src/services/crm/__tests__/conversations.hydrate-slack.test.tsno production surface mapped
  • apps/server/src/services/crm/conversations.tsno production surface mapped