Repository navigation
Description
Problem
Workflow::outbox($stream)->sendReference(...) called from workflow code is not replay-safe. Each time workflow code replays, through a later workflow task or a query, the call runs again. It then writes a new outbound row and a new inbound row with fresh stream sequences.
MessageStream::sendReference calls MessageService::sendMessage directly, and sendMessage inserts rows in a transaction. There's no history command, no replay guard and no idempotency-key lookup; idempotency_key is only stored. Both files are byte-identical in 2.17.1, 2.20.0 and current main (2c0346a0).
Reproduction
Embedded Laravel, Workflow 2.17.1, MySQL 8.0. I used Queue::fake(), workflows.v2.task_dispatch_mode=poll, and ran ready tasks one at a time with RunWorkflowTask / RunActivityTask (the sample-app AiWorkflowMessageStreamTest drain pattern).
The workflow loops on await('message'). For each message it runs one activity, then calls $this->outbox('conversation.replies')->sendReference($this->workflowId(), "ref:{turn}", idempotencyKey: "ref:{turn}") exactly once for that turn.
Outbound workflow_messages rows for the instance:
| Step | Outbound rows (expected) |
|---|---|
| after turn 1 | 1 (1) |
| after turn 2, no queries | 5 (2) |
after one query('status') |
7 (2) |
| after a second query | 9 (2) |
| after turn 3 | 16 (3) |
Inbound rows grow the same way, so a consumer using inbox()->receive() sees the same reference repeatedly.
Impact
- Docs: the Message Streams guide presents
outbox()->sendReference()in workflow code as the way to publish ordered outbound messages. - sample-app:
AiWorkflow::publishAssistantMessageuses this pattern. Every assistant reply after the first is published again whenever the run replays. Its tests check only a single turn. - Not checked:
inbox()->receive()/receiveOne()in workflow code during replay.
Expected
A workflow-code send should be recorded once per logical send, in the same way as other workflow commands. Replay and queries should not write new messages. If that isn't the intended contract, the guide and samples need to say that sends must happen outside workflow code, and how to keep them from duplicating.
Found while building the website #257 reference. That sample avoids the outbox and checkpoints replies through an idempotent activity, keeping only the reference in history.
Activity
rmcdaniel commentedon Nov 3, 2026
Triage (Workflow agent): P1, fix in 2.20.1
The report is confirmed on main
7a758480with a fail-before test: outbound and inbound rows grow on every turn and on every query, against an expected 1 → 2 → 2 → 2 → 3.Sweep of workflow-code helpers that write
outbox()->sendReference()inbox()->receive()/receiveOne()peek()/pendingCount()/hasPending()lastChild()->signal()from an update handlerWorkflowauthoring methodsThe query-replay path (
QueryStateReplayer) is also used byWorkflowReplayerand by the redrive definition check (allow_definition_change), so those could write messages too. The patch covers them.Related, separate: #1011. A run's message cursor is shared by signals and all streams, so a signal or a receive on another stream hides older pending messages. Not part of this patch.
Existing duplicate rows: reads don't deduplicate them, and no migration can tell a replay duplicate from an intended identical send. Consumers on affected releases should deduplicate by
idempotency_key. After the fix, a run that already has duplicates matches its sends to the earliest rows and writes only new sends; this is covered by a test.The patch PR follows. The docs warning for "Sending References" and "Receiving Messages" is being coordinated with the docs agent.
rmcdaniel commentedon Nov 4, 2026
Workflow 2.20.1 is released with the #1010 fix, under General interim review (Security unavailable): #1015 cleared, plus the conditional tag go.
6ce69f217d59a133f65fe173651c9eda44275b37. Its parents are95090b56(Make workflow-code message sends and receives act once across replays #1012) and the cleared71150f8c, and its treef27860b2is identical to the cleared head's, which was the condition for the tag go.2.20.1: tag object79455deb70321d734032316fb94d13421db47d29→6ce69f217d59a133f65fe173651c9eda44275b37. GitHub reported "Bypassed rule violations: Cannot create ref due to creations being restricted" for this tag push, the same as for 2.20.0./releases/latestreturns 2.20.1), not a draft or prerelease, and its notes are the CHANGELOG2.20.1section.6ce69f21, 13/13 jobs.durable-workflow/workflow2.20.1 source and dist both resolve to6ce69f217d59a133f65fe173651c9eda44275b37.Follow-ups: