Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(tasks): gate thread-sync task completion on outbound direction

merged#2618CedarCopilot

CedarCopilot wants to merge 3 commits into staging from fix/thread-sync-direction-and-close-gate

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

    Live on prod, no production signal yet

    Aug 29, 2026, 9:20 PM

Behaviors Libra is checking

Messages sent from a user's configured alias or BCC'd back to the user are classified as outbound through the SENT-label-aware predicate, allowing the associated response task to complete.Inconclusivelow confidence

Only 4 prod OTEL spans matching handleExecuteSyncThread reached Libra since the deploy, below the 20-span floor for calling a change verified. Nothing is failing; there is not yet enough traffic to confirm it.

prod, checked Aug 29, 2026, 7:19 PM
Thread-sync direction and trigger-event facts are derived from the newest timestamped non-draft message rather than the first or an unsent draft in the message array.Inconclusivelow confidence

Only 4 prod OTEL spans matching handleExecuteSyncThread reached Libra since the deploy, below the 20-span floor for calling a change verified. Nothing is failing; there is not yet enough traffic to confirm it.

prod, checked Aug 29, 2026, 7:19 PM
An unsent draft is excluded from direction resolution, so creating or updating a draft cannot close the task it was created to satisfy.Inconclusivelow confidence

Only 4 prod OTEL spans matching handleExecuteSyncThread reached Libra since the deploy, below the 20-span floor for calling a change verified. Nothing is failing; there is not yet enough traffic to confirm it.

prod, checked Aug 29, 2026, 8:19 PM
The no-match or unreachable-task diagnostic is evaluated only for outbound sends, preventing ordinary inbound replies from being reported as failed task stamping.Inconclusivelow confidence

Verification cited only 2 matching post-deploy events, below the 20-event floor for calling a change verified. Nothing is failing; there is not yet enough traffic to confirm it.

prod, checked Aug 29, 2026, 8:19 PM
Thread sync marks open email tasks for the thread complete only when the newest eligible message is confirmed outbound, while inbound and unknown-direction syncs leave those tasks open.Inconclusivelow confidence

Only 4 prod OTEL spans matching handleExecuteSyncThread reached Libra since the deploy, below the 20-span floor for calling a change verified. Nothing is failing; there is not yet enough traffic to confirm it.

prod, checked Aug 29, 2026, 9:19 PM
Threads with no readable sender, no timestamped eligible message, or otherwise unknown direction resolve to null rather than being treated as outbound, while a valid counterparty message remains inbound even when the self address is unavailable.Inconclusivelow confidence

Unable to complete verification: the available post-deploy logs show handler executions, but no direction-specific or completion signal, and the source mapping was not completed before the telemetry window ended.

prod, checked Aug 29, 2026, 9:20 PM

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

Rebased onto staging. The original version of this PR also fixed alias-blind direction labelling; staging's isInboundMessage (PR #2615) landed that first and did it better , it tests the SENT label as well as the address, so it also catches a self-BCC. That half is dropped. What remains is the half staging did not fix: the label was corrected, the close was not.

What's wrong

markEmailTasksCompleteByThreadId matches on task_output.threadId = <thread> AND status = 'todo'. No direction term. And it runs at line 547, ~90 lines above where direction is computed. So any message landing on the thread closes the task , the counterparty's included.

Closed response tasks over 30 days:

count
closed, user had actually replied1,970
closed with no outbound email on the conversation at all949
...of those, an inbound email landed within 60s of the close820
Respond to Aleph Patroy's billing dispute email   ← closed by "re: 20x cost inflation at Greptile"
Respond to Ben Holt , trial credits out           ← closed by "Re: Solo founder, high review volume"
Respond to Bill Adams's TPRM questions            ← closed by "Re: ElevenLabs MVP Testing - SOW"

The counterparty chased, the thread synced, and the user's obligation to reply silently left their list. Nobody replied to anything.

The comment being replaced asserted the opposite , "If this is an incoming email thread, there likely won't be matching tasks" , which is exactly wrong for response tasks: those are keyed to the inbound thread by construction, so they're the rows most likely to match.

What changed

  • Direction is resolved once, above the completion, and reused for the trigger-event facts, so the gate and the announcement can never disagree about the same message.
  • resolveSyncDirection / pickNewestMessage live next to the isInboundMessage predicate they wrap, so the decision that gates completion is testable.
  • pickNewestMessage excludes drafts. isInboundMessage returns false for a draft , correct on its own terms, since a draft isn't a reply from someone else , but a caller reading that false as outbound turns an unsent Cedar draft into an outbound send. Latent in staging today (it only affects wording); live the moment anything gates on the value, which is what this PR does. Without the exclusion, the agent drafting a reply would close the very task the draft was written to satisfy, before the user saw it.
  • Unknown direction does not close. A stale open task is visible and recoverable; a wrongly closed one is silent.
  • The no-match metric is scoped to outbound, so it keeps measuring missing thread keys rather than counting every quiet inbound reply as a stamping failure. A new log line makes the gate itself observable.

Behaviour change to be aware of

If direction can't be resolved, no tasks auto-close for that sync , previously they closed regardless. That's deliberate, and the next sync recovers it, but it's a change:

['cedar-prod']
| where message == "[handleExecuteSyncThread] Task close skipped , not an outbound send"
| summarize count() by user_id, direction, bin(_time, 1d)

A collapse in outbound for a whole account means direction resolution regressed, not that the user stopped sending.

handleExecuteFromClientSend also calls markEmailTasksCompleteByThreadId and is deliberately not gated: it only runs because the user just sent something, so it's outbound by construction.

Verified

  • tsc -b on @zero/server , exit 0, clean
  • vitest run over services/mail, services/user-tasks/__tests__, mastra/routeHandlers , 881 passed, 3 skipped
  • New sync-direction.test.ts , 10 tests: newest-message selection, alias sends via the SENT label, draft exclusion, and every null path
  • pnpm deps:check , no violations (1577 modules, 8222 dependencies)

Not in scope

T

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/handleExecuteSyncThread.tsno production surface mapped
  • apps/server/src/services/mail/send/__tests__/sync-direction.test.tsno production surface mapped
  • apps/server/src/services/mail/send/inbound-message.tsno production surface mapped
  • apps/server/src/services/user-tasks/tasks.tsno production surface mapped