fix(gateway): prevent hygiene compression from destroying archived transcript

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.
This commit is contained in:
AlexFucuson9 2026-07-09 08:00:11 +07:00 committed by Teknium
parent b298fd5db1
commit 549b87c9aa
2 changed files with 49 additions and 21 deletions

View file

@ -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

View file

@ -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 "