Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(slack): cancel an in-flight run when the user sends another message

merged#2931CedarCopilot

CedarCopilot wants to merge 2 commits into staging from fix/slack-supersede-inflight-turn

Customer behavior verdict

Libra exercised 2 changed behaviors; none regressed.

Who this touches

  • A superseded Slack run produces no reply or persisted completion: no production calls in the last 7 days.
  • Out-of-order Slack events preserve both author requests: no production calls in the last 7 days.
  • A teammate's Slack message remains outside the current user's request: no production calls in the last 7 days.
  • Arrival-time author metadata survives completion persistence: no production calls in the last 7 days.
  • A superseded Slack run produces no reply or persisted completion: This scenario failed on this PR. Libra could not establish a reliable comparison with the base revision.
  • Out-of-order Slack events preserve both author requests: Held on this PR; the scenario fails when this is broken on purpose.
  • A teammate's Slack message remains outside the current user's request: Held on this PR; the scenario fails when this is broken on purpose.

What Libra verified

  • A teammate's Slack message remains outside the current user's request

    Libra ran this behavior against the change and confirmed the check detects when it breaks.

    Technical evidence
    • a teammate's trailing Slack message is labeled separately instead of being presented as the current user's request

    Changed code: slack-bot-chat.ts.

  • Out-of-order Slack events preserve both author requests

    Libra ran this behavior against the change and confirmed the check detects when it breaks.

    Technical evidence
    • the resulting Slack history retains both same-author messages in Slack send-time order when events are processed out of order

    Changed code: slack-bot-chat.ts.

Live on prod, watching, 2 days leftTimeline and evidence
  1. Opened
    Sep 24, 2026, 11:06 AM
  2. Sep 24, 2026, 11:40 AM
  3. Merged
    Sep 24, 2026, 12:13 PM
  4. Live on prod
    Sep 24, 2026, 12:13 PM
  5. Observed 0 hours
    Sep 24, 2026, 12:13 PM
  6. Watching

    Live on prod, watching, 2 days left

    Sep 24, 2026, 12:13 PM
  7. Pipelines steady after this deploy
    Sep 24, 2026, 12:13 PM

Behaviors Libra is checking

When completed chat persistence upserts an already-arrived user row, its existing author metadata is merged instead of being overwritten by empty completion metadata.Not checked
prod
Shared Slack thread history identifies the current author's messages as User and other participants' messages as Teammate, so only the current author's unanswered suffix is merged into the request.Not checked
prod
Each Slack user message is persisted idempotently in its thread, with the sender identity and Slack send time, before the agent run starts.Not checked
prod
Slack user-message rows and completed-turn persistence use the Slack event timestamp rather than processing time, preserving the actual message order during long-running turns.Not checked
prod
The newer Slack run treats the same author's trailing unanswered messages and the current message as one request and is instructed to update existing work instead of creating duplicate drafts or tasks.Not checked
prod
An in-flight Slack run is aborted when the same author sends a newer message in the same thread, while messages from other thread participants do not cancel it.Not checked
prod

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

What was wrong

A Pax user (Trey) sent two quick Slack messages in one thread: "Yes, try to schedule for early next week", then "run it by me before sending". Each was its own Slack event and its own agent run. Run 2 started before run 1 had persisted anything (chat rows were only written when a run finished), so it saw none of run 1's work, re-derived the task with a different calendar call, and its draft-email overwrote run 1's Gmail draft (same draftId). Cedar showed Trey the first run's 3 slots, then saved and previewed a different 4-slot draft.

Evidence: chat_messages for thread slack:T0789EW2ZM3:D0BPM9PPL0P:1790269797.716889. Two user rows with Slack ts 1790269827 and 1790269841 (14s apart) and two draft-email tool results on draftId r163057067623954677, with different bodies. Turn 1 rows were written at 17:11:15 UTC, after turn 2 had already started.

What changed

  • The user's message is persisted on arrival, with its Slack send time and author (metadata.authorUserId).
  • Each run polls the DB (1.5s) for a newer message from the same author and aborts via an AbortSignal passed to the agent. A final check() runs before anything is persisted, so a run that finishes as a newer message lands still loses. The aborted run persists and posts nothing and deletes its "thinking" placeholder.
  • The newer run reloads history, so it sees the earlier unanswered message, and gets a prompt note: treat them as one request, and update any existing draft/task rather than duplicate it.
  • persistCompletedChatMessages now merges metadata on user rows so the arrival-time author is not wiped on completion. Other rows keep replace semantics.
  • Only the same author's messages cancel a run or are merged into the note, so teammates in a shared channel thread cannot cancel each other or run under each other's identity.

Polling reads the DB rather than process memory because the two events can land on different API instances.

Known limits

  • The abort lands on the next poll tick and cannot interrupt a tool call already executing, so the older run can still finish one in-flight write. The prompt note narrows the duplicate-draft race but does not close it.
  • If the newer run dies after persisting, neither message gets a reply; the next message picks up both.
  • Voice notes are not covered (they get a row only after transcription).
  • The prompt note has not been run against the live model; unit tests cover the mechanism, not the model's response to the note.

How to test

Unit: pnpm --filter @zero/server exec vitest run src/mastra/utils src/mastra/routeHandlers/slack. Manual: in a Slack thread with Cedar, send "draft a reply to X" and, within a few seconds, "show me before sending". Expect one reply covering both, and only one Gmail draft.

Verify in prod (Axiom cedar-prod, CloudWatch /aws/ecs/aws-prod-api/api-service)

['cedar-prod']
| where message == '[slack-turn-supersession] Superseded , aborting run'
| project _time, user_id, thread_id, user_message_id

Each row is a run that was cancelled. The matching thread should show exactly one assistant reply after it:

['cedar-prod']
| where message == '[persistCompletedChatMessages] Saved' and thread_id startswith 'slack:'
| where thread_id == '<thread_id from above>'

Failure signal: [slack-turn-supersession] Supersession check failed warns (DB poll errors), or a superseded thread with two assistant replies.

Notes

autofix:local stops on one unrelated frontend test (apps/mail/tests/modules/chat-store/chatHistoryList.test.ts, a date-bucket test). Lint, deps:check, server typecheck and the server vitest suites for this area pass.

šŸ¤– Generated with Claude Code

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/slack/__tests__/slack-turn-supersession.test.tsno production surface mapped
  • apps/server/src/mastra/routeHandlers/slack/slack-bot-chat.tsno production surface mapped
  • apps/server/src/mastra/routeHandlers/slack/slack-turn-supersession.tsno production surface mapped
  • apps/server/src/mastra/utils/__tests__/headless-history-author.test.tsno production surface mapped
  • apps/server/src/mastra/utils/chat-message-persistence.tsno production surface mapped
  • apps/server/src/mastra/utils/headless-history.tsno production surface mapped