CedarCopilot wants to merge 3 commits into staging from fix/thread-sync-direction-and-close-gate
Live on prod, no production signal yet
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.
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.
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.
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.
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.
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.
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.
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 replied | 1,970 |
| closed with no outbound email on the conversation at all | 949 |
| ...of those, an inbound email landed within 60s of the close | 820 |
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.
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.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.
tsc -b on @zero/server , exit 0, cleanvitest run over services/mail, services/user-tasks/__tests__, mastra/routeHandlers ,
881 passed, 3 skippedsync-direction.test.ts , 10 tests: newest-message selection, alias sends via the SENT
label, draft exclusion, and every null pathpnpm deps:check , no violations (1577 modules, 8222 dependencies)T
Libra has not measured any production surfaces for this change yet.