Duplicate reschedule notifications: the agent re-executed a move it couldn't see
RCA · 2026-08-10 · Camellia Apartments, prod conversation 8258d02a · incident fix shipped in PR #5644
What the PM saw
| Time (MDT) | Event |
|---|---|
| 2:10 PM | Prospect asks about Saturday tours; books 1:00 PM; identifies as Ciara Baird. |
| 2:12 PM | Tour confirmed at 1:00 PM · PM notification #0 (correct — a fresh booking). |
| 2:31:53 | Prospect: "would it be possible to move the appointment to 2pm?" |
| 2:31:58 | PM email #1: "Tour rescheduled … 2:00 PM" — sent by the ingestion pipeline, which auto-applied the move before Clara's turn ran. |
| 2:32:00 | Clara (words only, no tool call): "Your tour has been moved to 2:00 PM." |
| 2:32:25 | Prospect: "Thank you!!" |
| 2:32:31 | Clara calls reschedule_tour(Aug 15, 2:00 PM) — the same slot. A second rescheduled history event is written. |
| 2:32:37 | PM email #2 — identical — plus the duplicate Slack activity post. |
The tour row confirms it: two rescheduled history events to the identical time, 39 seconds apart.
Root cause — five mechanisms lined up
[COMPLETED ACTIONS] block injected into that turn's prompt only — never persisted to the conversation. The next turn rebuilds the transcript from persisted messages, where the move left zero trace.reschedule_tour from her tool list when the pipeline already acted — structurally forcing the words-only reply her own guardrails classify as a hard failure.pmConfirmNotifiedAt can never recognize a duplicate. Second event, second email, second Slack post.Why our test suite was green through all of this
The fix (PR #5644)
| Layer | Change |
|---|---|
| Root cause | Every definitive pipeline tour action now persists as a synthetic tour_pipeline_action tool record (system-authored, truthfully labeled) in the conversation. Future turns' transcripts show the move as executed — the repair guardrail sees compliance, not a drop. |
| Prompt | The suppression rule now includes rescheduled and recognizes the persisted record; the accept-own-offer rule gets a matching "already executed → acknowledge, don't re-call" carve-out. New eval case (CASE 11) scores the exact incident shape. |
| Guard | The hallucination guard's pending-time-change flag now clears on a pipeline-applied move (it previously stayed latently armed forever). |
| Idempotency | reschedule_tour to the already-confirmed slot is a no-op — no history event, no notification — for any caller (retry, double-tap, race). Defense-in-depth, not the fix. |
| Harness | New exactly-once checks (pm_notified_count, reschedule_event_count — settle-polled, fail-fast on excess) and gauntlet scenario RG-10: book → confirm → move → "Thank you!!", asserting one history event, one PM notification, tour still confirmed. This scenario fails on the pre-fix code and gates the nightly forever after. |
Verification
RED Incident reproduced on pre-fix prod (run SMS-1786398272219, live SMS against the test property): after "move it to 2pm" then "Thank you!!", the tour carried two rescheduled history events for one logical move — reschedule_event_count: 2 > 1 — DUPLICATE detected. The exact double-write that re-fired the PM's email on Camellia.
GREEN Fix verified end-to-end (run SMS-1786398813913, same scenario against the fix branch's full preview pipeline — webhook → queue → branch-deployed agent Lambda): 15/15 checks. "Thank you!!" got "Looking forward to seeing you Friday!" with no tool call and exactly one reschedule event.
Plus: unit tests for the audit-pair shape and transcript reconstruction, the idempotent no-op vs. a genuine move, the count-check semantics, and the wire-guard boundary; a new prompt-eval case scoring the exact incident shape; typecheck and lint clean; the scenario runs in the nightly gauntlet from now on.
Found along the way, filed separately: the test property's leasing calendar has been disconnected since mid-July, silently muting the suite's calendar/PM-email assertions (Trello: "Reconnect The Willows test property's leasing calendar"); and a ~40-minute prod window today where inbound SMS from unknown senders was dropped between two mainline deploys.