CedarCopilot wants to merge 2 commits into staging from fix/executor-sees-existing-drafts
Not deployed
Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.
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:
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.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.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.
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.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.
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.
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
Libra has not measured any production surfaces for this change yet.