Skip to content

fix(gateway): judge delivery success against final content, not flag trust (#95382, #98552 class) - #100533

Merged
teknium1 merged 2 commits into
mainfrom
fix/95382-delivery-content-reconciliation
Sep 1, 2026
Merged

teknium1 merged 2 commits into
mainfrom
fix/95382-delivery-content-reconciliation

Conversation

@teknium1

@teknium1 teknium1 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Infographic

Silent partial delivery — fixed

Summary

Fixes #95382 (Discord: silent partial delivery — first stream edit sets final_response_sent, gateway believes delivery succeeded, message truncated with no re-send) and closes the same false-positive class as #98552 (Telegram: content_delivered=True on a 624-char message truncated at 333 chars).

Reporter @Lenglemetz confirms #95382 is 100% reproducible post-campaign: the WebSocket drops after the first streaming edit (prefix only), the consumer's delivery flags suppress the gateway's normal final send, and the failed normal send is recorded with a non-retryable error — so the delivery-obligation ledger never replays it either. The full 2668-char response sits correctly in state.db while the user sees a truncated prefix and total silence.

Root cause

Two stacked over-trust gaps:

  1. gateway/stream_consumer.py::delivered_final_matches (~L654) — a delivery flag with no recorded turn-final payload returned None (= legacy trust). _stream_confirmed_final_delivery (gateway/run.py ~L31036) and the suppression site (~L31859) both treat anything but an explicit False as "delivered", so a record-less flag set after a partial delivery suppressed the corrective send. Two flag-setting sites still recorded nothing: _try_fresh_final (~L2936) and the native-streaming optimistic finalize (~L3164).
  2. plugins/platforms/discord/adapter.py::send (~L3453) — dead-transport failures returned error="Not connected" / raw exception strings. The ledger's runtime reconnect sweep (sweep_failed_for_runtime, gateway/delivery_ledger.py L394) only replays rows whose error is in _RUNTIME_RETRYABLE_ERRORS = {"send_path_degraded"} — so a Discord final send that failed on a dropped WS was stranded as failed until a full process restart. Telegram already classifies these correctly (adapter.py L5417).

The class fix (delivery judged against final content)

Change File Effect
Record-less flags reconciled against FINAL content via has_delivered_text; only the explicitly-marked ambiguous-timeout path (_delivery_ambiguous, set by _send_empty_fallback_final"ambiguous") keeps legacy trust gateway/stream_consumer.py first-edit-prefix / truncated finalize no longer suppresses the corrective send
_try_fresh_final records its delivered payload gateway/stream_consumer.py stale fresh-final now detectable by reconciliation
Native-streaming optimistic finalize records its frame payload (rolled back with the flags on definitive dispatch failure) gateway/stream_consumer.py WeCom-style partial finalize frame no longer reads as full delivery
Dead-transport send failures classified send_path_degraded (retryable): _client is None + connection-shaped exceptions (_is_discord_transport_error; timeouts excluded — ambiguous) plugins/platforms/discord/adapter.py ledger reconnect sweep replays the stranded final response

Adapter class table

Adapter Same class? Status
Telegram (edit-transport) Yes (#98552, #71643) Covered by the matcher tightening + all its flag sites already record (this repo, #71643 fix)
Discord Yes (#95382) Matcher tightening + send_path_degraded classification (this PR)
WeCom / native-stream adapters Yes (optimistic finalize was record-less) Optimistic finalize now records + rolls back (this PR)
Fresh-final adapters (any platform with prefers_fresh_final_streaming / time-threshold) Yes (_try_fresh_final was record-less) Now records (this PR)
Multi-message split (#78541) Handled pre-existing False on payload-less split preserved; regression suite green

Live repro: deterministic before/after harness (/tmp/repro95382.py, temp HERMES_HOME, real GatewayStreamConsumer, real DiscordAdapter.send, real delivery_ledger SQLite):

--- BEFORE (origin/main) ---
[A] delivered_final_matches (flags set, no record, prefix visible): None
[B] DiscordAdapter.send on dead transport: error='Not connected' retryable=False
[C] reconnect sweep claimed 0 obligation(s)
  A: BUG — legacy trust; gateway suppresses, tail LOST
  B+C: BUG — final response STRANDED in ledger (silent loss until restart)
--- AFTER (fix) ---
[A] delivered_final_matches (flags set, no record, prefix visible): False
[B] DiscordAdapter.send on dead transport: error='send_path_degraded' retryable=True
[C] reconnect sweep claimed 1 obligation(s)
  A: FIXED — mismatch detected; corrective send fires
  B+C: FIXED — dead-transport final send is replayed after reconnect

Tests

New tests/gateway/test_silent_partial_delivery_95382.py (17 tests):

  • Gateway boundary (real GatewayRunner._run_agent + live consumer, pattern from test_stale_finalize_suppression.py): record-less flags must not swallow the reply; dead-transport variant must leave already_sent unset for the normal send / ledger; honest-streaming control still suppresses exactly once.
  • Matcher unit invariants: record-less + partial visible → False; record-less + equal visible → True; ambiguous timeout → None; Suppressing normal final send swallows complete replies on Telegram group/forum sessions (payload-less split-delivery flags final_content_delivered) #78541 split behavior preserved.
  • Flag-site recording: _try_fresh_final records; recorded prefix reads as mismatch.
  • Discord classification + ledger E2E: _client=Nonesend_path_degraded; transport vs HTTP/timeout exception classification; real ledger sweep claims the degraded row and leaves a generic "Not connected" row alone.

Updated test_stale_finalize_suppression.py: the old test_no_record_returns_none pinned the exact over-trust this PR removes — replaced with the three-way contract (False / visible-match True / ambiguous None).

Sabotage-verified: reverting the matcher branch to return None fails 5 of the new tests; restoring goes green.

Targeted suites green (capped systemd-run --user --scope -p MemoryMax=8G): the two regression modules (34 passed) + delivery ledger/producer/delivery/telegram-final/wecom-double-send/progress-topics (124 passed, 1 xfailed) + tests/gateway -k 'stream or finalize or suppression or fresh_final or split or fallback' (720 passed; single test_approve_deny_commands failure is a pre-existing test-isolation flake — passes in its own module on this branch and fails identically on stashed main in the same batch).

Related: #71643 (stale successful finalize — the reconciliation contract this extends), #78541 (split-delivery record), #10748 (mirror case, same over-trust family).

Overlap note: open external PR #100228 (@salch-cred) implements the _try_fresh_final payload-recording sub-fix only (1 of the 4 changes here); maintainer call on whether to land it first for credit and rebase this, or supersede.

…trust (#95382, #98552)

A record-less delivery flag (final_response_sent /
final_content_delivered set with no recorded turn-final payload) was
trusted blindly by delivered_final_matches (None -> legacy trust), so a
first-edit prefix or a truncated finalize suppressed the gateway's
corrective send — silent partial delivery.

- delivered_final_matches: record-less flags are now reconciled against
  the FINAL content via has_delivered_text; only the explicitly-marked
  ambiguous-timeout path (_delivery_ambiguous) keeps legacy trust.
- _try_fresh_final and the native-streaming optimistic finalize now
  record their delivered payload (the last record-less flag setters);
  the optimistic record rolls back on definitive dispatch failure.
- Discord adapter: dead-transport send failures (client gone, WS
  closed/reset) are classified as send_path_degraded (retryable) so the
  delivery-obligation ledger's reconnect sweep replays the stranded
  final response instead of losing it until a process restart.

Fixes #95382; closes the #98552 false-positive class.
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 84b5ee6 — fix(gateway): gate record-less visible-text match on _alread

⚠️ Warnings

OSV vulnerability scan · View job

10 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 4m30s vs 4m21s (+3.4%). 6 job(s) slower, 3 faster, 4 unchanged.

  • Python tests / Run tests: +7.0s
  • Python lints / Windows footguns (blocking): +5.0s
  • OSV scan / Scan lockfiles / osv-scan: +5.0s
  • Check contributors / check-attribution: -5.0s
  • Python tests / e2e: +2.0s

Draft frames set _last_sent_text for dedupe without setting
_already_sent (they are ephemeral); an ungated has_delivered_text match
let a draft-only preview count as durable delivery and regressed
test_relay_seal_failure's dead-transport guarantee on CI.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/discord Discord bot adapter P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 1, 2026
@salch-cred

Copy link
Copy Markdown
Contributor

Thanks for the explicit overlap note and for leaving the call open.

For whoever makes it: #100228 is a strict subset of this PR — the _try_fresh_final recording site only. Two honest paths:

If you land #100228 first: I'll rebase it against this branch and drop my 6-test module in favor of your 17-test one so there's no duplication — the recording change itself is identical. One mechanical note from the #100450 salvage: my authorship survived the cherry-pick but was erased by the squash merge, which is why commits?author=salch-cred on this repo still returns nothing. If this path is chosen, rebase-merge is what actually lands the credit; squash repeats the erasure.

If you supersede: equally fine. The class fix is the better artifact — the Discord send_path_degraded classification and the native-streaming finalize recording are real gaps my PR didn't touch. I'll close #100228 as superseded and confirm here.

Either way your 720-test run covers the 47 existing streaming tests my branch was green against, so nothing is lost in the supersede path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P1 High — major feature broken, no workaround platform/discord Discord bot adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants