Skip to content

fix(gateway): a platform replay after a reconnect is no longer answered twice - #120444

Merged
teknium1 merged 1 commit into
mainfrom
fix/gateway-reconnect-dedup
Sep 23, 2026
Merged

teknium1 merged 1 commit into
mainfrom
fix/gateway-reconnect-dedup

Conversation

@teknium1

@teknium1 teknium1 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

When a platform drops and the gateway reconnects it, a message the platform sends again right after the reconnect is now dropped instead of being processed and answered a second time.

Root cause: the reconnect watcher replaces a failed adapter with a new instance, and inbound dedup (MessageDeduplicator) lives on the instance, so the new adapter started with an empty cache.

Changes

  • gateway/platforms/helpers.py: MessageDeduplicator.absorb() copies another cache's live IDs, keeping their original seen times. inbound_dedup_caches(adapter) collects an adapter's MessageDeduplicator attributes by reference. carry_inbound_dedup() seeds a rebuilt adapter from them.
  • gateway/run_adapters.py: the primary reconnect queue entry stores the retired adapter's caches, and _reconnect_failed_platform seeds the new adapter before it connects. The multiplex secondary-profile reconnect (_schedule_secondary_profile_reconnect → _run_secondary_profile_reconnect → _secondary_reconnect_attempt) gets the same handover.
  • The caches are held by reference, so an ID the old adapter admits between being queued and being disconnected still reaches the new one.
  • No per-adapter code. Every adapter that uses the shared helper is covered: Discord, Slack, Mattermost, DingTalk, Teams, Google Chat, LINE, ntfy, Photon, WeCom (both), Weixin, QQ Bot and Yuanbao.
  • Docs: website/docs/developer-guide/adding-platform-adapters.md gets a new "Inbound Deduplication" pattern, and gateway/platforms/ADDING_A_PLATFORM.md gets a checklist bullet saying the reconnect carries this state and a hand-rolled cache does not.

Validation

Live repro: before / after on the real gateway, using the C12 exactly-once harness from #120344. A child-process GatewayRunner runs the real agent against the scripted fake LLM provider and a fake transport. The test injects inbound in-X, the turn gets answered, the adapter hits a retryable fatal, the real reconnect watcher installs a new adapter, and the same in-X is injected again.

origin/main 03544a73 this PR
test_redelivered_inbound_id_processed_once[after_reconnect] (--runxfail) FAILED: second inbound op admitted → an extra model turn … replied in this chat: ['<<…after_reconnect-extra2>> unscripted extra turn …'] passed (3/3 redelivery cells)
new test_platform_reconnect.py::…test_replayed_inbound_id_after_runner_reconnect_is_dropped red (assert False is True) green
new test_multiplex_adapter_registry.py::…test_secondary_reconnect_keeps_inbound_dedup red (assert False is True) green
  • Two invariant tests drive the real _handle_adapter_fatal_error / _handle_profile_adapter_fatal_error → reconnect path. Each checks that the replacement adapter drops the replayed ID, and that a fresh ID still gets through as a control. No existing assertions changed; the multiplex fixture adapter gained a _dedup attribute.
  • Lint and hygiene checks: ruff, check_no_tmp_literals.py, check-windows-footguns.py --all and git diff --check all clean.
  • scripts/run_tests.sh tests/gateway (host load ~380): 8533 passed, 12 failed. Re-running the failing files alone:
    • Five files fail identically on a pristine origin/main worktree: test_compression_failure_session_sync, test_session_db_recovery, test_approve_deny_commands, test_api_server_active_work_drain, test_session_hygiene_turnhold_adoption.
    • The rest pass alone. test_buzz_websocket passes 20/20 on both trees with plain pytest, but occasionally times out under run_tests.sh on either tree.
  • Follow-up for Gateway replies, cron deliveries and the boot outbox are proven exactly-once through faults, crashes and DST (delivery E2E suites + CI) #120344: its after_reconnect cell is a strict xfail (RECONNECT_DEDUP_GAP) and will XPASS once this lands. Drop that marker and fk_tg.redeliver-after_reconnect from KNOWN_GAP_TOKENS.

Not covered

Related

Infographic

infographic

…ed twice

The reconnect watcher replaces a failed adapter with a NEW instance, and
every adapter keeps its inbound MessageDeduplicator on the instance. The
rebuilt adapter started with an empty cache, so a platform re-delivering a
recent inbound ID right after the reconnect (websocket resume replay,
webhook retry, unacked poll batch) got it processed and answered again.

The reconnect queue entry now holds the retired adapter's
MessageDeduplicator attributes by reference, and the rebuilt adapter
absorbs their live IDs before it connects. The multiplex secondary-profile
reconnect path gets the same handover. Any adapter using the shared helper
is covered without per-adapter code.
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 04efcac — fix(gateway): a platform replay after a reconnect is no long

debug info

CI timings

CI timings · View report · View job

Wall time 7m42s vs 6m10s (+24.9%). 10 job(s) slower, 2 faster, 1 unchanged.

  • Python tests / Run tests: +74.0s
  • Python lints / Windows footguns (blocking): +51.0s
  • OS-specific tests / Windows-only tests: -42.0s
  • Docs Site / docs-site-checks: +36.0s
  • Check contributors / check-attribution: +29.0s

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 23, 2026
@teknium1 teknium1 added the ci-reviewed applied to manually approve dangerous changes label Sep 23, 2026
@teknium1
teknium1 merged commit 43b8951 into main Sep 23, 2026
37 checks passed
@teknium1
teknium1 deleted the fix/gateway-reconnect-dedup branch September 23, 2026 17:41
teknium1 added a commit that referenced this pull request Sep 23, 2026
…y cells

A strict xfail on a gap whose fix is an open PR turns main red the moment
that fix merges (XPASS), and the non-strict ones guarded nothing. Each gap
now has a probe (tests/e2e/core/delivery/_pending_fixes.py) that reproduces
the defect's mechanism on the tree under test in a throwaway interpreter;
expect_gap() applies the strict xfail only while the probe still reproduces
it, so the cell becomes a plain test once the fix is in the tree, whatever
the merge order. Covered: #120314, #119970 (soak), #120315, #120377,
#120444 (C12) and #120450 (cell 5). Each probe was checked against every
fix head: it flips on its own PR and on no other.

C12 cells made deterministic (identical outcome on every run):
- long_split streams the whole reply as one chunk; long_streamed and
  stream_timeout_first_send pace chunks so each lands in its own consumer
  tick. The five former coin-flip xfails are now two plain cells and three
  strict #120315 gap cells.
- sent_ack_lost waits until the answer is persisted before the kill, so it
  pins the #120377 recovery; the streamed-before-persisted order is its own
  cell (stream_accepted_unpersisted, a strict live gap with a stalled
  provider stream).
- zzz_unclean_restart compares director.resumes against a snapshot taken
  before its kill instead of requiring it empty: crash cells on their own
  homes may legitimately resume.
- the whole-run audit skips the reconnect replay only while #120444's gap
  is open.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…y cells

A strict xfail on a gap whose fix is an open PR turns main red the moment
that fix merges (XPASS), and the non-strict ones guarded nothing. Each gap
now has a probe (tests/e2e/core/delivery/_pending_fixes.py) that reproduces
the defect's mechanism on the tree under test in a throwaway interpreter;
expect_gap() applies the strict xfail only while the probe still reproduces
it, so the cell becomes a plain test once the fix is in the tree, whatever
the merge order. Covered: #120314, #119970 (soak), #120315, #120377,
#120444 (C12) and #120450 (cell 5). Each probe was checked against every
fix head: it flips on its own PR and on no other.

C12 cells made deterministic (identical outcome on every run):
- long_split streams the whole reply as one chunk; long_streamed and
  stream_timeout_first_send pace chunks so each lands in its own consumer
  tick. The five former coin-flip xfails are now two plain cells and three
  strict #120315 gap cells.
- sent_ack_lost waits until the answer is persisted before the kill, so it
  pins the #120377 recovery; the streamed-before-persisted order is its own
  cell (stream_accepted_unpersisted, a strict live gap with a stalled
  provider stream).
- zzz_unclean_restart compares director.resumes against a snapshot taken
  before its kill instead of requiring it empty: crash cells on their own
  homes may legitimately resume.
- the whole-run audit skips the reconnect replay only while #120444's gap
  is open.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…y cells

A strict xfail on a gap whose fix is an open PR turns main red the moment
that fix merges (XPASS), and the non-strict ones guarded nothing. Each gap
now has a probe (tests/e2e/core/delivery/_pending_fixes.py) that reproduces
the defect's mechanism on the tree under test in a throwaway interpreter;
expect_gap() applies the strict xfail only while the probe still reproduces
it, so the cell becomes a plain test once the fix is in the tree, whatever
the merge order. Covered: #120314, #119970 (soak), #120315, #120377,
#120444 (C12) and #120450 (cell 5). Each probe was checked against every
fix head: it flips on its own PR and on no other.

C12 cells made deterministic (identical outcome on every run):
- long_split streams the whole reply as one chunk; long_streamed and
  stream_timeout_first_send pace chunks so each lands in its own consumer
  tick. The five former coin-flip xfails are now two plain cells and three
  strict #120315 gap cells.
- sent_ack_lost waits until the answer is persisted before the kill, so it
  pins the #120377 recovery; the streamed-before-persisted order is its own
  cell (stream_accepted_unpersisted, a strict live gap with a stalled
  provider stream).
- zzz_unclean_restart compares director.resumes against a snapshot taken
  before its kill instead of requiring it empty: crash cells on their own
  homes may legitimately resume.
- the whole-run audit skips the reconnect replay only while #120444's gap
  is open.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…y cells

A strict xfail on a gap whose fix is an open PR turns main red the moment
that fix merges (XPASS), and the non-strict ones guarded nothing. Each gap
now has a probe (tests/e2e/core/delivery/_pending_fixes.py) that reproduces
the defect's mechanism on the tree under test in a throwaway interpreter;
expect_gap() applies the strict xfail only while the probe still reproduces
it, so the cell becomes a plain test once the fix is in the tree, whatever
the merge order. Covered: #120314, #119970 (soak), #120315, #120377,
#120444 (C12) and #120450 (cell 5). Each probe was checked against every
fix head: it flips on its own PR and on no other.

C12 cells made deterministic (identical outcome on every run):
- long_split streams the whole reply as one chunk; long_streamed and
  stream_timeout_first_send pace chunks so each lands in its own consumer
  tick. The five former coin-flip xfails are now two plain cells and three
  strict #120315 gap cells.
- sent_ack_lost waits until the answer is persisted before the kill, so it
  pins the #120377 recovery; the streamed-before-persisted order is its own
  cell (stream_accepted_unpersisted, a strict live gap with a stalled
  provider stream).
- zzz_unclean_restart compares director.resumes against a snapshot taken
  before its kill instead of requiring it empty: crash cells on their own
  homes may legitimately resume.
- the whole-run audit skips the reconnect replay only while #120444's gap
  is open.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…y cells

A strict xfail on a gap whose fix is an open PR turns main red the moment
that fix merges (XPASS), and the non-strict ones guarded nothing. Each gap
now has a probe (tests/e2e/core/delivery/_pending_fixes.py) that reproduces
the defect's mechanism on the tree under test in a throwaway interpreter;
expect_gap() applies the strict xfail only while the probe still reproduces
it, so the cell becomes a plain test once the fix is in the tree, whatever
the merge order. Covered: #120314, #119970 (soak), #120315, #120377,
#120444 (C12) and #120450 (cell 5). Each probe was checked against every
fix head: it flips on its own PR and on no other.

C12 cells made deterministic (identical outcome on every run):
- long_split streams the whole reply as one chunk; long_streamed and
  stream_timeout_first_send pace chunks so each lands in its own consumer
  tick. The five former coin-flip xfails are now two plain cells and three
  strict #120315 gap cells.
- sent_ack_lost waits until the answer is persisted before the kill, so it
  pins the #120377 recovery; the streamed-before-persisted order is its own
  cell (stream_accepted_unpersisted, a strict live gap with a stalled
  provider stream).
- zzz_unclean_restart compares director.resumes against a snapshot taken
  before its kill instead of requiring it empty: crash cells on their own
  homes may legitimately resume.
- the whole-run audit skips the reconnect replay only while #120444's gap
  is open.
teknium1 added a commit that referenced this pull request Sep 23, 2026
… gaps with no fix PR

#120314, #120377, #120444 and #120450 are on main: their PROBES entries,
every expect_gap naming them and the gap_open(120444) audit branch go, so
those cells are plain tests again (two of those probes read source text,
which the suite must not do). Cell 5's README row no longer claims a
strict xfail.

The two static strict xfails with no probe and no fix PR
(STREAM_ACK_LOST_GAP, STREAM_CRASH_AFTER_ACCEPT_GAP) become run-time
xfails via _pending_fixes.known_failure: only the final assertions run
under it, after every wait (restart, catch-up, settle) has succeeded,
and only an AssertionError matching the gap's own signature XFAILs; any
other failure stays red and a fixed tree simply passes. The whole-run
audit now skips only tokens whose cell actually XFAILed this run.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…y cells

A strict xfail on a gap whose fix is an open PR turns main red the moment
that fix merges (XPASS), and the non-strict ones guarded nothing. Each gap
now has a probe (tests/e2e/core/delivery/_pending_fixes.py) that reproduces
the defect's mechanism on the tree under test in a throwaway interpreter;
expect_gap() applies the strict xfail only while the probe still reproduces
it, so the cell becomes a plain test once the fix is in the tree, whatever
the merge order. Covered: #120314, #119970 (soak), #120315, #120377,
#120444 (C12) and #120450 (cell 5). Each probe was checked against every
fix head: it flips on its own PR and on no other.

C12 cells made deterministic (identical outcome on every run):
- long_split streams the whole reply as one chunk; long_streamed and
  stream_timeout_first_send pace chunks so each lands in its own consumer
  tick. The five former coin-flip xfails are now two plain cells and three
  strict #120315 gap cells.
- sent_ack_lost waits until the answer is persisted before the kill, so it
  pins the #120377 recovery; the streamed-before-persisted order is its own
  cell (stream_accepted_unpersisted, a strict live gap with a stalled
  provider stream).
- zzz_unclean_restart compares director.resumes against a snapshot taken
  before its kill instead of requiring it empty: crash cells on their own
  homes may legitimately resume.
- the whole-run audit skips the reconnect replay only while #120444's gap
  is open.
teknium1 added a commit that referenced this pull request Sep 23, 2026
… gaps with no fix PR

#120314, #120377, #120444 and #120450 are on main: their PROBES entries,
every expect_gap naming them and the gap_open(120444) audit branch go, so
those cells are plain tests again (two of those probes read source text,
which the suite must not do). Cell 5's README row no longer claims a
strict xfail.

The two static strict xfails with no probe and no fix PR
(STREAM_ACK_LOST_GAP, STREAM_CRASH_AFTER_ACCEPT_GAP) become run-time
xfails via _pending_fixes.known_failure: only the final assertions run
under it, after every wait (restart, catch-up, settle) has succeeded,
and only an AssertionError matching the gap's own signature XFAILs; any
other failure stays red and a fixed tree simply passes. The whole-run
audit now skips only tokens whose cell actually XFAILed this run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-reviewed applied to manually approve dangerous changes comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists 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.

2 participants