Commit38388e36Recorded30 Jun 2026Repositorycourier

Bump courier to 0.3.8: best-effort success + sync-log + dedup

Message

The complete send-spam fix (root cause: an upstream MCP-client retry on a SLOW send error re-delivered an already-delivered message).

Part 1 (best-effort success): relock onto sigil-telegram 0.10.5 / sigil-http 0.16.6. A send that reached Telegram but whose ack read failed (tg-ack-unconfirmed: delivered, ack unconfirmed) now returns SUCCESS ('Message sent (ack unconfirmed).') so the client has nothing to retry. Only a genuine never-sent failure (connect/write) returns an error, where a client retry correctly re-delivers.

Part 2 (sync logging): documented/confirmed leader-mode logging uses a synchronous file port (async-port? = #f), so log writes can't recurse through the scheduler's await-port-writable abort-to-prompt during unwinding, and add no latency. (The underlying re-entrancy is a sigil-core bug, filed separately.)

Part 3 (dedup retained, hardened): the recent-sends key is now recorded only on a DELIVERED send (success or ack-unconfirmed), never on a never-sent failure — so a client retry of a never-sent message is NOT suppressed and correctly re-delivers, while identical retries of a delivered message are still suppressed.

Hygiene: COURIERTELEGRAMAPIURL / COURIERDISABLETELEGRAMSEND (dry-run, no raise) / COURIERTELEGRAMSEND_DELAY test hooks, inert by default; per-invocation id logged at DEBUG (off by default).

Validated in isolation (local mocks, zero live sends): delivered-ack- unconfirmed → success + retry deduped (1 delivery); connect-fail → error + retry not deduped; normal → success + retry deduped. 50/50.

Changed
 package.sgl              |  2 +-
 sigil.lock               |  8 ++++----
 src/courier/config.sgl   | 14 ++++++++++++++
 src/courier/main.sgl     |  9 +++++++++
 src/courier/telegram.sgl | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
 5 files changed, 90 insertions(+), 26 deletions(-)
Diff
package.sglmodified
@@ -5,7 +5,7 @@
5
6
(package
7
name: "courier"
8
version: "0.3.7"
+8
version: "0.3.8"
9
description: "Chat and notification channel server for Claude Code sessions"
10
url: "https://codeberg.org/sigil/courier"
11
license: "BSD-3-Clause"
sigil.lockmodified
@@ -40,8 +40,8 @@
40
(package name: "sigil-telegram"
41
url: "codeberg:sigil/sigil-telegram"
42
ref: "^0.10.0"
43
sha: "e8a6471c15a7e7bad5d400b5ecc1b616fe9c9441"
44
version: "0.10.4")
+43
sha: "67862220e42d5fdffe44824143d9b7f72ff0b934"
+44
version: "0.10.5")
45
(package name: "sigil-test"
46
url: "codeberg:sigil/sigil"
47
ref: "^0.17"
@@ -57,8 +57,8 @@
57
(package name: "sigil-http"
58
url: "codeberg:sigil/sigil-http"
59
ref: "^0.16.0"
60
sha: "ea578f14613951d5da5446da04bdb5d2d1ee9cbf"
61
version: "0.16.5")
+60
sha: "9a9c716c147136457db2a0cc4a428686bc43a0a8"
+61
version: "0.16.6")
62
(package name: "sigil-ansi"
63
url: "codeberg:sigil/sigil-ansi"
64
ref: "^0.16.0"
src/courier/config.sglmodified
@@ -16,6 +16,8 @@
16
courier-config-telegram-token
17
courier-config-telegram-chat-id
18
courier-config-telegram-api-url
+19
courier-config-telegram-send-disabled
+20
courier-config-telegram-send-delay
21
courier-config-allowed-senders
22
23
load-courier-config)
@@ -32,6 +34,12 @@
34
;; #f = the real https://api.telegram.org. Used to point a test
35
;; courier at a local mock endpoint (never the live API).
36
(telegram-api-url default: #f)
+37
;; Test hook (COURIER_DISABLE_TELEGRAM_SEND=1): skip the actual
+38
;; Telegram delivery and return success (dry-run). Inert by default.
+39
(telegram-send-disabled default: #f)
+40
;; Test hook (COURIER_TELEGRAM_SEND_DELAY=<sec>): sleep before the
+41
;; send (latency simulation). 0 = inert (default).
+42
(telegram-send-delay default: 0)
43
(allowed-senders default: '()))
44
45
;; ============================================================
@@ -53,11 +61,17 @@
61
(chat-id-str (getenv "COURIER_TELEGRAM_CHAT_ID"))
62
(chat-id (and chat-id-str (string->number chat-id-str)))
63
(api-url (getenv "COURIER_TELEGRAM_API_URL"))
+64
(send-disabled (let ((v (getenv "COURIER_DISABLE_TELEGRAM_SEND")))
+65
(and v (not (string=? v "")) (not (string=? v "0")))))
+66
(send-delay (let ((v (getenv "COURIER_TELEGRAM_SEND_DELAY")))
+67
(or (and v (string->number v)) 0)))
68
(yaml (load-config-file)))
69
(courier-config
70
telegram-token: token
71
telegram-chat-id: chat-id
72
telegram-api-url: api-url
+73
telegram-send-disabled: send-disabled
+74
telegram-send-delay: send-delay
75
allowed-senders: (parse-allowed-senders yaml))))
76
77
;; ============================================================
src/courier/main.sglmodified
@@ -85,6 +85,15 @@
85
;; generation, so a hang -> /mcp -> still-hung -> /mcp loop lost
86
;; the original (load-bearing) evidence. Appending into one file
87
;; keeps the whole history (capped by *log-carryover-max*).
+88
;; Part 2 (sync logging): the target is a plain file port
+89
;; (open-output-file → async-port? = #f), so log writes never go
+90
;; through the scheduler's await-port-writable / abort-to-prompt path.
+91
;; That keeps logging synchronous — it cannot recurse during exception
+92
;; unwinding (the sigil-core await-port-writable re-entrancy bug only
+93
;; triggers on ASYNC ports like stdout/stderr pipes) and adds no
+94
;; scheduler latency to a send's error path. Leader mode always
+95
;; configures this (main() calls it whenever no explicit --log target
+96
;; was given), so production never logs to an async port.
97
(define (configure-persistent-log!)
98
(let* ((log-dir (path-dirname (default-relay-dir)))
99
(log-path (path-join log-dir "courier.log")))
src/courier/telegram.sglmodified
@@ -13,6 +13,7 @@
13
(sigil fs)
14
(sigil path)
15
(sigil telegram)
+16
(only (sigil telegram client) tg-ack-unconfirmed?)
17
(sigil mcp server)
18
(sigil log)
19
(courier config)
@@ -81,9 +82,13 @@
82
(default-chat-id (courier-config-telegram-chat-id config))
83
(api-url (or (courier-config-telegram-api-url config)
84
"https://api.telegram.org"))
+85
(send-disabled (courier-config-telegram-send-disabled config))
+86
(send-delay (courier-config-telegram-send-delay config))
87
;; Mutable recent-sends cache (alist of (key . send-second)),
88
;; updated via set!. Single-threaded MCP loop → no race.
86
(recent-sends '()))
+89
(recent-sends '())
+90
;; Per-invocation counter, logged at DEBUG (off by default).
+91
(send-invocation 0))
92
(mcp-server-register-tool! server
93
"send-message" "Send a message to a recipient (relay name, chat ID, or 'leader')"
94
'((type . "object")
@@ -112,27 +117,63 @@
117
(if (and token chat-id)
118
(let* ((now (current-second))
119
(key (string-append (number->string chat-id)
115
":" text)))
116
;; Prune stale entries, then dedupe: an identical
117
;; (chat-id, text) send within the window is an
118
;; upstream retry — skip delivery, return success.
+120
":" text))
+121
(inv (begin (set! send-invocation
+122
(+ send-invocation 1))
+123
send-invocation)))
+124
(log-debug "send-message handler" inv: inv
+125
chat-id: chat-id)
126
(set! recent-sends (dedup-prune recent-sends now))
120
(if (dedup-seen? recent-sends key)
121
(begin
122
(log-info "Duplicate send-message suppressed"
123
chat-id: chat-id)
124
"Message sent.")
125
(begin
126
(set! recent-sends
127
(cons (cons key now) recent-sends))
128
(tg-send-message
129
(tg-client token: token
130
api-url: api-url
131
request-timeout: *send-request-timeout*
132
connect-timeout: *send-connect-timeout*)
133
chat-id text)
134
(log-info "Telegram message sent" chat-id: chat-id)
135
"Message sent.")))
+127
(cond
+128
;; Part 3 (dedup backstop): an identical
+129
;; (chat-id, text) send already DELIVERED within
+130
;; the window → suppress, return the same
+131
;; success. (Only delivered sends are recorded,
+132
;; below, so a never-sent failure is NOT deduped
+133
;; and a client retry correctly re-delivers.)
+134
((dedup-seen? recent-sends key)
+135
(log-info "Duplicate send-message suppressed"
+136
chat-id: chat-id)
+137
"Message sent.")
+138
(else
+139
;; Test hook: latency simulation (inert by default).
+140
(when (and (number? send-delay) (> send-delay 0))
+141
(sleep send-delay))
+142
(if send-disabled
+143
;; Test hook: dry-run — no real delivery,
+144
;; but treat as sent for dedup purposes.
+145
(begin
+146
(set! recent-sends
+147
(cons (cons key now) recent-sends))
+148
(log-info "Telegram send disabled (dry-run)"
+149
chat-id: chat-id)
+150
"Message sent.")
+151
;; Part 1 (best-effort success): a send that
+152
;; reached Telegram but whose ack read failed
+153
;; (delivered, ack unconfirmed) returns SUCCESS
+154
;; so the client doesn't retry a delivered
+155
;; message; a genuine never-sent failure
+156
;; propagates as an error (client retry
+157
;; correctly re-delivers). Either delivered
+158
;; outcome records the key for the dedup
+159
;; backstop.
+160
(guard (e ((tg-ack-unconfirmed? e)
+161
(set! recent-sends
+162
(cons (cons key now)
+163
recent-sends))
+164
(log-info "Telegram delivered, ack unconfirmed"
+165
chat-id: chat-id)
+166
"Message sent (ack unconfirmed)."))
+167
(tg-send-message
+168
(tg-client token: token
+169
api-url: api-url
+170
request-timeout: *send-request-timeout*
+171
connect-timeout: *send-connect-timeout*)
+172
chat-id text)
+173
(set! recent-sends
+174
(cons (cons key now) recent-sends))
+175
(log-info "Telegram message sent" chat-id: chat-id)
+176
"Message sent.")))))
177
"Error: Telegram not configured (missing token or chat ID)"))))))))))
178
179
;; ============================================================