From 549b87c9aa53fa091d65309b5e0861bf61bfc740 Mon Sep 17 00:00:00 2001 From: AlexFucuson9 Date: Thu, 9 Jul 2026 08:00:11 +0700 Subject: [PATCH] fix(gateway): prevent hygiene compression from destroying archived transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When gateway session-hygiene auto-compression fires with in-place compaction, the flow was: 1. _compress_context() calls archive_and_compact() — soft-archives old rows (active=0, compacted=1) and inserts compacted messages as the new active set. This is the non-destructive, durable path. 2. The hygiene handler then called rewrite_transcript() — which calls replace_messages(active_only=False) — DELETEing ALL rows including the just-archived turns. Silent permanent data loss (#61145). The interactive /compress handler had the same bug. Fix: only call rewrite_transcript() when session rotation produced a new session id (legacy path). When in-place compaction succeeded, skip the rewrite — archive_and_compact() already handled persistence. Closes #61145. --- gateway/run.py | 24 ++++++++++++++++++-- gateway/slash_commands.py | 46 +++++++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 54f38d1bb79..313d9a14825 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11240,7 +11240,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) # Only rewrite the transcript when rotation produced - # a NEW session id OR in-place compaction succeeded. + # a NEW session id. In-place compaction does NOT + # need a rewrite: archive_and_compact() has already + # soft-archived the previous active rows and inserted + # the compacted messages as the new active set inside + # _compress_context(). Calling rewrite_transcript() + # after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs + # ALL rows — including the archived turns that + # archive_and_compact() deliberately preserved + # (silent data loss, #61145). + # # The danger this guards against (mirrors the # /compress fix #44794/#39704): if _compress_context # returns a summary but neither rotates nor completes @@ -11249,7 +11259,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # rewrite_transcript() would DELETE the original # messages and replace them with only the compressed # summary (permanent data loss, #21301). - if _hyg_rotated or _hyg_in_place: + if _hyg_rotated: self.session_store.rewrite_transcript( session_entry.session_id, _compressed ) @@ -11260,6 +11270,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _new_tokens = estimate_messages_tokens_rough( _compressed ) + elif _hyg_in_place: + # archive_and_compact() already persisted the + # compacted transcript inside _compress_context. + # Reset counts to match the new active set. + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) else: # No rewrite happened — transcript preserved # unchanged, so the post-compression counts equal diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3d7bed924c7..aab3b733ae2 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3262,18 +3262,23 @@ class GatewaySlashCommandsMixin: # at it; in place the original transcript is untouched) and lets # the outer handler surface a "compress failed" banner instead. # - # The rewrite runs when EITHER rotation produced a new id OR - # in-place compaction succeeded. It is skipped in the THIRD - # case: _compress_context could NOT rotate AND was not in-place - # (e.g. legacy mode but _session_db unavailable / the DB split - # raised) — there session_id is unchanged for a FAILURE reason, - # and rewrite_transcript() would DELETE the original messages and - # replace them with only the compressed summary (permanent data - # loss #44794, #39704). In in-place mode the unchanged id is - # SUCCESS, so the rewrite is exactly right (and is the durable - # write when the throwaway /compress agent has no _session_db of - # its own). - if rotated or _in_place: + # Only rewrite the transcript when rotation produced a NEW + # session id. In-place compaction does NOT need a rewrite: + # archive_and_compact() has already soft-archived the previous + # active rows and inserted the compacted messages as the new + # active set inside _compress_context(). Calling + # rewrite_transcript() after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs ALL rows — + # including the archived turns that archive_and_compact() + # deliberately preserved (silent data loss, #61145). + # + # The third case: _compress_context could NOT rotate AND was + # not in-place (e.g. legacy mode but _session_db unavailable / + # the DB split raised) — there session_id is unchanged for a + # FAILURE reason, and rewrite_transcript() would DELETE the + # original messages and replace them with only the compressed + # summary (permanent data loss #44794, #39704). + if rotated: if not self.session_store.rewrite_transcript( new_session_id, compressed ): @@ -3281,13 +3286,16 @@ class GatewaySlashCommandsMixin: f"failed to persist compressed transcript for " f"session {new_session_id}" ) - if rotated: - session_entry.session_id = new_session_id - self.session_store._save() - await asyncio.to_thread( - self._sync_telegram_topic_binding, - source, session_entry, reason="compress-command", - ) + session_entry.session_id = new_session_id + self.session_store._save() + await asyncio.to_thread( + self._sync_telegram_topic_binding, + source, session_entry, reason="compress-command", + ) + elif _in_place: + # archive_and_compact() already persisted the compacted + # transcript inside _compress_context — nothing to do. + pass else: logger.warning( "Manual /compress: session rotation did not occur "