Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(drafting): let the drafting agent see and act on drafts already on a deal

closed#2567CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/executor-sees-existing-drafts

Not deployedTimeline and evidence
  1. Opened
    Aug 24, 2026, 10:30 AM
  2. Aug 24, 2026, 10:46 AM
  3. Merged, live on staging, live on prod, observed
    Pending
  4. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

What was wrong

Second half of the Concentrate duplicate-draft incident (2026-08-24). #2566 stops the spurious rerun; this makes a rerun harmless , it carries the existing draft forward instead of writing a second one. Either alone would have prevented Zach's duplicate; together they cover the paths the other misses (reaper heal-by-rerun, manual replay).

The post-event executor could not see a draft it had already made. Three independent reasons:

  1. The guard never ran. draft-email's duplicate check is gated on if (inputData.threadId && conversationId). The re-run passed threadId: null , it opened a new thread , so checkAndHandleExistingDraft was never called. Both drafts returned draftAlreadyExists=false.
  2. It would have missed anyway. That guard is thread-scoped, and the two drafts live in different Gmail threads (1a033d018ac8e9d1 vs 1a033d4a6434b0b4). The <existing_drafts> block in conversation_context has the same limit: it is built from the conversation's synced threads, and an unsent draft that opens a new thread never gets a crm_email_events row , which is every post-meeting recap. Confirmed: neither draft thread exists in crm_email_events.
  3. The conversation-scoped guard doesn't cover it. blockDuplicateSiblingDraft is wired only into save-draft/save-slack-draft, not draft-email, and findSiblingDraft matches on parentRunId , same dispatch only. Two redeliveries are two dispatches, so they are not siblings.

Nothing anywhere asked "has a post-meeting draft already been made for this conversation?" , and the executor had no tool to remove a draft either: deleteDraft existed only behind tRPC.

What changed

  • services/drafting/cedar-owned-drafts.ts , ownership (an open task points at the draft) and liveness (resolveDraftState) in one place, so the read and delete paths cannot drift.
  • run-post-event-executor gains a <cedar_owned_drafts> block. Scoped to the drafting dispatch so the other ~20 formatHydratedConversationForAgent callers take no Gmail round-trip.
  • delete-draft tool, refused unless an open task on the conversation owns the draft.
  • draft-email accepts draftId to rewrite in place without a threadId, ownership re-checked since the id comes from a model.
  • listTasksTool shares resolveDraftState instead of its own copy.

Why drafts are verified against Gmail

The draft's task is the only conversation-scoped record of it, but a task can outlive its draft , nothing closes it when the user sends or deletes the draft in Gmail. Zach has 97 open tasks carrying a draftId, oldest 2026-07-22. So every draft is resolved against Gmail before anyone is told it exists.

Liveness is three-valued on purpose: only the driver's definitive not-found drops a draft and unlinks it from its task; a transient failure keeps it visible and marked verified="false", because assuming it is gone is exactly what produces the duplicate.

Also fixed

formatDraftsForAgent rendered draft.id , the message id. ParsedMessage.draftId is the stable Gmail draft id that save-draft/delete-draft accept. Agents were being handed an id no draft tool takes; now renders the real one, which also gives both draft blocks a shared identifier.

How to test

  • apps/server/src/services/drafting/__tests__/cedar-owned-drafts.test.ts , 15 cases: ownership refusal, the three liveness outcomes, Slack tasks staying out of the Gmail path, unlink-on-gone preserving sibling fields.
  • listTasksTool.draft-fetch.test.ts moves to vi.hoisted , the spy was a plain top-level const, which only worked while the import was lazy. Assertions unchanged.

Verified: types clean · deps:check clean (1534 modules) · 1520 tests passing across services/drafting, services/agent-action-queue, mastra/tools, mastra/skills, mastra/utils, cron.

🤖 Generated with Claude Code

G

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/mastra/agents/on-event-execution-agent.tsno production surface mapped
  • apps/server/src/mastra/skills/drafting/drafting.tsno production surface mapped
  • apps/server/src/mastra/tools/draft-comms/deleteDraftTool.tsno production surface mapped
  • apps/server/src/mastra/tools/event-execution/onEventExecutionDraftEmailTool.tsno production surface mapped
  • apps/server/src/mastra/tools/event-execution/orchestrator-dispatch-tools.tsno production surface mapped
  • apps/server/src/mastra/tools/subagent-tool-allowlists.tsno production surface mapped
  • apps/server/src/mastra/tools/task/__tests__/listTasksTool.draft-fetch.test.tsno production surface mapped
  • apps/server/src/mastra/tools/task/listTasksTool.tsno production surface mapped
  • apps/server/src/mastra/tools/tool-names.tsno production surface mapped
  • apps/server/src/mastra/utils/context-formatting/event-context.tsno production surface mapped
  • apps/server/src/services/drafting/__tests__/cedar-owned-drafts.test.tsno production surface mapped
  • apps/server/src/services/drafting/cedar-owned-drafts.tsno production surface mapped