AtlatestRepositorycourier
1
# Send-path duplication: off-device evidence (2026-07-06)3
All runs go through `repro/mock_telegram.py`; **nothing reached real Telegram**4
(courier's SEND path is forced to the mock via `COURIER_TELEGRAM_API_URL`).6
## Setup8
- `mock_telegram.py` — records every `sendMessage`; can inject send failure9
modes (`ok`, `reset`, `delay:N`, `status:N`, `notok`) via `POST /_mode`.10
- `send_driver.py` — a minimal MCP client (the "leader") that fires ONE11
logical `send-message` and models the harness reaction to a send that does12
not return a clean, timely success: a per-tool-call timeout, and on13
timeout/death a **kill + respawn + re-issue** of the same send (the14
`/mcp`-reconnect + agent-retry loop), up to `--retries` times. All restarts15
share one `COURIER_RELAY_DIR`, exactly as the real leader's restarts share16
`~/.courier`.18
## Root cause20
courier sends **exactly once per tool call** (traced: `telegram.sgl` →21
`tg-send-message` → `tg-api-call` → `http-post/json`; no retry at any layer,22
and the MCP server guards tool handlers so an exception cannot crash the23
process). The duplication is **cross-process**: the `sendMessage` ack read can24
block up to `*send-request-timeout*` (25s). If the leader's MCP client gives up25
sooner — a short tool timeout, or a human/harness `/mcp` because the call26
"looks hung" — it SIGKILLs courier and re-issues the send. Each attempt's POST27
reaches Telegram before the kill → the same message is delivered N times. The28
v0.3.7/0.3.8 `(chat-id,text)` dedup could not stop it: `recent-sends` was29
**in-memory**, so a restarted process started with an empty cache. The30
"`inv=1` then `--- courier restart ---`" in the live log is this31
SIGKILL-on-reconnect, not an internal segfault.33
## Before (pre-fix master `4a5ae2b`, in-memory dedup)35
```36
STORM (delay:8, tool-timeout 3, retries 3): 1 logical send -> 4 deliveries37
```39
Each of the 4 attempts (gen0..gen3) re-delivered because the fresh process's40
in-memory dedup was empty.42
## After (fix: persistent record-before-send dedup, `src/courier/dedup.sgl`)44
```45
baseline ok 1 send -> 1 delivery "Message sent."46
reset (ack lost) 1 send -> 1 delivery "Message sent (ack unconfirmed)."47
STORM x3 1 send -> 1 delivery (gen0 killed mid-send; gen1 suppressed)48
STORM x5 1 send -> 1 delivery49
status:500 / notok 1 send, clean error "…(message not delivered)." NO crash, key released50
never-sent (dead port) 0 deliveries, clean error, key released (retry allowed)51
```53
In the storm, gen0 records the dedup key **before** calling `tg-send-message`,54
delivers, then is SIGKILLed; gen1 (fresh process, empty memory) reads the same55
on-disk key and returns "Message sent." in ~0.1s without re-delivering.57
## Reproduce59
```60
sigil build61
# optional pre-fix control:62
git worktree add /tmp/courier-control master && (cd /tmp/courier-control && sigil deps install && sigil build)63
COURIER_CONTROL_BIN=/tmp/courier-control/build/dev/bin/courier repro/repro-send-duplication.sh64
# => ALL CHECKS PASSED (FIXED storms -> 1; CONTROL storm -> 4)65
```67
## Note on exactly-once vs at-most-once69
Telegram's `sendMessage` has no idempotency key, so exactly-once is only70
achievable by courier suppressing duplicate deliveries. The fix records a send71
**before** attempting delivery and treats a send killed mid-flight as72
delivered (at-most-once). A definite non-delivery (never-sent connect failure,73
or Telegram rejection) **releases** the key so a genuine retry can go through.74
This biases toward "never spam" over "never lose a notification" — the correct75
bias for this bot, and the whole point of the saga.