Bump courier to 0.3.8: best-effort success + sync-log + dedup
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.
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(-)package.sglmodified
(package name: "courier" version: "0.3.7" version: "0.3.8" description: "Chat and notification channel server for Claude Code sessions" url: "https://codeberg.org/sigil/courier" license: "BSD-3-Clause"sigil.lockmodified
(package name: "sigil-telegram" url: "codeberg:sigil/sigil-telegram" ref: "^0.10.0" sha: "e8a6471c15a7e7bad5d400b5ecc1b616fe9c9441" version: "0.10.4") sha: "67862220e42d5fdffe44824143d9b7f72ff0b934" version: "0.10.5") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: "^0.17" (package name: "sigil-http" url: "codeberg:sigil/sigil-http" ref: "^0.16.0" sha: "ea578f14613951d5da5446da04bdb5d2d1ee9cbf" version: "0.16.5") sha: "9a9c716c147136457db2a0cc4a428686bc43a0a8" version: "0.16.6") (package name: "sigil-ansi" url: "codeberg:sigil/sigil-ansi" ref: "^0.16.0"src/courier/config.sglmodified
courier-config-telegram-token courier-config-telegram-chat-id courier-config-telegram-api-url courier-config-telegram-send-disabled courier-config-telegram-send-delay courier-config-allowed-senders load-courier-config) ;; #f = the real https://api.telegram.org. Used to point a test ;; courier at a local mock endpoint (never the live API). (telegram-api-url default: #f) ;; Test hook (COURIER_DISABLE_TELEGRAM_SEND=1): skip the actual ;; Telegram delivery and return success (dry-run). Inert by default. (telegram-send-disabled default: #f) ;; Test hook (COURIER_TELEGRAM_SEND_DELAY=<sec>): sleep before the ;; send (latency simulation). 0 = inert (default). (telegram-send-delay default: 0) (allowed-senders default: '())) ;; ============================================================ (chat-id-str (getenv "COURIER_TELEGRAM_CHAT_ID")) (chat-id (and chat-id-str (string->number chat-id-str))) (api-url (getenv "COURIER_TELEGRAM_API_URL")) (send-disabled (let ((v (getenv "COURIER_DISABLE_TELEGRAM_SEND"))) (and v (not (string=? v "")) (not (string=? v "0"))))) (send-delay (let ((v (getenv "COURIER_TELEGRAM_SEND_DELAY"))) (or (and v (string->number v)) 0))) (yaml (load-config-file))) (courier-config telegram-token: token telegram-chat-id: chat-id telegram-api-url: api-url telegram-send-disabled: send-disabled telegram-send-delay: send-delay allowed-senders: (parse-allowed-senders yaml)))) ;; ============================================================src/courier/main.sglmodified
;; generation, so a hang -> /mcp -> still-hung -> /mcp loop lost ;; the original (load-bearing) evidence. Appending into one file ;; keeps the whole history (capped by *log-carryover-max*). ;; Part 2 (sync logging): the target is a plain file port ;; (open-output-file → async-port? = #f), so log writes never go ;; through the scheduler's await-port-writable / abort-to-prompt path. ;; That keeps logging synchronous — it cannot recurse during exception ;; unwinding (the sigil-core await-port-writable re-entrancy bug only ;; triggers on ASYNC ports like stdout/stderr pipes) and adds no ;; scheduler latency to a send's error path. Leader mode always ;; configures this (main() calls it whenever no explicit --log target ;; was given), so production never logs to an async port. (define (configure-persistent-log!) (let* ((log-dir (path-dirname (default-relay-dir))) (log-path (path-join log-dir "courier.log")))src/courier/telegram.sglmodified
(sigil fs) (sigil path) (sigil telegram) (only (sigil telegram client) tg-ack-unconfirmed?) (sigil mcp server) (sigil log) (courier config) (default-chat-id (courier-config-telegram-chat-id config)) (api-url (or (courier-config-telegram-api-url config) "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 '())) (recent-sends '()) ;; Per-invocation counter, logged at DEBUG (off by default). (send-invocation 0)) (mcp-server-register-tool! server "send-message" "Send a message to a recipient (relay name, chat ID, or 'leader')" '((type . "object") (if (and token chat-id) (let* ((now (current-second)) (key (string-append (number->string chat-id) ":" text))) ;; Prune stale entries, then dedupe: an identical ;; (chat-id, text) send within the window is an ;; upstream retry — skip delivery, return success. ":" 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)) (if (dedup-seen? recent-sends key) (begin (log-info "Duplicate send-message suppressed" chat-id: chat-id) "Message sent.") (begin (set! recent-sends (cons (cons key now) recent-sends)) (tg-send-message (tg-client token: token api-url: api-url request-timeout: *send-request-timeout* connect-timeout: *send-connect-timeout*) chat-id text) (log-info "Telegram message sent" chat-id: chat-id) "Message sent."))) (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) (log-info "Duplicate send-message suppressed" chat-id: chat-id) "Message sent.") (else ;; Test hook: latency simulation (inert by default). (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. (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. (guard (e ((tg-ack-unconfirmed? e) (set! recent-sends (cons (cons key now) recent-sends)) (log-info "Telegram delivered, ack unconfirmed" chat-id: chat-id) "Message sent (ack unconfirmed).")) (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)")))))))))) ;; ============================================================