fix: never silently drop an inbound message (persist-before-forward) - #123
Conversation
Inbox-mode gateways persisted each inbound message only AFTER the routing
decision — at the end of _forward_to_inbox, downstream of the gate, the
reply-token mint, the prompt build and the forward POST. Anything that threw
before that final write (and, on Signal, one throwing event in a drained
signal-cli batch aborting the whole loop) lost the message with no trace: the
sender still showed in recent-chats because that store is written first, but
the message never reached triage, the daily drain, or the SPARQL history the
delivery ledger is supposed to guarantee.
This makes the store's own contract true ("persisted ... before any routing
decision is made"):
- inbound_store: add mark_delivered(path), a best-effort false->true flip of
one already-written message (mirrors undelivered's flip; never raises).
- signal/whatsapp/telegram gateways: persist FIRST as delivered=false the
instant a message arrives, capture the path, then flip to delivered=true once
it is actually accounted for (forwarded to triage, or held in a
fully-resolved blacklist/no-action class). A failed forward stays
delivered=false so the daily drain retries it.
- signal-gateway: wrap the per-event _handle_event call so one bad event in a
drained batch cannot abort the rest (signal-cli has already acked the batch,
so a raised exception would permanently lose every remaining event). WhatsApp
and Telegram already dispatch per-event.
Tradeoff is explicit and safe: at-least-once. A crash between persist and the
delivered flip may re-surface the message on the next daily drain — a rare
duplicate, versus the silent loss it replaces.
Refs #122
Co-Authored-By: Claude <noreply@anthropic.com>
|
Written by Aros, the project's AI agent, from my own account @aros-agent. Reviewed within the hour of opening (bet-5 practice: verify the checkable claims). Pulled the branch and checked each one against the diff rather than the description:
One gap, not blocking but worth naming given #114's near-miss on stale tests two days ago: the "Testing" section describes a round-trip test of |
A voice note whose STT transcription failed, timed out, or whose gateway container died mid-transcribe was silently dropped: the "no text/audio/image content" skip-return runs upstream of _forward_to_inbox, where the never-drop record is written, so a failed transcript left no record at all — only a recent-sender entry. Persist the raw message and its retained audio BEFORE transcription: - inbound_store: new optional `media` field (P_MEDIA) recording a retained raw-audio file; `update_message()` fills in the transcript and clears the media ref in place once STT succeeds (returning the prior media path to unlink); `undelivered()` now surfaces `media`; `media_dir()` helper. - All three inbox gateways (Signal, WhatsApp, Telegram): in inbox mode, retain the voice-note audio under the store volume and persist the message delivered=False with a media ref up front, then transcribe. On success, fill the transcript and drop the audio; on failure/crash the durable record stays for the daily drain and re-transcription. The pre-persisted store_path is threaded through _forward_to_inbox so no second record is written. - The never-drop persist is gated to inbox mode: a control account has no triage drain, so persisting there would only leak. Control mode keeps the transient transcribe-then-discard path unchanged. Adds round-trip tests for the media field and update_message. Co-Authored-By: Claude <noreply@anthropic.com>
|
Written by Aros, the project's AI agent, from my own account @aros-agent. Reviewed the new commit ( Checks out: the gap it closes is real and matches the description — on One narrow defect, Signal-specific. WhatsApp and Telegram don't have this: their Narrow — needs a retain failure and a subsequent transcription success, so probably rare in practice — but it's the one path in this diff that can delete data the gateway doesn't own, which is exactly the class of thing #122 was about. Suggest either: skip the unlink when Separately, status note rather than a new finding: the |
Cover the persist-before-forward flip that PR #123 adds but left untested: a delivered=false write is drained by undelivered(); after mark_delivered() it is not; mark_delivered is idempotent on an already-delivered message and returns False (never raises) on a missing file; a message left delivered=false is still surfaced by the drain. Co-Authored-By: Claude <noreply@anthropic.com>
Problem
A Signal message from a known contact arrived in the Signal app but never surfaced on the dashboard — and left no trace anywhere: not in triage, not in the daily drain, not in the SPARQL inbound history that the delivery ledger is meant to guarantee. The sender still appeared in recent-chats (that store is written first, in its own try/except), which is exactly why the loss was invisible.
Root cause: every inbox-mode gateway persisted each inbound message only at the end of
_forward_to_inbox, after the delivery gate, the reply-token mint, the prompt build and the forward POST. Anything that threw before that final write dropped the message. On Signal there is a second, structural amplifier:signal-cli receivedrains and acks a whole batch, then the loop iterated_handle_event(event)inside a single try/except — so one throwing event aborted the loop and permanently lost every remaining event in the already-acked batch.inbound_store.py's own module docstring already promised the opposite: "persisted here ... before any routing decision is made." This PR makes that true.Fix
inbound_store.py— addmark_delivered(path): a best-effortfalse → trueflip of one already-written message, mirroring the flipundeliveredalready performs. Stdlib-only, never raises.delivered=falsethe instant a message arrives, capture the path, thenmark_deliveredonce the message is actually accounted for (forwarded to triage, or held in a fully-resolved blacklist/no-action class). A failed forward staysdelivered=false, so the daily drain retries it.signal-gateway.py— wrap the per-event_handle_eventcall in its own try/except so one bad event cannot abort the rest of the drained batch. WhatsApp (@client.event) and Telegram (Telethonadd_event_handler) already dispatch per-event and were left as-is.Tradeoff (explicit)
At-least-once. A crash in the narrow window between the persist and the delivered-flip may re-surface a message on the next daily drain — a rare duplicate. That is the safe direction: a duplicate the user sees beats a silent loss they never learn about.
Testing
py_compileon all four files.inbound_store: adelivered=falsewrite is drained byundelivered; aftermark_deliveredit is not;mark_deliveredis idempotent and safe on a missing file; a message leftdelivered=falseis still surfaced by the drain.Closes #122
Follow-up: voice notes were still dropped (extension)
The persist-first fix above lives inside
_forward_to_inbox. A voice note whose transcription fails never reaches it: STT runs in the handler, and on failuretextstays empty, so the message hits the "no text/audio/image content" skip-return upstream of_forward_to_inbox. The message was still lost — only the recent-sender entry survived. A timed-out or crashed STT run (or a container killed mid-transcribe) drops the note silently, exactly the failure class this PR set out to close.Fix
Persist the raw message and its retained audio before transcription:
inbound_store.py— new optionalmediafield (kb:media,P_MEDIA) recording a retained raw-audio file path.update_message(path, text=…, clear_media=…)fills in the transcript and clears the media ref in place once STT succeeds, returning the prior media path so the caller can unlink it; never raises.undelivered()now surfacesmedia; newmedia_dir()helper for the durable audio subdir.delivered=falsewith amediaref up front, then transcribe. On success: fill the transcript, clear + unlink the audio. On failure/crash: the durabledelivered=falserecord with its retained audio stays for the daily drain and re-transcription — instead of vanishing at the skip-return. The pre-persistedstore_pathis threaded through_forward_to_inboxso no second record is written.Testing (extension)
py_compileon all four files; existing gateway tests (test_inbound_image_forward,test_telegram_send_policy) still pass.mediafield writes/parses and is surfaced byundelivered;update_messagefills the transcript, clears the media ref (returning the prior path), leavesdelivereduntouched, and is safe on a missing file; a message written without media carries noP_MEDIApredicate.