fix(telegram): disable DM topic mode when last binding is pruned

Follow-up to #31501. When the send-fallback prune removes a chat's
final telegram_dm_topic_bindings row, also flip
telegram_dm_topic_mode.enabled to 0 in the same transaction.

Without this, a user who turns topics off in the Telegram client
(rather than via /topic off) leaves enabled=1 with zero lanes:
_recover_telegram_topic_thread_id keeps treating the chat as
topic-enabled and lobby messages keep hunting for bindings that no
longer exist. Clearing the flag makes recovery fully stand down once
the dead topics are gone.

Adds 3 regression tests covering the last-binding clear, the
multi-binding no-op, and the unmatched-prune no-op.
This commit is contained in:
Teknium 2026-06-22 12:17:20 -07:00
parent 11246dbe21
commit 6681f28d5b
2 changed files with 101 additions and 2 deletions

View file

@ -4615,8 +4615,19 @@ class SessionDB:
topic, causing tool progress, approvals, and replies to land
in the wrong place. Issue #31501.
Returns the number of rows deleted (0 when the binding was
already absent or the topic-mode tables haven't been
When this prune removes the chat's *last* remaining binding,
the chat's row in ``telegram_dm_topic_mode`` is also flipped to
``enabled = 0`` in the same transaction. Otherwise the chat
would be left in topic mode with zero lanes and
``gateway.run._recover_telegram_topic_thread_id`` keeps treating
the chat as topic-enabled, lobby messages keep hunting for a
binding that no longer exists, and a user who disabled topics in
the Telegram client (rather than via ``/topic off``) stays stuck
until the next send happens to fail. Clearing the flag makes
recovery fully stand down once the dead topics are gone.
Returns the number of binding rows deleted (0 when the binding
was already absent or the topic-mode tables haven't been
migrated yet both are silent no-ops; we never raise from
a cleanup hot path).
"""
@ -4637,6 +4648,29 @@ class SessionDB:
except sqlite3.OperationalError:
# Tables don't exist yet — nothing to prune.
deleted["count"] = 0
return
if not deleted["count"]:
return
# If that was the chat's last binding, disable topic mode for
# the chat so recovery stops steering lobby messages at a now
# empty lane set. Same transaction → no read-after-prune race.
try:
remaining = conn.execute(
"""
SELECT 1 FROM telegram_dm_topic_bindings
WHERE chat_id = ? LIMIT 1
""",
(chat_id,),
).fetchone()
if remaining is None:
conn.execute(
"UPDATE telegram_dm_topic_mode "
"SET enabled = 0, updated_at = ? WHERE chat_id = ?",
(time.time(), chat_id),
)
except sqlite3.OperationalError:
# telegram_dm_topic_mode absent — binding prune still stands.
pass
self._execute_write(_do)
return deleted["count"]

View file

@ -155,6 +155,71 @@ class TestDeleteTelegramTopicBinding:
db.close()
class TestPruneClearsTopicModeWhenLastBindingGone:
"""Proactive cleanup (#31501 follow-up): pruning the chat's final
binding must also flip ``telegram_dm_topic_mode.enabled`` to 0 so
recovery fully stands down covers the user who disabled topics in
the Telegram client without ever running ``/topic off``."""
def test_clears_enabled_when_last_binding_pruned(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
db.enable_telegram_topic_mode(
chat_id="5595856929", user_id="5595856929",
)
_seed_binding(db, thread_id="15287")
assert db.is_telegram_topic_mode_enabled(
chat_id="5595856929", user_id="5595856929",
) is True
removed = db.delete_telegram_topic_binding(
chat_id="5595856929", thread_id="15287",
)
assert removed == 1
assert db.is_telegram_topic_mode_enabled(
chat_id="5595856929", user_id="5595856929",
) is False
db.close()
def test_keeps_enabled_while_other_bindings_remain(self, tmp_path):
# Deleting one of several topics must NOT disable topic mode —
# the chat still has healthy lanes that recovery should serve.
db = SessionDB(db_path=tmp_path / "state.db")
db.enable_telegram_topic_mode(
chat_id="5595856929", user_id="5595856929",
)
_seed_binding(db, thread_id="15287", session_id="sess-stale")
_seed_binding(db, thread_id="15418", session_id="sess-fresh")
db.delete_telegram_topic_binding(
chat_id="5595856929", thread_id="15287",
)
assert db.is_telegram_topic_mode_enabled(
chat_id="5595856929", user_id="5595856929",
) is True
db.close()
def test_noop_prune_leaves_enabled_untouched(self, tmp_path):
# A prune that matches no row must not flip the flag — there's
# still a live binding the (wrong) thread_id didn't match.
db = SessionDB(db_path=tmp_path / "state.db")
db.enable_telegram_topic_mode(
chat_id="5595856929", user_id="5595856929",
)
_seed_binding(db, thread_id="15287")
removed = db.delete_telegram_topic_binding(
chat_id="5595856929", thread_id="99999",
)
assert removed == 0
assert db.is_telegram_topic_mode_enabled(
chat_id="5595856929", user_id="5595856929",
) is True
db.close()
# ---------------------------------------------------------------------------
# Adapter glue — _prune_stale_dm_topic_binding
# ---------------------------------------------------------------------------