Skip to content

fix(compression): recover live tip across rotation chains - #71486

Open
ruizanthony wants to merge 1 commit into
NousResearch:mainfrom
ruizanthony:fix/compression-chain-tip-recovery
Open

fix(compression): recover live tip across rotation chains#71486
ruizanthony wants to merge 1 commit into
NousResearch:mainfrom
ruizanthony:fix/compression-chain-tip-recovery

Conversation

@ruizanthony

@ruizanthony ruizanthony commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix compression continuation recovery when a session has rotated through more than one compression child, without allowing a new turn to continue on a compression-ended parent or with mixed lifecycle bindings.

The resolver now:

  • follows the canonical compression lineage in one SQLite read snapshot;
  • rejects ambiguous canonical siblings, malformed decoy metadata, cycles, stale/non-compression branches, and live non-leaf intermediates;
  • adopts only a unique live leaf whose lifecycle fields are both NULL;
  • acquires the candidate tip lease before loading its transcript, making the adopted snapshot stable against both transcript writers and rotation;
  • revalidates lineage and lease ownership before exposing the child id;
  • refreshes the lease while lifecycle hooks run and synchronously verifies ownership before committing the agent transition;
  • treats an explicit False or exception from context lifecycle hooks as terminal, restores the rollback-capable context binding directly, and aborts before core commit;
  • commits the canonical child before memory-provider notification, then uses a forward-only pending gate: failed providers alone are retried on a later turn, and a successful retry reloads the child's durable transcript before any model, tool, or persistence work;
  • never replays successful provider callbacks as a fake rollback;
  • distinguishes retryable lineage/lease contention from unsupported DB contracts, empty tips, binding failures, and SQLite errors through structured CompressionRecoveryUnavailableError fields;
  • requires recovery lock adapters to declare named patience_s and raise_on_error controls rather than merely accepting **kwargs, and composes each write against one recovery deadline;
  • treats lease expiry as an irreversible fencing boundary: a stalled holder cannot refresh an expired row, even when no competitor has reclaimed it;
  • resolves declared SessionDB, context-lifecycle, rollback-binder, and memory hooks statically without accepting attributes synthesized by __getattr__;
  • refuses memory providers that synthesize on_session_switch through __getattr__, keeping the pending gate active without invoking the dynamic hook;
  • aborts build_turn_context() before persistence, model, or tool execution whenever recovery or pending memory binding cannot complete safely;
  • requires the inbound user batch to persist successfully before returning TurnContext, so a concurrent parent rotation cannot be downgraded to a warning before the first model call;
  • preserves the current main system-prompt deduplication contract when returning the recovered child.

Rebase / conflict resolution

  • Candidate: 08f17204adfd272e610f350ec5261435feb6f2f8
  • Current main parent: 91937a6dc3ffbbe2f3be91a500f0ecf962c4cf53
  • Replaces published predecessor: 9a97f2cb7917a500b8496e8ac70210446b0492ff.
  • Product code is byte-identical to the independently certified predecessor; the only delta is three test-contract updates exposed by the full CI matrix.
  • The original textual conflict was SessionDB.find_live_compression_child() in hermes_state.py.
  • The rebuilt resolver keeps the predecessor's fail-closed traversal and adds the system_prompts join required by current main.

Review feedback addressed

The follow-up covers the sweeper findings and successive exact-SHA counter-review rounds:

  • no shallow depth cap;
  • canonical sibling ambiguity fails closed before lifecycle filtering;
  • malformed marker JSON fails closed;
  • only validated branch/delegate decoys are excluded;
  • a live child must be a leaf;
  • closed non-compression children cannot be traversed;
  • cycles and malformed identifiers fail closed;
  • transcript loading happens only after the candidate lease is acquired;
  • an expired/reclaimed adoption lease cannot report success on a closed tip;
  • context lifecycle callbacks are not replayed against the parent and successor after lease loss;
  • explicit False and exceptions from both context and memory lifecycle hooks are propagated;
  • partial memory-provider success is handled forward-only: core state remains on the child, the turn is blocked, and only failed providers are retried;
  • every pre-commit context error path attempts a direct parent rebind without invoking lifecycle callbacks;
  • persistent child-lease contention and SQLite errors retain distinct terminal diagnostics;
  • lock adapters without bounded write/error controls fail immediately as unsupported_db rather than inheriting the ordinary 20-second SQLite budget;
  • **kwargs-only lock adapters fail the same declared-contract check instead of silently accepting undeclared budget controls;
  • positional-only lock controls cannot be hidden behind **kwargs to retain legacy 20-second/default-no-raise behavior;
  • release failures cannot rewrite a committed adoption as a failure: the exact adoption holder remains active only while the durable lease still matches, then is cleared before append on expiry, reclaim, or unverifiable ownership;
  • an explicit False from lease release is handled as a release failure and preserves the committed holder identity;
  • a parent rotation that wins between initial inspection and inbound persistence aborts the turn before TurnContext, model, tools, or downstream persistence;
  • dynamically synthesized memory-provider session hooks fail closed and cannot clear the forward-only pending gate;
  • dynamically synthesized context lifecycle hooks and rollback binders fail closed without invocation;
  • a synthesized on_session_start cannot fall through to an otherwise declared rollback binder and complete adoption;
  • after a pending memory bind succeeds, turn setup reloads the durable child transcript; if that reload fails, the completed provider transition is recorded and is not replayed on retry;
  • if that child rotates again while memory was pending, the newer canonical tip transcript supersedes the just-reloaded child transcript;
  • a lease expiring during lifecycle work cannot be revived after an ordinary append; recovery aborts and preserves the durable intervening message;
  • adoption leases and refresher threads are stopped/released through finally on every exit.

TDD and validation

RED before the fixes:

  • deep compression chains were not recovered;
  • a live intermediate with a descendant was incorrectly adopted;
  • a competing compressor could publish a successor after final validation while the agent still adopted the closed tip;
  • a transcript write between load and lease acquisition was omitted from the recovered snapshot;
  • lease expiry during lifecycle hooks replayed callbacks across the lost tip, parent, and successor;
  • persistent candidate-lease contention returned None, allowing turn setup to continue on the compression-ended parent;
  • SQLite lock errors were retried as ordinary tip contention under repeated 20-second budgets;
  • undeclared SessionDB contracts and empty durable tips were treated as permissive misses;
  • final-refresh exceptions and failed lifecycle bindings could leave local collaborators bound to an uncommitted tip;
  • adapter release failure masked an already committed adoption;
  • explicit False lifecycle returns were ignored;
  • one memory provider could succeed while another failed, leaving mixed bindings without a retry gate;
  • a holder preserved after release failure remained stale after lease expiry or third-party reclaim.
  • positional-only adapter controls plus **kwargs bypassed the bounded SQLite budget contract;
  • release() returning False was treated as success and discarded the durable holder identity;
  • turn-start batch refusal after concurrent parent rotation was logged and ignored, allowing the first model call.
  • a provider could synthesize on_session_switch via __getattr__, return implicit success, and clear the pending gate without a declared lifecycle contract.
  • an expired, unreclaimed lease could be refreshed by its old holder after an intervening append, authorizing adoption of a stale transcript snapshot.
  • a context compressor could synthesize on_session_start via __getattr__, run the callback, and complete adoption without a declared lifecycle contract.
  • a **kwargs-only lock adapter was treated as if it explicitly declared the bounded SQLite recovery contract.
  • a successful pending memory retry left the caller-supplied parent snapshot in TurnContext instead of reloading the durable child transcript.
  • a synthesized on_session_start could be rejected but then bypassed through a declared bind_session_state fallback, completing adoption.
  • a transcript reload error after successful memory retry could force the completed provider transition to be replayed.
  • full CI exposed two dynamic MagicMock SessionDB fixtures that no longer declared get_session, plus one idle-lock test that still expected continuation after fenced turn-start persistence failed.

GREEN on the exact committed candidate:

  • 1,111 compression, gateway, memory, persistence, recovery, SQLite-state, turn-prologue, sidecar, idle-lock, and non-CLI persistence tests passed across 96 files;
  • all thirteen dedicated regressions for turn-start rotation, bounded adapter declarations, release/append failures, static lifecycle hooks, pending-memory transcript reload, selective no-replay, and expiry fencing passed inside that exact-SHA gate;
  • Ruff 0.15.10, Python compilation, and git diff --check passed;
  • the exact exported tree d0ceb64ccc5944642e7c47e0b147aaacb474d46d is verified read-only.

Scope

Seventeen files only: lineage resolver/caller behavior, adoption/recovery and memory lifecycle contracts, SessionDB lock API controls, transcript-holder validation, turn-start persistence gating, and regression coverage. No dependency, schema, configuration, or unrelated user-facing workflow changes.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 25, 2026
@ruizanthony
ruizanthony force-pushed the fix/compression-chain-tip-recovery branch 3 times, most recently from f9810d6 to 7761d9a Compare July 29, 2026 18:13
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Rebased cleanly onto current main. Exact head: 7761d9ad4369219c282d772b85111c6691d93387. Targeted chain/rotation/lineage gate passed. Please review and merge this PR before #72806.

@ruizanthony

Copy link
Copy Markdown
Contributor Author

Friendly nudge for merge order: this PR is gate-green (38/38 checks) at exact head 7761d9ad43. It should land before #72806 (durable revision fencing), which builds on this lineage-recovery contract — merging this one first keeps #72806's rebase trivial and its gate meaningful. Happy to rebase if upstream has moved. Thanks!

@ysinner

ysinner commented Jul 30, 2026

Copy link
Copy Markdown

Superseded as merge approval: the happy-path 57-hop reproduction below remains valid, but a later fail-closed review found blocking edge cases in the current resolver. See the correction and strict test receipt: #71486 (comment)

Independent production reproduction confirms this bug and the fix:

  • On current main, the direct-child resolver returned None for a stale session whose unique canonical live tip was 57 compression hops downstream. The gateway then could not adopt the continuation and completed the turn without a final assistant message.
  • I applied this PR's exact patch to its recorded base in an isolated worktree. The changed/related test selection passed locally: 95 passed.
  • I also exercised the patched SessionDB against a read-only SQLite backup of that real 57-hop lineage. It resolved a non-empty child and matched an independently walked canonical tip.
  • The PR's current GitHub checks are all green, including all required test/lint, supply-chain, Docker, and desktop E2E gates.

This is not theoretical depth hardening: repeated compression on a long-lived WebUI session reproduced it in production. The multi-hop traversal and gateway rerouting here address the observed failure class.

@ysinner

ysinner commented Jul 30, 2026

Copy link
Copy Markdown

Final correction for exact PR head 7761d9ad4369219c282d772b85111c6691d93387.

The original 57-hop production reproduction remains valid, but the current PR head should not be merged unchanged. The earlier strict follow-up still had a shallow 100-hop cap and did not cover two caller-side FTS recovery errors. I replaced it with a complete follow-up against the exact PR head.

The final follow-up:

  • walks until a unique live tip or a cycle, with no shallow depth cap;
  • validates all canonical children before removing only parent-bound branch/delegate and tool decoys;
  • fails closed on malformed/non-TEXT metadata, lifecycle inconsistency, forks and wrong-parent markers;
  • holds one connection-specific SQLite read snapshot for the full walk;
  • after a successful FTS retry, returns on an empty queue or explicitly advances to pending[0];
  • retries FTS corruption on the actual reroute child_id, not the archived parent.

Verification on the exact PR head plus this patch:

30/30 lineage tests passed
15/15 Gateway SessionDB recovery tests passed
514/514 lineage + state tests passed
py_compile PASS
Ruff PASS
git diff --check PASS
resolver method SHA-256 matches independently approved production method:
0446d624cb8386b5c907ac76df1dd15372f2b4d068ff37fc88e4452ec6a6e42c

Patch SHA-256:

029b886f00d8447a554533bc9371e925a1e44581dbb3089349b2cd0d7577b73a
Complete follow-up patch for exact head 7761d9ad…
diff --git a/gateway/session.py b/gateway/session.py
index 89f418866..d7c4ab3c9 100644
--- a/gateway/session.py
+++ b/gateway/session.py
@@ -2978,11 +2978,24 @@ class SessionStore:
                     child = self._db.find_live_compression_child(session_id)
                     child_id = str(child["id"]) if child and child.get("id") else ""
                     if child_id:
+                        reroute_succeeded = False
                         try:
                             self._append_transcript_message(child_id, msg)
                         except Exception as reroute_exc:
                             exc = reroute_exc
+                            if (
+                                self._is_fts_corruption_error(reroute_exc)
+                                and self._rebuild_fts_once()
+                            ):
+                                try:
+                                    self._append_transcript_message(child_id, msg)
+                                except Exception as retry_exc:
+                                    exc = retry_exc
+                                else:
+                                    reroute_succeeded = True
                         else:
+                            reroute_succeeded = True
+                        if reroute_succeeded:
                             with self._transcript_retry_lock:
                                 if pending and pending[0] is msg:
                                     pending.pop(0)
@@ -3047,6 +3060,8 @@ class SessionStore:
                             if not pending:
                                 self._dirty_transcripts.pop(queue_session_id, None)
                                 self._transcript_append_failures.pop(session_id, None)
+                                return
+                            msg = pending[0]
                         continue
                 with self._transcript_retry_lock:
                     failures = self._transcript_append_failures.get(session_id, 0) + 1
diff --git a/hermes_state.py b/hermes_state.py
index 51768174e..0221b7e84 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -2760,59 +2760,119 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
     def find_live_compression_child(
         self, parent_session_id: str
     ) -> Optional[Dict[str, Any]]:
-        """Follow a unique compression-continuation chain to its live tip.
-
-        A stale agent may observe that another compression path already rotated
-        its parent more than once. Recovery is safe only when every hop in the
-        durable lineage identifies exactly one continuation. Multiple eligible
-        children are treated as ambiguous and fail closed rather than guessing
-        which transcript owns subsequent messages.
+        """Return the unique live tip of a compression continuation chain.
+
+        A stale agent may still address any archived segment after one or more
+        compression rotations. Recovery is safe only while every compression
+        segment has exactly one canonical continuation. Branches, delegates,
+        tool children, malformed metadata, invalid lifecycle states, and
+        lineage cycles all fail closed rather than guessing which transcript
+        owns subsequent messages.
         """
         if not parent_session_id:
             return None
-        current_session_id = parent_session_id
-        seen: set[str] = set()
         with self._lock:
             conn = self._conn
-            if conn is None:
+            if conn is None or conn.in_transaction:
                 return None
-            for _ in range(100):
-                if current_session_id in seen:
-                    return None
-                seen.add(current_session_id)
-                parent = conn.execute(
-                    "SELECT ended_at, end_reason FROM sessions WHERE id = ?",
-                    (current_session_id,),
-                ).fetchone()
-                if (
-                    parent is None
-                    or parent["ended_at"] is None
-                    or parent["end_reason"] != "compression"
-                ):
-                    return None
+
+            def _canonical_children(session_id: str):
                 rows = conn.execute(
                     """
-                    SELECT * FROM sessions
+                    SELECT id, ended_at, end_reason, model_config, source
+                    FROM sessions
                     WHERE parent_session_id = ?
-                      AND (ended_at IS NULL OR end_reason = 'compression')
-                      AND json_extract(COALESCE(model_config, '{}'), '$._branched_from') IS NULL
-                      AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL
-                      AND COALESCE(source, '') != 'tool'
-                    ORDER BY started_at ASC
-                    LIMIT 2
+                    ORDER BY started_at ASC, id ASC
                     """,
-                    (current_session_id,),
+                    (session_id,),
                 ).fetchall()
-                if len(rows) != 1:
-                    return None
-                child = rows[0]
-                child_session_id = str(child["id"] or "")
-                if not child_session_id or child_session_id in seen:
-                    return None
-                if child["ended_at"] is None:
-                    return dict(child)
-                current_session_id = child_session_id
-        return None
+                canonical = []
+                for row in rows:
+                    source = row["source"]
+                    if not isinstance(source, str) or not source:
+                        return None
+                    if source.lower() == "tool":
+                        continue
+                    raw_config = row["model_config"]
+                    if raw_config is None:
+                        model_config = {}
+                    elif not isinstance(raw_config, str):
+                        return None
+                    else:
+                        try:
+                            model_config = json.loads(raw_config)
+                        except (TypeError, ValueError, json.JSONDecodeError):
+                            return None
+                        if not isinstance(model_config, dict):
+                            return None
+                    marker_names = [
+                        marker
+                        for marker in ("_branched_from", "_delegate_from")
+                        if marker in model_config
+                    ]
+                    if len(marker_names) > 1:
+                        return None
+                    if marker_names:
+                        marker_value = model_config[marker_names[0]]
+                        if (
+                            not isinstance(marker_value, str)
+                            or not marker_value
+                            or marker_value != session_id
+                        ):
+                            return None
+                        continue
+                    canonical.append(row)
+                return canonical
+
+            conn.execute("BEGIN")
+            try:
+                current_session_id = parent_session_id
+                seen: set[str] = set()
+                while current_session_id not in seen:
+                    seen.add(current_session_id)
+                    parent = conn.execute(
+                        "SELECT ended_at, end_reason FROM sessions WHERE id = ?",
+                        (current_session_id,),
+                    ).fetchone()
+                    if (
+                        parent is None
+                        or parent["ended_at"] is None
+                        or parent["end_reason"] != "compression"
+                    ):
+                        return None
+
+                    children = _canonical_children(current_session_id)
+                    if children is None or len(children) != 1:
+                        return None
+                    child = children[0]
+                    child_session_id = child["id"]
+                    if (
+                        not isinstance(child_session_id, str)
+                        or not child_session_id
+                        or child_session_id in seen
+                    ):
+                        return None
+
+                    child_ended_at = child["ended_at"]
+                    child_end_reason = child["end_reason"]
+                    if child_ended_at is None:
+                        if child_end_reason is not None:
+                            return None
+                        descendants = _canonical_children(child_session_id)
+                        if descendants is None or descendants:
+                            return None
+                        live_child = conn.execute(
+                            "SELECT * FROM sessions WHERE id = ?",
+                            (child_session_id,),
+                        ).fetchone()
+                        return dict(live_child) if live_child is not None else None
+                    if child_end_reason != "compression":
+                        return None
+                    current_session_id = child_session_id
+                return None
+            finally:
+                if conn.in_transaction:
+                    conn.rollback()
 
     def publish_compression_child(
         self,
diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py
index f6f7b3703..7fb3c3d3c 100644
--- a/tests/gateway/test_session.py
+++ b/tests/gateway/test_session.py
@@ -2515,6 +2515,114 @@ class TestGatewaySessionDbRecovery:
         store.rewind_session("s1", 1)
         assert "s1" not in store._dirty_transcripts
 
+    def test_fts_retry_success_empty_queue_does_not_duplicate(self):
+        import threading
+
+        store = object.__new__(SessionStore)
+        store._transcript_retry_lock = threading.Lock()
+        store._dirty_transcripts = {}
+        store._transcript_append_failures = {}
+        attempts = []
+        first_attempt = True
+
+        def _append(session_id, message):
+            nonlocal first_attempt
+            attempts.append((session_id, message["content"]))
+            if first_attempt:
+                first_attempt = False
+                raise RuntimeError("database disk image is malformed")
+
+        store._append_transcript_message = _append
+        store._rebuild_fts_once = lambda: True
+
+        store._append_to_transcript_serialized(
+            "session", {"role": "user", "content": "only"}
+        )
+
+        assert attempts == [("session", "only"), ("session", "only")]
+        assert store._dirty_transcripts == {}
+
+    def test_fts_retry_success_advances_to_remaining_message(self):
+        import threading
+
+        store = object.__new__(SessionStore)
+        store._transcript_retry_lock = threading.Lock()
+        store._dirty_transcripts = {
+            "session": [{"role": "user", "content": "older"}]
+        }
+        store._transcript_append_failures = {}
+        attempts = []
+        first_attempt = True
+
+        def _append(session_id, message):
+            nonlocal first_attempt
+            attempts.append((session_id, message["content"]))
+            if first_attempt:
+                first_attempt = False
+                raise RuntimeError("database disk image is malformed")
+
+        store._append_transcript_message = _append
+        store._rebuild_fts_once = lambda: True
+
+        store._append_to_transcript_serialized(
+            "session", {"role": "assistant", "content": "newer"}
+        )
+
+        assert attempts == [
+            ("session", "older"),
+            ("session", "older"),
+            ("session", "newer"),
+        ]
+        assert store._dirty_transcripts == {}
+
+    def test_fts_retry_during_reroute_targets_child(self):
+        import threading
+        from types import SimpleNamespace
+        from typing import Any
+
+        from hermes_state import CompressionSessionClosedError
+
+        class FakeDb:
+            def find_live_compression_child(self, session_id):
+                assert session_id == "parent"
+                return {"id": "child"}
+
+        store: Any = object.__new__(SessionStore)
+        store._db = FakeDb()
+        store._lock = threading.RLock()
+        store._entries = {"route": SimpleNamespace(session_id="parent")}
+        store._save = lambda: None
+        store._transcript_retry_lock = threading.Lock()
+        store._dirty_transcripts = {}
+        store._transcript_append_failures = {}
+        store._transcript_reroutes = {}
+        attempts = []
+        child_first_attempt = True
+
+        def _append(session_id, message):
+            nonlocal child_first_attempt
+            attempts.append((session_id, message["content"]))
+            if session_id == "parent":
+                raise CompressionSessionClosedError("parent")
+            if child_first_attempt:
+                child_first_attempt = False
+                raise RuntimeError("database disk image is malformed")
+
+        store._append_transcript_message = _append
+        store._rebuild_fts_once = lambda: True
+
+        store._append_to_transcript_serialized(
+            "parent", {"role": "assistant", "content": "rerouted"}
+        )
+
+        assert attempts == [
+            ("parent", "rerouted"),
+            ("child", "rerouted"),
+            ("child", "rerouted"),
+        ]
+        assert store._entries["route"].session_id == "child"
+        assert store._dirty_transcripts == {}
+
     def test_fts_corruption_error_does_not_match_false_positives(self):
         """_is_fts_corruption_error must not match unrelated error strings
         containing 'fts' as a substring (e.g. 'shifts', 'gifts')."""
diff --git a/tests/state/test_compression_lineage_guard.py b/tests/state/test_compression_lineage_guard.py
index 7a4869e4a..072cdaf84 100644
--- a/tests/state/test_compression_lineage_guard.py
+++ b/tests/state/test_compression_lineage_guard.py
@@ -126,12 +126,10 @@ def test_find_live_compression_child_rejects_cycle(db: SessionDB) -> None:
     assert db.find_live_compression_child("parent") is None
 
 
-def test_find_live_compression_child_accepts_maximum_supported_depth(
-    db: SessionDB,
-) -> None:
+def test_find_live_compression_child_has_no_shallow_depth_cap(db: SessionDB) -> None:
     _compression_parent(db)
     parent = "parent"
-    for index in range(99):
+    for index in range(128):
         child = f"compressed-{index}"
         db.create_session(child, source="webui", parent_session_id=parent)
         db.end_session(child, "compression")
@@ -144,15 +142,92 @@ def test_find_live_compression_child_accepts_maximum_supported_depth(
     assert child["id"] == "deep-live-tip"
 
 
-def test_find_live_compression_child_bounds_pathological_depth(db: SessionDB) -> None:
+def test_find_live_compression_child_rejects_live_intermediate(
+    db: SessionDB,
+) -> None:
     _compression_parent(db)
-    parent = "parent"
-    for index in range(100):
-        child = f"compressed-{index}"
-        db.create_session(child, source="webui", parent_session_id=parent)
-        db.end_session(child, "compression")
-        parent = child
-    db.create_session("too-deep-tip", source="webui", parent_session_id=parent)
+    db.create_session("live-intermediate", source="webui", parent_session_id="parent")
+    db.create_session(
+        "live-grandchild",
+        source="webui",
+        parent_session_id="live-intermediate",
+    )
+
+    assert db.find_live_compression_child("parent") is None
+
+
+def test_find_live_compression_child_counts_closed_noncompression_sibling(
+    db: SessionDB,
+) -> None:
+    _compression_parent(db)
+    db.create_session("closed-sibling", source="webui", parent_session_id="parent")
+    db.end_session("closed-sibling", "agent_close")
+    db.create_session("live-child", source="webui", parent_session_id="parent")
+
+    assert db.find_live_compression_child("parent") is None
+
+
+def test_find_live_compression_child_rejects_live_row_with_end_reason(
+    db: SessionDB,
+) -> None:
+    _compression_parent(db)
+    db.create_session("inconsistent", source="webui", parent_session_id="parent")
+    with db._lock:
+        assert db._conn is not None
+        db._conn.execute(
+            "UPDATE sessions SET end_reason = 'compression' WHERE id = ?",
+            ("inconsistent",),
+        )
+        db._conn.commit()
+
+    assert db.find_live_compression_child("parent") is None
+
+
+def test_find_live_compression_child_rejects_malformed_decoy_marker(
+    db: SessionDB,
+) -> None:
+    _compression_parent(db)
+    db.create_session("live-child", source="webui", parent_session_id="parent")
+    db.create_session(
+        "malformed-decoy",
+        source="webui",
+        parent_session_id="parent",
+        model_config={"_branched_from": None},
+    )
+
+    assert db.find_live_compression_child("parent") is None
+
+
+def test_find_live_compression_child_rejects_nontext_child_metadata(
+    db: SessionDB,
+) -> None:
+    _compression_parent(db)
+    db.create_session("blob-config", source="webui", parent_session_id="parent")
+    with db._lock:
+        assert db._conn is not None
+        db._conn.execute(
+            "UPDATE sessions SET model_config = ? WHERE id = ?",
+            (b"{}", "blob-config"),
+        )
+        db._conn.commit()
+
+    assert db.find_live_compression_child("parent") is None
+
+
+@pytest.mark.parametrize("raw_config", ["{not-json", "[]", "null"])
+def test_find_live_compression_child_rejects_malformed_child_metadata(
+    db: SessionDB,
+    raw_config: str,
+) -> None:
+    _compression_parent(db)
+    db.create_session("malformed", source="webui", parent_session_id="parent")
+    with db._lock:
+        assert db._conn is not None
+        db._conn.execute(
+            "UPDATE sessions SET model_config = ? WHERE id = ?",
+            (raw_config, "malformed"),
+        )
+        db._conn.commit()
 
     assert db.find_live_compression_child("parent") is None
 
@@ -198,6 +273,68 @@ def test_find_live_compression_child_ignores_non_continuation_children(
     assert child["id"] == "canonical"
 
 
+@pytest.mark.parametrize("marker", ["_branched_from", "_delegate_from"])
+def test_find_live_compression_child_rejects_decoy_marker_for_different_parent(
+    db: SessionDB,
+    marker: str,
+) -> None:
+    _compression_parent(db)
+    db.create_session("canonical", source="webui", parent_session_id="parent")
+    db.create_session(
+        "false-decoy",
+        source="webui",
+        parent_session_id="parent",
+        model_config={marker: "different-parent"},
+    )
+
+    assert db.find_live_compression_child("parent") is None
+
+
+def test_find_live_compression_child_ignores_malformed_tool_decoy(
+    db: SessionDB,
+) -> None:
+    _compression_parent(db)
+    db.create_session("live-tip", source="webui", parent_session_id="parent")
+    db.create_session("tool-child", source="tool", parent_session_id="parent")
+    with db._lock:
+        assert db._conn is not None
+        db._conn.execute(
+            "UPDATE sessions SET model_config = ? WHERE id = ?",
+            ("{not-json", "tool-child"),
+        )
+        db._conn.commit()
+
+    child = db.find_live_compression_child("parent")
+
+    assert child is not None
+    assert child["id"] == "live-tip"
+
+
+def test_find_live_compression_child_ignores_decoys_below_live_tip(
+    db: SessionDB,
+) -> None:
+    _compression_parent(db)
+    db.create_session("live-tip", source="webui", parent_session_id="parent")
+    db.create_session(
+        "branch",
+        source="webui",
+        parent_session_id="live-tip",
+        model_config={"_branched_from": "live-tip"},
+    )
+    db.create_session(
+        "delegate",
+        source="webui",
+        parent_session_id="live-tip",
+        model_config={"_delegate_from": "live-tip"},
+    )
+    db.create_session("tool-child", source="tool", parent_session_id="live-tip")
+
+    child = db.find_live_compression_child("parent")
+
+    assert child is not None
+    assert child["id"] == "live-tip"
+
+
 def test_append_message_rejects_compression_ended_parent_atomically(db: SessionDB) -> None:
     _compression_parent(db)
     before = db.get_session("parent")["message_count"]

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the real multi-hop recovery gap: current main still has a direct-child-only resolver at hermes_state.py:3076-3113.

Problems

  • hermes_state.py:2812 at PR head returns a child solely because ended_at IS NULL; it does not prove the row is a leaf. A live intermediate with a canonical descendant would be adopted instead of failing closed.
  • hermes_state.py:2797 filters out closed non-compression children before len(rows) is checked, so a canonical agent_close sibling does not make the lineage ambiguous.
  • hermes_state.py:2798-2799 use SQLite json_extract() on arbitrary metadata. Malformed values can raise instead of failing closed, and decoy markers are not validated before excluding a child.

Suggested changes

  • Read all direct children in one snapshot, defensively validate TEXT metadata, remove only validated decoys, then require exactly one canonical child before lifecycle classification.
  • Return a live child only if both lifecycle fields are NULL and it has no canonical descendants; continue only through a child closed for compression. Add the corresponding malformed-metadata and ambiguity regression coverage.

Automated hermes-sweeper review.

Comment thread hermes_state.py Outdated
Comment thread hermes_state.py Outdated
Comment thread hermes_state.py Outdated
@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@ruizanthony
ruizanthony force-pushed the fix/compression-chain-tip-recovery branch from 7761d9a to 6e71486 Compare August 3, 2026 07:12
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Addressed all three fail-closed review findings at exact head 6e7148621b360daaac6ef83b8dcbe4a68f61dd5f: the resolver now reads every direct child in one SQLite snapshot, validates TEXT/JSON metadata and parent-bound decoys before cardinality/lifecycle classification, and accepts a live continuation only when it is a canonical leaf with no canonical descendants. Added malformed metadata, invalid decoy, closed-sibling ambiguity, live-intermediate, and deep-chain regressions. Rebased onto current main; targeted adjacent receipt: 141 passed; compileall and diff-check passed.

@ruizanthony
ruizanthony force-pushed the fix/compression-chain-tip-recovery branch from 6e71486 to 5d4539d Compare August 3, 2026 13:02
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Rebased on current main and republished at exact head 5d4539dcfc782953fbc27bd630b96b245c7c7621. #72806 is stacked directly on this commit. Exact critical lineage/rotation/state gate: 96/96 passed. Please re-review this SHA before the dependent durable-revision PR.

@ruizanthony
ruizanthony force-pushed the fix/compression-chain-tip-recovery branch from 5d4539d to 9a97f2c Compare August 4, 2026 00:51
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Published exact candidate 9a97f2cb7917a500b8496e8ac70210446b0492ff on current main parent 91937a6dc3ffbbe2f3be91a500f0ecf962c4cf53.

Certification evidence:

  • patch-id unchanged from the independently certified predecessor; the two intervening upstream commits have zero path overlap with this PR;
  • local exact matrix: 1,020/1,020 tests passed across 93 files;
  • independent concurrency/SQLite/transcript review: PASS (219/219 tests, autonomous **kwargs adapter probe PASS);
  • independent lifecycle/memory/TurnContext review: PASS (271/271 tests, lifecycle probes 8/8 PASS);
  • canonical exported tree 940cdc3e874aadaa015e435a7307b454b999eb5f rehashed read-only after both reviews.

Please re-certify and review the exact head 9a97f2cb7917a500b8496e8ac70210446b0492ff.

@ruizanthony
ruizanthony force-pushed the fix/compression-chain-tip-recovery branch from 9a97f2c to 08f1720 Compare August 4, 2026 01:28
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Published exact candidate 08f17204adfd272e610f350ec5261435feb6f2f8 on current main parent 91937a6dc3ffbbe2f3be91a500f0ecf962c4cf53.

This candidate closes the three failures from CI run 30866922429 without changing product code:

  • two test SessionDB MagicMock fixtures now explicitly declare get_session instead of relying on dynamic attributes rejected by the recovery gate;
  • the idle-lock regression now asserts the certified fail-closed behavior (turn_start_persistence_failed), no compressor call, external holder preserved, and no message persisted.

Certification evidence:

  • product code byte-identical to 9a97f2cb7917a500b8496e8ac70210446b0492ff; delta is exactly three test files;
  • local exact matrix: 1,111/1,111 tests passed across 96 files;
  • two independent exact-SHA terminal reviews: PASS, P0=0 · P1=0 · P2=0;
  • canonical exported tree d0ceb64ccc5944642e7c47e0b147aaacb474d46d rehashed read-only after both reviews.

Please re-certify and review the exact head 08f17204adfd272e610f350ec5261435feb6f2f8.

@ruizanthony
ruizanthony force-pushed the fix/compression-chain-tip-recovery branch from 08f1720 to 1d1edf7 Compare August 20, 2026 22:53
@ruizanthony

Copy link
Copy Markdown
Contributor Author

Final gate remediation is now at 1d1edf7d2912c384dd8c1a2c1ad27065269401d5, rebuilt directly on current origin/main (ee000768cef4dc9399f32c88b507104ce15400dd).

What changed:

  • find_live_compression_child() now walks the complete compression chain with no hop cap inside one SQLite read snapshot.
  • Every direct child is loaded before canonical cardinality/lifecycle decisions. Only source=tool and branch/delegate decoys with exactly one correctly typed, direct-parent-bound marker are excluded.
  • Raw model_config is parsed in Python. Non-TEXT, malformed/non-object JSON, duplicate markers, wrong marker types/values, incoherent lifecycle rows, forks, wrong-parent markers, and cycles all fail closed.
  • A row is returned only when both ended_at and end_reason are NULL and it has zero canonical descendants in the same snapshot.
  • Gateway stale-writer recovery now uses that conservative resolver. A successful FTS retry returns when the queue is empty or advances to pending[0]; rerouted FTS retries target the child ID, never the archived parent.

Regression coverage includes depth 128, cycle, fork, closed sibling ambiguity, malformed/non-TEXT metadata, invalid/duplicate/wrong-parent decoy markers, lifecycle corruption, live intermediate, valid decoys/tool children, one-snapshot concurrency, FTS empty/backlogged queues, and rerouted child FTS retry.

Validation:

  • New regressions against clean origin/main: 13 state failures and 4 gateway failures (expected before fix).
  • Targeted: 109 passed.
  • Adjacent state/gateway/compression/rearm/tail suites: 525 passed, 2 skipped.
  • py_compile, Ruff check on all four touched files, and git diff --check: passed.
  • The broader state run had 541 passed, 2 skipped, 2 failed only in tests/state/test_fts_runtime_rebuild.py; both failures reproduced unchanged on a clean origin/main worktree (9 passed, 2 failed) with the same SQLite runtime.

No compression rearm/headroom, active-user-tail, compression.max_attempts, or compression runtime/config behavior was changed.

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

Labels

area/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants