Fix send-path duplication: persist the send dedup across restarts
The courier-spam storm was send-side duplication, not backlog re-delivery: David always saw many copies of the SAME message. courier sends exactly once per send-message tool call (no retry in telegram.sgl, sigil-telegram, or sigil-http; the MCP server guards handlers so an exception can't crash the process). The duplication is cross-process: the sendMessage ack read can block up to 25s, and if the leader's MCP client gives up sooner (short tool timeout, or a /mcp reconnect because the call "looks hung") it SIGKILLs courier and re-issues the same send. Each attempt reaches Telegram before the kill, so the message is delivered once per attempt. The v0.3.7/0.3.8 dedup couldn't stop it: the recent-sends cache was in-memory, wiped on every restart.
Fix (new module (courier dedup)): - Persist the (chat-id,text) dedup to disk (send-dedup.log beside the relay dir; override COURIERSENDDEDUPFILE) so it survives restarts. - Record a send BEFORE delivery, so a SIGKILL mid-send still dedups the leader's retry. Keys are FNV-1a hashes (no plaintext on disk); sliding window default 60s (COURIERSENDDEDUPWINDOW). - Handler never lets a transport error escape or re-fire a send: tg-ack-unconfirmed keeps the key and reports success; any other error means not-delivered (never-sent or rejected), so release the key for a genuine retry and return a clean "message not delivered" string.
Net: exactly-once on success, at-most-once on a mid-flight kill (the storm), at-least-once on definite non-delivery. Biases toward never spamming over never losing a notification.
Verified off-device against a mock (nothing reaches real Telegram): pre-fix control storms to 4 deliveries, fixed stays at 1. Adds repro/repro-send-duplication.sh + send_driver.py, mock send-failure injection, and test/test-send-dedup.sgl. Full suite 69 pass; check/lint clean.
repro/.gitignore | 4 +++
repro/evidence-2026-07-06-send-duplication.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++
repro/mock_telegram.py | 239 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
repro/repro-send-duplication.sh | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
repro/send_driver.py | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/courier/dedup.sgl | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/courier/telegram.sgl | 108 ++++++++++++++++++++++++++++++------------------------------------
test/test-send-dedup.sgl | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1035 insertions(+), 60 deletions(-)repro/.gitignoreadded
run/*.out*.err__pycache__/repro/evidence-2026-07-06-send-duplication.mdadded
# Send-path duplication: off-device evidence (2026-07-06)All runs go through `repro/mock_telegram.py`; **nothing reached real Telegram**(courier's SEND path is forced to the mock via `COURIER_TELEGRAM_API_URL`).## Setup- `mock_telegram.py` — records every `sendMessage`; can inject send failure modes (`ok`, `reset`, `delay:N`, `status:N`, `notok`) via `POST /_mode`.- `send_driver.py` — a minimal MCP client (the "leader") that fires ONE logical `send-message` and models the harness reaction to a send that does not return a clean, timely success: a per-tool-call timeout, and on timeout/death a **kill + respawn + re-issue** of the same send (the `/mcp`-reconnect + agent-retry loop), up to `--retries` times. All restarts share one `COURIER_RELAY_DIR`, exactly as the real leader's restarts share `~/.courier`.## Root causecourier sends **exactly once per tool call** (traced: `telegram.sgl` →`tg-send-message` → `tg-api-call` → `http-post/json`; no retry at any layer,and the MCP server guards tool handlers so an exception cannot crash theprocess). The duplication is **cross-process**: the `sendMessage` ack read canblock up to `*send-request-timeout*` (25s). If the leader's MCP client gives upsooner — a short tool timeout, or a human/harness `/mcp` because the call"looks hung" — it SIGKILLs courier and re-issues the send. Each attempt's POSTreaches Telegram before the kill → the same message is delivered N times. Thev0.3.7/0.3.8 `(chat-id,text)` dedup could not stop it: `recent-sends` was**in-memory**, so a restarted process started with an empty cache. The"`inv=1` then `--- courier restart ---`" in the live log is thisSIGKILL-on-reconnect, not an internal segfault.## Before (pre-fix master `4a5ae2b`, in-memory dedup)```STORM (delay:8, tool-timeout 3, retries 3): 1 logical send -> 4 deliveries```Each of the 4 attempts (gen0..gen3) re-delivered because the fresh process'sin-memory dedup was empty.## After (fix: persistent record-before-send dedup, `src/courier/dedup.sgl`)```baseline ok 1 send -> 1 delivery "Message sent."reset (ack lost) 1 send -> 1 delivery "Message sent (ack unconfirmed)."STORM x3 1 send -> 1 delivery (gen0 killed mid-send; gen1 suppressed)STORM x5 1 send -> 1 deliverystatus:500 / notok 1 send, clean error "…(message not delivered)." NO crash, key releasednever-sent (dead port) 0 deliveries, clean error, key released (retry allowed)```In the storm, gen0 records the dedup key **before** calling `tg-send-message`,delivers, then is SIGKILLed; gen1 (fresh process, empty memory) reads the sameon-disk key and returns "Message sent." in ~0.1s without re-delivering.## Reproduce```sigil build# optional pre-fix control:git worktree add /tmp/courier-control master && (cd /tmp/courier-control && sigil deps install && sigil build)COURIER_CONTROL_BIN=/tmp/courier-control/build/dev/bin/courier repro/repro-send-duplication.sh# => ALL CHECKS PASSED (FIXED storms -> 1; CONTROL storm -> 4)```## Note on exactly-once vs at-most-onceTelegram's `sendMessage` has no idempotency key, so exactly-once is onlyachievable by courier suppressing duplicate deliveries. The fix records a send**before** attempting delivery and treats a send killed mid-flight asdelivered (at-most-once). A definite non-delivery (never-sent connect failure,or Telegram rejection) **releases** the key so a genuine retry can go through.This biases toward "never spam" over "never lose a notification" — the correctbias for this bot, and the whole point of the saga.repro/mock_telegram.pyadded
#!/usr/bin/env python3"""Mock Telegram Bot API for OFF-DEVICE courier repro.Models the two semantics that matter for the getUpdates-backlog spam bug: * getUpdates(offset=N) CONFIRMS (drops) every pending update with update_id < N, then returns the remaining pending updates. A call with no offset (or offset 0) confirms nothing and returns the whole backlog -- exactly how a fresh poller (offset reset to 0) re-fetches Telegram's ~24h backlog. * sendMessage records the outbound and returns ok. Nothing here ever reaches real Telegram; the point is to COUNT would-be sends.Every request is appended to <logdir>/requests.log so the driver canassert on re-delivery. Control endpoints (not part of the Telegram API)let the driver seed and inject updates: POST /_seed body: {"updates":[{...},...]} replace the backlog POST /_add body: {"text":"...","sender_id":"..","chat_id":".."} append one update GET /_stats {"delivered":[ids], "sends":N}The sentinel-zero (2026-07-02) storm case is exercised by`repro-sentinel-zero.sh`: `_seed []` gives an EMPTY backlog so a cold startdrains count=0, then `_add` accumulates a backlog before a restart -- thescenario where persisting offset=0 (pre-fix) causes re-delivery.Usage: mock_telegram.py <port> <logdir>"""import jsonimport sysimport osimport timeimport threadingfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerPORT = int(sys.argv[1])LOGDIR = sys.argv[2]os.makedirs(LOGDIR, exist_ok=True)REQ_LOG = os.path.join(LOGDIR, "requests.log")SEND_LOG = os.path.join(LOGDIR, "sends.log")# ------------------------------------------------------------------# sendMessage failure-injection (send-path duplication repro).## The default "ok" mode returns a normal Telegram success. The other# modes model the transport conditions that make courier's send handler# NOT return a clean, timely success -- the situations under which the# leader's MCP client would re-issue the send and (pre-fix) re-deliver:## ok normal 200 {ok:true} response# delay:<sec> sleep <sec> BEFORE responding (models a slow ack read;# with <sec> > courier's *send-request-timeout* the read# times out AFTER Telegram already recorded the send)# reset record the send, then drop the connection with NO HTTP# response (models a delivered-but-ack-lost read failure)# status:<code> record the send, respond with an HTTP error status# notok record the send, respond 200 {ok:false} (API-level error)## Set the initial mode via env SEND_MODE; change at runtime via# POST /_mode {"mode":"..."}. Every mode still COUNTS the send, because# in all of them Telegram has actually received (and would deliver) the# message -- that is the whole point of counting deliveries at the mock.# ------------------------------------------------------------------SEND_MODE_DEFAULT = os.environ.get("SEND_MODE", "ok")LOCK = threading.Lock()STATE = { "updates": [], # pending updates (list of dicts with update_id) "next_id": 1, # next update_id to assign via _add "getupdates": 0, # count of getUpdates calls "sends": 0, # count of sendMessage calls "delivered_ids": [], # update_ids the mock RETURNED to a poller (per call) "send_mode": SEND_MODE_DEFAULT,}def logline(path, msg): with open(path, "a") as f: f.write(msg + "\n") f.flush()def make_update(update_id, text, sender_id="42", chat_id="1001"): return { "update_id": update_id, "message": { "message_id": update_id, "date": 1000000 + update_id, "text": text, "from": {"id": int(sender_id), "is_bot": False, "first_name": "David", "username": "daviwil"}, "chat": {"id": int(chat_id), "type": "private"}, }, }class Handler(BaseHTTPRequestHandler): def log_message(self, *a): pass # silence default stderr logging def _body(self): n = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(n) if n else b"" try: return json.loads(raw) if raw else {} except Exception: return {} def _reply(self, obj, code=200): data = json.dumps(obj).encode() try: self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) except (BrokenPipeError, ConnectionResetError): # courier was SIGKILLed mid-send (the storm's restart step) -- # the delivery was still counted; the lost ack is expected. pass def do_GET(self): if self.path == "/_stats": with LOCK: self._reply({"delivered": STATE["delivered_ids"], "sends": STATE["sends"], "getupdates": STATE["getupdates"], "pending": [u["update_id"] for u in STATE["updates"]]}) return self._reply({"ok": False, "description": "not found"}, 404) def do_POST(self): body = self._body() path = self.path # ---- control endpoints ---- if path == "/_seed": with LOCK: STATE["updates"] = list(body.get("updates", [])) STATE["next_id"] = (max([u["update_id"] for u in STATE["updates"]], default=0) + 1) STATE["getupdates"] = 0 STATE["sends"] = 0 STATE["delivered_ids"] = [] self._reply({"ok": True}) return if path == "/_add": with LOCK: uid = STATE["next_id"] STATE["next_id"] += 1 STATE["updates"].append(make_update( uid, body.get("text", "msg"), body.get("sender_id", "42"), body.get("chat_id", "1001"))) self._reply({"ok": True, "update_id": uid}) return if path == "/_mode": with LOCK: STATE["send_mode"] = body.get("mode", "ok") mode = STATE["send_mode"] logline(REQ_LOG, f"--- send_mode set to {mode!r} ---") self._reply({"ok": True, "mode": mode}) return # ---- Telegram Bot API ---- # path looks like /bot<token>/<method> method = path.rsplit("/", 1)[-1] if method == "getUpdates": offset = body.get("offset", 0) or 0 with LOCK: STATE["getupdates"] += 1 if offset > 0: # Confirm: drop everything below the offset. STATE["updates"] = [u for u in STATE["updates"] if u["update_id"] >= offset] result = list(STATE["updates"]) ids = [u["update_id"] for u in result] STATE["delivered_ids"].append({"offset": offset, "returned": ids}) logline(REQ_LOG, f"getUpdates offset={offset} -> returned={ids}") self._reply({"ok": True, "result": result}) return if method == "sendMessage": chat_id = body.get("chat_id") text = body.get("text", "") # Count the send FIRST: in every mode Telegram has received the # message and would deliver it to David's phone. That is exactly # the count that matters -- how many times the phone buzzes. with LOCK: STATE["sends"] += 1 n = STATE["sends"] mode = STATE["send_mode"] logline(SEND_LOG, f"#{n} sendMessage chat_id={chat_id} text={text!r} mode={mode}") logline(REQ_LOG, f"sendMessage chat_id={chat_id} text={text!r} mode={mode}") # ---- failure injection ---- if mode == "reset": # Delivered, but ack read fails: drop the socket, no response. try: self.connection.close() except Exception: pass return if mode.startswith("delay:"): try: secs = float(mode.split(":", 1)[1]) except ValueError: secs = 30.0 time.sleep(secs) # fall through to a normal ok response (may arrive after the # client/courier read deadline already fired) if mode.startswith("status:"): try: code = int(mode.split(":", 1)[1]) except ValueError: code = 500 self._reply({"ok": False, "error_code": code, "description": "injected error"}, code=code) return if mode == "notok": self._reply({"ok": False, "error_code": 400, "description": "injected api error"}) return self._reply({"ok": True, "result": { "message_id": 9000 + n, "date": 1, "chat": {"id": chat_id, "type": "private"}, "text": text}}) return # Any other Telegram method (getMe, etc.) -> generic ok. logline(REQ_LOG, f"{method} (generic-ok)") self._reply({"ok": True, "result": {}})if __name__ == "__main__": logline(REQ_LOG, f"--- mock start on :{PORT} ---") srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) srv.serve_forever()repro/repro-send-duplication.shadded
#!/usr/bin/env bash# Off-device regression harness for the courier SEND-PATH DUPLICATION storm.## Proves, entirely against a local mock (NOTHING reaches real Telegram),# that one logical `send-message` is delivered EXACTLY ONCE even when the# leader's MCP client gives up on a slow send and SIGKILLs+restarts+re-issues# it -- the loop that spammed David with many copies of the same message.## * FIXED binary (this worktree): storm -> 1 delivery.# * optional CONTROL binary (pre-fix master, set COURIER_CONTROL_BIN):# storm -> N>1 deliveries, demonstrating the bug the fix removes.## Mechanism of the storm (see src/courier/dedup.sgl):# the sendMessage ack read can take up to 25s; if the client's tool-call# timeout is shorter (or a human /mcp's because it "looks hung"), it kills# courier and re-issues the send. Each attempt reaches Telegram before the# kill. The pre-fix in-memory dedup was wiped on every restart, so it could# not suppress the retry; the fix persists the dedup to disk and records a# send BEFORE delivery, so a restarted courier suppresses the re-issue.## Usage:# ./repro-send-duplication.sh# COURIER_CONTROL_BIN=/path/to/prefix/courier ./repro-send-duplication.shset -uHERE="$(cd "$(dirname "$0")" && pwd)"BIN="${COURIER_BIN:-$HERE/../build/dev/bin/courier}"PORT="${PORT:-8611}"PY="${PYTHON:-python3}"RUNDIR="$HERE/run"FAILED=0if [ ! -x "$BIN" ]; then echo "FATAL: courier binary not found at $BIN (run 'sigil build' first)" exit 2fistart_mock() { rm -rf "$RUNDIR"; mkdir -p "$RUNDIR" "$PY" "$HERE/mock_telegram.py" "$PORT" "$RUNDIR/mocklog" 2>/dev/null & MOCK=$! sleep 1}stop_mock() { kill "$MOCK" 2>/dev/null; wait "$MOCK" 2>/dev/null; sleep 0.2; }# run_case <label> <bin> <expected-deliveries> <driver-args...>run_case() { local label="$1" bin="$2" expect="$3"; shift 3 start_mock "$PY" "$HERE/send_driver.py" --bin "$bin" \ --api-url "http://127.0.0.1:$PORT" \ --stats-url "http://127.0.0.1:$PORT/_stats" \ --mode-endpoint "http://127.0.0.1:$PORT/_mode" \ --logdir "$RUNDIR" "$@" >/dev/null 2>&1 local got got="$(grep -c sendMessage "$RUNDIR/mocklog/sends.log" 2>/dev/null || echo 0)" stop_mock if [ "$got" = "$expect" ]; then echo " PASS $label: $got delivery(ies) (expected $expect)" else echo " FAIL $label: $got delivery(ies) (expected $expect)" FAILED=1 fi}echo "=== FIXED binary: $BIN ==="run_case "baseline ok -> 1" "$BIN" 1 --mode ok --tool-timeout 30 --retries 0 --text baselinerun_case "reset (ack lost) -> 1" "$BIN" 1 --mode reset --tool-timeout 30 --retries 0 --text resetrun_case "STORM x3 -> 1 (exactly-once)" "$BIN" 1 --mode delay:8 --tool-timeout 3 --retries 3 --text storm3run_case "STORM x5 -> 1 (exactly-once)" "$BIN" 1 --mode delay:8 --tool-timeout 2 --retries 5 --text storm5if [ -n "${COURIER_CONTROL_BIN:-}" ] && [ -x "${COURIER_CONTROL_BIN}" ]; then echo "=== CONTROL (pre-fix) binary: $COURIER_CONTROL_BIN ===" # The pre-fix binary re-delivers once per attempt: 1 logical send + 3 # retries = 4 deliveries. This is the storm the fix removes. run_case "STORM x3 -> 4 (bug present)" "$COURIER_CONTROL_BIN" 4 \ --mode delay:8 --tool-timeout 3 --retries 3 --text storm3else echo "=== CONTROL skipped (set COURIER_CONTROL_BIN to a pre-fix courier) ==="firm -rf "$RUNDIR"if [ "$FAILED" = 0 ]; then echo "ALL CHECKS PASSED" exit 0else echo "SOME CHECKS FAILED" exit 1firepro/send_driver.pyadded
#!/usr/bin/env python3"""Off-device driver for the courier send-path duplication repro.Acts as a minimal MCP client (the "leader") speaking JSON-RPC overcourier's stdio. Fires ONE logical `send-message` and models how thereal leader harness reacts when a send does not return a clean, timelysuccess: * a per-tool-call timeout (the client stops waiting for the response), * on timeout OR courier death, kill + respawn courier and RE-ISSUE the same send, up to --retries times (this is the harness /mcp-reconnect + agent-retry loop that turned one logical send into N deliveries).The bug is proven by pointing courier's SEND path at mock_telegram.py(COURIER_TELEGRAM_API_URL) and counting sendMessage hits: one logicalsend producing >1 delivery == reproduced.NOTHING here can reach real Telegram: the api-url override forces everyrequest to the local mock.Usage: send_driver.py --bin <courier> --api-url <mock-base> --logdir <dir> [--mode ok|reset|delay:N|status:N|notok] [--tool-timeout SEC] [--retries N] [--text STR] [--mode-endpoint URL]"""import argparseimport jsonimport osimport subprocessimport sysimport threadingimport timeimport urllib.requestCHAT_ID = "331005009" # David's real chat id, per the captured log. Never used # live: the api-url override sends only to the mock.def set_mock_mode(mode_endpoint, mode): data = json.dumps({"mode": mode}).encode() req = urllib.request.Request(mode_endpoint, data=data, headers={"Content-Type": "application/json"}) urllib.request.urlopen(req, timeout=5).read()def mock_sends(stats_url): with urllib.request.urlopen(stats_url, timeout=5) as r: return json.load(r)["sends"]class Courier: """A single courier subprocess speaking MCP over stdio.""" def __init__(self, bin_path, env, logdir, gen): self.gen = gen self.errf = open(os.path.join(logdir, f"courier-{gen}.stderr"), "wb") self.proc = subprocess.Popen( [bin_path, "serve"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.errf, env=env, bufsize=0) self._id = 0 self._lock = threading.Lock() def _send(self, obj): line = (json.dumps(obj) + "\n").encode() self.proc.stdin.write(line) self.proc.stdin.flush() def _rpc(self, method, params, timeout): """Send a request, wait up to `timeout` for the matching response. Returns (result_or_error_dict, elapsed). Raises TimeoutError if no response arrives in time, or ProcessLookupError if courier dies.""" with self._lock: self._id += 1 rid = self._id self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) result = {} done = threading.Event() def reader(): nonlocal result for raw in self.proc.stdout: try: msg = json.loads(raw) except Exception: continue if msg.get("id") == rid: result = msg done.set() return t = threading.Thread(target=reader, daemon=True) t.start() start = time.time() ok = done.wait(timeout) elapsed = time.time() - start if not ok: if self.proc.poll() is not None: raise ProcessLookupError(f"courier exited rc={self.proc.returncode}") raise TimeoutError(f"no response to {method} in {timeout}s") return result, elapsed def notify(self, method, params): self._send({"jsonrpc": "2.0", "method": method, "params": params}) def initialize(self, timeout): res, _ = self._rpc("initialize", { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "send-driver", "version": "0"}, }, timeout) self.notify("notifications/initialized", {}) return res def call_send(self, text, timeout): return self._rpc("tools/call", { "name": "send-message", "arguments": {"text": text, "to": CHAT_ID}, }, timeout) def alive(self): return self.proc.poll() is None def kill(self): try: self.proc.kill() self.proc.wait(timeout=5) except Exception: pass try: self.errf.close() except Exception: passdef main(): ap = argparse.ArgumentParser() ap.add_argument("--bin", required=True) ap.add_argument("--api-url", required=True, help="mock base, e.g. http://127.0.0.1:PORT") ap.add_argument("--logdir", required=True) ap.add_argument("--stats-url", required=True) ap.add_argument("--mode-endpoint", required=True) ap.add_argument("--mode", default="ok") ap.add_argument("--tool-timeout", type=float, default=30.0) ap.add_argument("--retries", type=int, default=0, help="max kill+respawn+re-send cycles after a timeout/death") ap.add_argument("--text", default="repro test message") args = ap.parse_args() env = dict(os.environ) env["COURIER_TELEGRAM_TOKEN"] = "TESTTOKEN" env["COURIER_TELEGRAM_CHAT_ID"] = CHAT_ID env["COURIER_TELEGRAM_API_URL"] = args.api_url # Sends ENABLED against the mock (this is the whole point). env.pop("COURIER_DISABLE_TELEGRAM_SEND", None) env["CLAUDE_OPS_LOG_DIR"] = args.logdir # Isolate persistent state (relay dir + the send-dedup.log that the # fix writes beside it) to this run's logdir, so runs don't cross- # contaminate. Every restarted courier gen shares this dir -- exactly # how the real leader's restarts share ~/.courier -- so the persistent # dedup can do its job across process restarts. env["COURIER_RELAY_DIR"] = os.path.join(os.path.abspath(args.logdir), "relays") set_mock_mode(args.mode_endpoint, args.mode) before = mock_sends(args.stats_url) print(f"== driver: mode={args.mode} tool-timeout={args.tool_timeout}s " f"retries={args.retries} ==") attempts = 0 gen = 0 outcome = "UNKNOWN" cur = Courier(args.bin, env, args.logdir, gen) try: cur.initialize(timeout=15) while True: attempts += 1 print(f"-- attempt {attempts}: sending (courier gen {gen}) --") try: res, elapsed = cur.call_send(args.text, timeout=args.tool_timeout) txt = "" if "result" in res: try: txt = res["result"]["content"][0]["text"] except Exception: txt = json.dumps(res["result"]) else: txt = json.dumps(res.get("error", res)) print(f" tool returned in {elapsed:.1f}s: {txt!r}") outcome = "SEND_RETURNED" break except TimeoutError as e: print(f" TIMEOUT: {e}") except ProcessLookupError as e: print(f" COURIER DIED: {e}") # timeout or death: this is where the harness would restart+retry if attempts > args.retries: outcome = "GAVE_UP_AFTER_RETRIES" break cur.kill() gen += 1 print(f" -> restart courier (gen {gen}) and re-issue send") cur = Courier(args.bin, env, args.logdir, gen) cur.initialize(timeout=15) finally: # Close stdin so courier shuts down cleanly, then reap. time.sleep(0.5) try: cur.proc.stdin.close() except Exception: pass time.sleep(0.5) alive = cur.alive() cur.kill() after = mock_sends(args.stats_url) deliveries = after - before print(f"== RESULT: logical_sends=1 attempts={attempts} " f"deliveries_at_mock={deliveries} outcome={outcome} " f"last_courier_alive_before_kill={alive} ==") # Machine-readable summary line for scripts. print(f"SUMMARY mode={args.mode} deliveries={deliveries} " f"attempts={attempts} outcome={outcome}")if __name__ == "__main__": main()src/courier/dedup.sgladded
;;; (courier dedup) - Persistent send-idempotency backstop.;;;;;; courier delivers exactly one Telegram message per `send-message` tool;;; call. The danger is DUPLICATION ACROSS PROCESS RESTARTS: the ack read;;; for a sendMessage can take up to *send-request-timeout* (25s), and if;;; the leader's MCP client gives up sooner -- a short tool-call timeout,;;; or a human/harness `/mcp` reconnect because the call "looks hung" --;;; it SIGKILLs courier and re-issues the same send. Each attempt's POST;;; reaches Telegram before the kill, so the same message is delivered N;;; times (the courier-spam storm). The earlier in-memory (chat-id,text);;; dedup could not stop this: a fresh process starts with an empty cache.;;;;;; The fix is to persist the dedup cache to disk and RECORD A SEND BEFORE;;; attempting delivery, so a restarted courier re-issuing the same send;;; recognises it as already-in-flight/delivered and suppresses it. This;;; trades exactly-once for at-most-once on genuinely ambiguous outcomes;;; (a send killed mid-flight is assumed delivered and not retried), which;;; is the correct bias for a notification bot: a rare missed notification;;; is vastly preferable to a spam storm.;;;;;; Keys are FNV-1a hashes of "<chat-id>:<text>" so no message plaintext;;; is written to disk and the file stays compact. Entries expire on a;;; sliding window (a repeat refreshes the timestamp), so a retry loop is;;; suppressed for as long as it keeps firing, then the key ages out.(define-library (courier dedup) (import (sigil core) (sigil math) (sigil string) (sigil fs) (sigil path) (sigil process) (courier relay)) (export *send-dedup-window* send-dedup-window send-dedup-path send-dedup-key dedup-check-and-record! dedup-unrecord! ;; exported for tests dedup-load-entries dedup-prune) (begin ;; Default sliding-window size (seconds). Comfortably covers a ;; /mcp-reconnect + agent-retry loop (each cycle is seconds) while ;; rarely suppressing an intentional identical re-send. Overridable ;; via COURIER_SEND_DEDUP_WINDOW for tests / tuning. (define *send-dedup-window* 60) (define (send-dedup-window) (: -> number?) (let ((v (getenv "COURIER_SEND_DEDUP_WINDOW"))) (or (and v (let ((n (string->number v))) (and n (> n 0) n))) *send-dedup-window*))) ;; Persist location. Lives beside the relay dir (same place the ;; poller keeps its state), overridable for tests via ;; COURIER_SEND_DEDUP_FILE. (define (send-dedup-path) (: -> string?) (let ((override (getenv "COURIER_SEND_DEDUP_FILE"))) (if (and override (not (string=? override ""))) override (path-join (path-dirname (default-relay-dir)) "send-dedup.log")))) ;; ---- FNV-1a 64-bit key hashing (no plaintext on disk) ---- (define *fnv-offset* 14695981039346656037) (define *fnv-prime* 1099511628211) (define *fnv-mod* 18446744073709551616) ;; 2^64 (define (send-dedup-key chat-id text) (: (any-of integer? string?) string? -> string?) (let* ((s (string-append (if (string? chat-id) chat-id (number->string chat-id)) ":" text)) (n (string-length s))) (let loop ((i 0) (h *fnv-offset*)) (if (>= i n) (number->string h 16) (loop (+ i 1) (modulo (* (bitwise-xor h (char->integer (string-ref s i))) *fnv-prime*) *fnv-mod*)))))) ;; ---- File-backed store: list of (key-string . epoch-seconds) ---- ;; Parse one "epoch hash" line into (hash . epoch), or #f if malformed. (define (parse-entry line) (let ((line (string-trim line))) (if (string=? line "") #f (let ((sp (string-index line (lambda (c) (char=? c #\space))))) (if (not sp) #f (let ((epoch (string->number (substring line 0 sp))) (key (string-trim (substring line (+ sp 1) (string-length line))))) (if (and epoch (> (string-length key) 0)) (cons key epoch) #f))))))) (define (dedup-load-entries path) (: string? -> list?) (if (file-exists? path) (guard (e (else '())) (let loop ((lines (string-split (read-file-string path) "\n")) (acc '())) (if (null? lines) (reverse acc) (let ((entry (parse-entry (car lines)))) (loop (cdr lines) (if entry (cons entry acc) acc)))))) '())) ;; Drop entries older than the window. (define (dedup-prune entries now window) (: list? number? number? -> list?) (cond ((null? entries) '()) ((< (- now (cdr (car entries))) window) (cons (car entries) (dedup-prune (cdr entries) now window))) (else (dedup-prune (cdr entries) now window)))) ;; Remove any entry with the given key. (define (remove-key entries key) (cond ((null? entries) '()) ((string=? (car (car entries)) key) (remove-key (cdr entries) key)) (else (cons (car entries) (remove-key (cdr entries) key))))) (define (has-key? entries key) (cond ((null? entries) #f) ((string=? (car (car entries)) key) #t) (else (has-key? (cdr entries) key)))) (define (save-entries! path entries) (ensure-directory (path-dirname path)) (write-file-string path (apply string-append (map (lambda (e) (string-append (number->string (cdr e)) " " (car e) "\n")) entries)))) ;;; Atomically (single-threaded MCP loop) check whether `key` was ;;; recently sent and record it as sent NOW. ;;; ;;; Returns 'suppress if the key is already present within the window ;;; (a duplicate — caller must NOT deliver), refreshing its timestamp ;;; so an ongoing retry loop stays suppressed. Returns 'proceed if the ;;; key is new, having recorded it BEFORE the caller attempts delivery ;;; (so a crash/kill mid-delivery still dedups the next attempt). (define (dedup-check-and-record! path key now window) (: string? string? number? number? -> symbol?) (let* ((entries (dedup-prune (dedup-load-entries path) now window)) (dup (has-key? entries key))) (save-entries! path (cons (cons key now) (remove-key entries key))) (if dup 'suppress 'proceed))) ;;; Remove a previously-recorded key. Called when the caller learns ;;; the send definitely did NOT reach Telegram, so a genuine retry is ;;; allowed to go through. (define (dedup-unrecord! path key) (: string? string? -> void?) (when (file-exists? path) (guard (e (else (values))) (save-entries! path (remove-key (dedup-load-entries path) key)))))))src/courier/telegram.sglmodified
(sigil mcp server) (sigil log) (courier config) (courier dedup) (courier relay)) (export register-send-message-tool! register-send-media-tool! ;; read, so the request timeout alone can't bound it). (define *send-connect-timeout* 10) ;; Idempotency/dedup backstop for Telegram sends. courier delivers ;; exactly one message per send-message tool call and returns a prompt ;; result; but if the upstream MCP client re-issues an identical tool ;; call (e.g. a timeout-driven retry), courier would deliver it AGAIN. ;; We dedupe identical (chat-id, text) sends within this window: a ;; repeat skips the actual delivery and returns the SAME success (an ;; error would only make the client retry harder). The window must ;; comfortably cover a client's retry interval; a rare intentional ;; identical re-send being suppressed is acceptable for a bot. (define *send-dedup-window* 30) ;; seconds ;; Drop cache entries older than the dedup window. Self-contained ;; (no list-lib dependency); the cache only ever holds the last few ;; seconds of sends, so O(n) is fine. (define (dedup-prune sends now) (cond ((null? sends) '()) ((< (- now (cdr (car sends))) *send-dedup-window*) (cons (car sends) (dedup-prune (cdr sends) now))) (else (dedup-prune (cdr sends) now)))) ;; Is `key` present in the (already-pruned) recent-sends alist? (define (dedup-seen? sends key) (cond ((null? sends) #f) ((string=? (car (car sends)) key) #t) (else (dedup-seen? (cdr sends) key)))) ;; Idempotency/dedup backstop for Telegram sends lives in ;; (courier dedup): a PERSISTENT, restart-surviving (chat-id,text) ;; window. It is the load-bearing defence against the send-path ;; duplication storm — see that module's header. The in-memory ;; version this replaces could not survive the SIGKILL-and-retry ;; loop that caused the spam, because a fresh process started with ;; an empty cache. ;; ============================================================ ;; Send Message Tool "https://api.telegram.org")) (send-disabled (courier-config-telegram-send-disabled config)) (send-delay (courier-config-telegram-send-delay config)) ;; Mutable recent-sends cache (alist of (key . send-second)), ;; updated via set!. Single-threaded MCP loop → no race. (recent-sends '()) ;; Persistent dedup store (survives process restarts) + its ;; sliding window. Resolved once at registration; both honour ;; COURIER_SEND_DEDUP_FILE / COURIER_SEND_DEDUP_WINDOW. (dedup-path (send-dedup-path)) (dedup-window (send-dedup-window)) ;; Per-invocation counter, logged at DEBUG (off by default). (send-invocation 0)) (mcp-server-register-tool! server default-chat-id))) (if (and token chat-id) (let* ((now (current-second)) (key (string-append (number->string chat-id) ":" text)) (key (send-dedup-key chat-id text)) (inv (begin (set! send-invocation (+ send-invocation 1)) send-invocation))) (log-debug "send-message handler" inv: inv chat-id: chat-id) (set! recent-sends (dedup-prune recent-sends now)) (cond ;; Part 3 (dedup backstop): an identical ;; (chat-id, text) send already DELIVERED within ;; the window → suppress, return the same ;; success. (Only delivered sends are recorded, ;; below, so a never-sent failure is NOT deduped ;; and a client retry correctly re-delivers.) ((dedup-seen? recent-sends key) ;; Persistent, restart-surviving dedup. RECORD ;; the key BEFORE attempting delivery: if the ;; leader SIGKILLs courier mid-send and re-issues ;; the same send (the storm), the fresh process ;; sees the recorded key and suppresses it. An ;; in-memory cache could not do this — a restart ;; wiped it, which is why the spam recurred. (case (dedup-check-and-record! dedup-path key now dedup-window) ((suppress) (log-info "Duplicate send-message suppressed" chat-id: chat-id) "Message sent.") (when (and (number? send-delay) (> send-delay 0)) (sleep send-delay)) (if send-disabled ;; Test hook: dry-run — no real delivery, ;; but treat as sent for dedup purposes. ;; Test hook: dry-run — no real delivery. (begin (set! recent-sends (cons (cons key now) recent-sends)) (log-info "Telegram send disabled (dry-run)" chat-id: chat-id) "Message sent.") ;; Part 1 (best-effort success): a send that ;; reached Telegram but whose ack read failed ;; (delivered, ack unconfirmed) returns SUCCESS ;; so the client doesn't retry a delivered ;; message; a genuine never-sent failure ;; propagates as an error (client retry ;; correctly re-delivers). Either delivered ;; outcome records the key for the dedup ;; backstop. ;; Deliver exactly once. The key is already ;; recorded. A transport error must NEVER ;; escape this handler (an MCP error invites ;; a client retry) and must never re-fire a ;; send. (guard (e ((tg-ack-unconfirmed? e) (set! recent-sends (cons (cons key now) recent-sends)) ;; Reached Telegram, ack read ;; failed: treat as delivered, ;; keep the recorded key. (log-info "Telegram delivered, ack unconfirmed" chat-id: chat-id) "Message sent (ack unconfirmed).")) "Message sent (ack unconfirmed).") (else ;; ok:true is the ONLY delivery ;; path; any other error means ;; Telegram did not accept the ;; message (never-sent or ;; rejected). Release the key so ;; a genuine retry can go through, ;; and return cleanly (no re-raise, ;; no crash). (dedup-unrecord! dedup-path key) (log-warn "Telegram send failed" chat-id: chat-id error: (format "~a" e)) "Error: Telegram send failed (message not delivered).")) (tg-send-message (tg-client token: token api-url: api-url request-timeout: *send-request-timeout* connect-timeout: *send-connect-timeout*) chat-id text) (set! recent-sends (cons (cons key now) recent-sends)) (log-info "Telegram message sent" chat-id: chat-id) "Message sent."))))) "Error: Telegram not configured (missing token or chat ID)"))))))))))test/test-send-dedup.sgladded
;; Unit tests for (courier dedup) -- the persistent send-idempotency;; backstop that stops the send-path duplication storm.;;;; The storm is: a send whose ack read is slow gets the leader's MCP;; client to SIGKILL courier and re-issue the same send; each attempt;; reaches Telegram, so one logical send is delivered N times. The old;; in-memory dedup could not stop it because a restarted process starts;; with an empty cache. These tests pin the behaviour that fixes it:;; the dedup is file-backed (survives "restart" = a second call reading;; the same file) and a key is recorded BEFORE delivery.(import (sigil test) (sigil core) (sigil string) (sigil fs) (sigil path) (sigil process) (courier dedup))(define (fresh-path) (path-join (make-temp-directory) "send-dedup.log"));; ============================================================;; Key hashing;; ============================================================(test-group "send-dedup-key" (test "is deterministic for the same (chat-id, text)" (assert-equal (send-dedup-key 331005009 "hello") (send-dedup-key 331005009 "hello"))) (test "differs for different text" (assert-true (not (string=? (send-dedup-key 331005009 "hello") (send-dedup-key 331005009 "hellO"))))) (test "differs for different chat-id" (assert-true (not (string=? (send-dedup-key 1 "hi") (send-dedup-key 2 "hi"))))) (test "accepts a string chat-id equivalently to a number" (assert-equal (send-dedup-key 42 "x") (send-dedup-key "42" "x"))) (test "is a single hex token safe for line-based storage" ;; Text with spaces/newlines must not leak into the key (which is ;; stored as `<epoch> <key>` on one line). (let ((k (send-dedup-key 7 "multi\nline text with spaces"))) (assert-false (string-contains? k " ")) (assert-false (string-contains? k "\n")))));; ============================================================;; check-and-record: the core suppress/proceed decision;; ============================================================(test-group "dedup-check-and-record!" (test "a new key proceeds; an immediate repeat is suppressed" (let ((path (fresh-path)) (k (send-dedup-key 1 "msg"))) (assert-equal 'proceed (dedup-check-and-record! path k 1000 60)) (assert-equal 'suppress (dedup-check-and-record! path k 1001 60)))) (test "distinct keys never suppress each other" (let ((path (fresh-path)) (a (send-dedup-key 1 "aaa")) (b (send-dedup-key 1 "bbb"))) (assert-equal 'proceed (dedup-check-and-record! path a 1000 60)) (assert-equal 'proceed (dedup-check-and-record! path b 1000 60)))) (test "a repeat AFTER the window proceeds again (entry expired)" (let ((path (fresh-path)) (k (send-dedup-key 1 "msg"))) (assert-equal 'proceed (dedup-check-and-record! path k 1000 60)) ;; 61s later, outside the 60s window (assert-equal 'proceed (dedup-check-and-record! path k 1061 60)))) (test "suppression refreshes the timestamp (sliding window)" ;; Record at t=1000; hit at t=1050 (within 60) suppresses AND ;; refreshes to 1050; a hit at t=1100 is 50s after the refresh, so ;; still within the window -> a retry loop stays suppressed as long ;; as it keeps firing. (let ((path (fresh-path)) (k (send-dedup-key 1 "msg"))) (assert-equal 'proceed (dedup-check-and-record! path k 1000 60)) (assert-equal 'suppress (dedup-check-and-record! path k 1050 60)) (assert-equal 'suppress (dedup-check-and-record! path k 1100 60)))));; ============================================================;; Persistence across "restart" (a second reader of the same file);; ============================================================(test-group "persistence across restart" (test "a key recorded by one call is seen by a later call on the same file" ;; This is the whole fix: process A records the key (before it is ;; SIGKILLed mid-send), process B re-issues the same send and reads ;; the SAME file -> suppress. Modeled here as two calls sharing path. (let ((path (fresh-path)) (k (send-dedup-key 331005009 "storm"))) (assert-equal 'proceed (dedup-check-and-record! path k 5000 60)) ;; "process B" -- brand new call, no in-memory state, same file (assert-equal 'suppress (dedup-check-and-record! path k 5002 60)))) (test "recorded entries are loadable from disk" (let ((path (fresh-path)) (k (send-dedup-key 1 "persisted"))) (dedup-check-and-record! path k 2000 60) (let ((entries (dedup-load-entries path))) (assert-equal 1 (length entries)) (assert-equal k (car (car entries))) (assert-equal 2000 (cdr (car entries)))))));; ============================================================;; unrecord: release a key when delivery definitely did not happen;; ============================================================(test-group "dedup-unrecord!" (test "unrecording a key lets the next send proceed" (let ((path (fresh-path)) (k (send-dedup-key 1 "msg"))) (assert-equal 'proceed (dedup-check-and-record! path k 1000 60)) (dedup-unrecord! path k) (assert-equal 'proceed (dedup-check-and-record! path k 1001 60)))) (test "unrecording one key leaves others intact" (let ((path (fresh-path)) (a (send-dedup-key 1 "a")) (b (send-dedup-key 1 "b"))) (dedup-check-and-record! path a 1000 60) (dedup-check-and-record! path b 1000 60) (dedup-unrecord! path a) (assert-equal 'proceed (dedup-check-and-record! path a 1001 60)) (assert-equal 'suppress (dedup-check-and-record! path b 1001 60)))) (test "unrecording on a missing file is a no-op (no crash)" (let ((path (fresh-path)) (k (send-dedup-key 1 "x"))) (dedup-unrecord! path k) ;; file does not exist yet (assert-equal 'proceed (dedup-check-and-record! path k 1000 60)))));; ============================================================;; prune + robustness;; ============================================================(test-group "dedup-prune" (test "drops entries older than the window, keeps fresh ones" (let ((entries (list (cons "old" 1000) (cons "fresh" 1900)))) (let ((kept (dedup-prune entries 1950 60))) (assert-equal 1 (length kept)) (assert-equal "fresh" (car (car kept)))))))(test-group "robustness" (test "a malformed dedup file loads as empty (guarded), send proceeds" (let ((path (fresh-path))) (ensure-directory (path-dirname path)) (write-file-string path "garbage no epoch here\n\n \n") (assert-equal '() (dedup-load-entries path)) (assert-equal 'proceed (dedup-check-and-record! path (send-dedup-key 1 "x") 1000 60)))));; ============================================================;; Config overrides;; ============================================================(test-group "config overrides" (test "COURIER_SEND_DEDUP_WINDOW overrides the default window" (setenv! "COURIER_SEND_DEDUP_WINDOW" "120") (assert-equal 120 (send-dedup-window)) (setenv! "COURIER_SEND_DEDUP_WINDOW" "") (assert-equal *send-dedup-window* (send-dedup-window))) (test "a non-positive / non-numeric window falls back to the default" (setenv! "COURIER_SEND_DEDUP_WINDOW" "0") (assert-equal *send-dedup-window* (send-dedup-window)) (setenv! "COURIER_SEND_DEDUP_WINDOW" "nonsense") (assert-equal *send-dedup-window* (send-dedup-window)) (setenv! "COURIER_SEND_DEDUP_WINDOW" "")) (test "COURIER_SEND_DEDUP_FILE overrides the path" (setenv! "COURIER_SEND_DEDUP_FILE" "/tmp/custom-dedup.log") (assert-equal "/tmp/custom-dedup.log" (send-dedup-path)) (setenv! "COURIER_SEND_DEDUP_FILE" "")))