Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(tasks): report draft-path task closures to the client and the next-steps agent

merged#2617CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/task-complete-draft-send-notify

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Aug 29, 2026, 11:14 AM
  2. Aug 29, 2026, 12:06 PM
  3. Merged
    Aug 29, 2026, 12:45 PM
  4. Live on prod
    Aug 29, 2026, 12:45 PM
  5. Observed 6 hours
    Aug 29, 2026, 12:45 PM
  6. Pipelines steady after this deploy
    Aug 29, 2026, 12:45 PM
  7. Unobserved

    Live on prod, no production signal yet

    Aug 29, 2026, 6:22 PM

Behaviors Libra is checking

For client-send email events, the next-steps agent receives explicit outbound trigger facts, including the thread and email metadata plus descriptions of tasks programmatically closed by either completion path, so it does not re-derive a task that the send already satisfied.Inconclusivemedium confidence

Post-deploy telemetry shows 19 successful updateNextStepsAndTasksTool completions, including 2 client-sync executions in api-service, with no tied tool failures observed. However, telemetry does not expose the request context payload, so there is no positive runtime evidence.

prod, checked Aug 29, 2026, 4:21 PM
When a Cedar-drafted send has already closed its task through the mail.send path, the client receives a taskCompleted event so the completed task disappears from its task surfaces.Inconclusivelow confidence

Unable to complete verification: the available telemetry queries returned no matching OTEL spans or CloudWatch log executions for handleExecuteFromClientSend after deployment, and source mapping to the concrete HTTP route could not be read because the source-read budget was.

prod, checked Aug 29, 2026, 5:20 PM
A failure while looking up a recent draft closure is treated as a notification enhancement failure and does not block the send follow-up execution.Inconclusivelow confidence

Unable to complete verification because the source read budget was exhausted before telemetry could be queried.

prod, checked Aug 29, 2026, 5:20 PM
The server can identify the most recently completed task for a user and draft within the last ten minutes without changing task state, preventing stale or duplicate draft IDs from producing an incorrect closure notification.Inconclusivehigh confidence

Since 2026-08-29T19:45:37Z, production telemetry contains 0 OTEL spans or CloudWatch log entries tied to findRecentlyCompletedTaskByDraftId, handleExecuteFromClientSend, taskCompleted, or completeTaskByDraftId. The only related evidence was 278 thread-based.

prod, checked Aug 29, 2026, 6:20 PM
Task completions made by either the thread-based follow-up handler or the draft-based send path are combined and reported exactly once even when both paths close the same task.Inconclusivemedium confidence

Since 2026-08-29T19:45:37Z in prod, CloudWatch returned 0 matching records for [handleExecuteFromClientSend], Streaming taskCompleted, Marking email tasks complete, and draft-path closure logs. OTEL returned 0 explicit client-send/handler matches; broadened queries found only.

prod, checked Aug 29, 2026, 6:22 PM

Libra has verdicts on 0 of 5 tracked behaviors on prod; 5 are still being checked. Libra checks hourly for 3 days after each deploy.

What was wrong

Sending a Cedar-drafted email closes its task correctly , and then tells nobody.

Two writers race on the same send:

mail.send ──► completeTaskByDraftId ──► completeTask()   writes 'done', streams nothing
   client ──► handleExecuteFromClientSend
                 └─► markEmailTasksCompleteByThreadId    writes AND streams taskCompleted

The draft path runs inside the send request; the handler only after the client fires a follow-up call. So the draft path lands first, the handler's status = 'todo' predicate matches nothing, and the one path that emits taskCompleted has nothing to emit it about.

Traced on a real send (<email>, task 979d93bb):

20:01:44.000  user sends the Cedar draft
20:01:46.097  completeTaskByDraftId writes status='done'      ← no stream, no invalidation
20:01:46.142  [completeTask] Completed task 979d93bb...
20:01:46.708  handleExecuteFromClientSend: 0 rows matched     ← streams nothing
20:01:57.618  [completeTask] ...is already done               ← user clicks the checkbox
20:02:38.777  [completeTask] ...is already done               ← user clicks again

Two consequences. The user watches an unchecked box on work that finished two seconds earlier , and clicking it does nothing visible either, because completeTask early-returns on an already-done task. And the next-steps agent is handed No task was programmatically closed about a task that had just closed; since activeTaskStatusConditions() hides done rows from <existing_tasks>, a live next-steps bullet with no visible task behind it reads as uncovered work, and the agent re-derives the task the send just completed.

What changed

  • findRecentlyCompletedTaskByDraftId (services/user-tasks/tasks.ts) , read-only lookup of the closure the draft path already made. It reports a closure, never performs one. Bounded by recency so re-sending an old draft cannot re-announce an old completion.
  • handleExecuteFromClientSend now reports the union of both closure sets and streams taskCompleted for all of them. draftId was already on the input schema (handleExecuteFromClientSend.ts:40) and already forwarded by the composer (email-composer.tsx:1456) , it was simply never read.
  • triggerEventFacts is now set on this path, so buildTriggerEventBlock states the closures instead of falling back to free-text reasoning. Direction is outbound by construction: this handler only runs because the user just sent something.

No new plumbing , the client's taskCompletedProcessor already updates both surfaces (conversation.userTasks via setConversations, userTasksSlice via updateTask).

How to test

Send an email from a Cedar-generated draft that has a task attached. The task should tick in the UI on send, with no refresh. In the execution's prompt, <trigger_event> should now name the closed task rather than saying No task was programmatically closed.

Verified

  • tsc -b on @zero/server , exit 0, clean
  • vitest run over routeHandlers/event-execution/__tests__, services/user-tasks/__tests__, mastra/tools/task/__tests__ , 289 passed, 2 skipped
  • pnpm deps:check , no violations (1572 modules, 8190 dependencies)

Scope

Deliberately narrow. Two adjacent defects found during the investigation are not fixed here and need their own PRs:

  1. handleExecuteSyncThread.ts:637 hand-rolls its sender check against the default connection only, ignoring send-as aliases, so a user who sends from a second address has ~every outbound labelled inbound. determineEmailDirection + getEmailAliases already exist for exactly this.
  2. markEmailTasksCompleteByThreadId is called direction-blind at handleExecuteSyncThread.ts:547, so an inbound message closes response tasks the user never answered. 820 such closes in the last 30 days.

🤖 Generated with Claude Code

Greptile Summary

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/routeHandlers/event-execution/__tests__/handleExecuteFromClientSend-draft-closure.test.tsno production surface mapped
  • apps/server/src/mastra/routeHandlers/event-execution/__tests__/handleExecuteFromClientSend-live-next-steps.test.tsno production surface mapped
  • apps/server/src/mastra/routeHandlers/event-execution/handleExecuteFromClientSend.tsno production surface mapped
  • apps/server/src/services/user-tasks/tasks.tsno production surface mapped