Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions hermes_state_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,43 @@ def _heal_session_model_usage_pk(self, cursor: sqlite3.Cursor) -> None:
finally:
cursor.execute("PRAGMA foreign_keys=ON")

def _heal_polluted_gateway_delegate_markers(self, cursor: sqlite3.Cursor) -> None:
"""Strip ``_delegate_from`` from gateway main rows (``session_key`` set).

A main gateway session must never carry the delegate marker — the
marker excludes the row from every picker (``list_sessions_rich``,
``list_recent_sessions_bounded``) while the gateway keeps routing
into it by ``session_key``. The polluted state is the opposite of
#103789 (children leaking into the sidebar); here the main chat
vanishes while messages keep flowing (#109073). The pollution
survives via historic upsert/merge paths that copied a delegate
child's ``model_config`` onto the main row. Idempotent and safe to
run on every open (no version gate).
"""
try:
# Probe first: the UPDATE takes the write lock even when it
# matches no rows and would block every open behind a sibling's
# transaction.
if cursor.execute(
"SELECT 1 FROM sessions WHERE session_key IS NOT NULL AND session_key != '' "
"AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NOT NULL LIMIT 1"
).fetchone() is None:
return
cur = cursor.execute(
"UPDATE sessions SET model_config = "
"CASE WHEN json_remove(COALESCE(model_config, '{}'), '$._delegate_from') = '{}' "
"THEN NULL ELSE json_remove(COALESCE(model_config, '{}'), '$._delegate_from') END "
"WHERE session_key IS NOT NULL AND session_key != '' "
"AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NOT NULL"
)
if cur.rowcount:
logger.warning(
"Healed %d polluted gateway session(s) carrying _delegate_from (session_key set) (#109073)",
cur.rowcount,
)
except sqlite3.OperationalError as exc:
logger.debug("gateway delegate-marker heal skipped: %s", exc)

# ── _init_schema ───────────────────────────────────────────────────────

def _init_schema(self):
Expand Down Expand Up @@ -903,6 +940,15 @@ def _init_schema(self):
# already at v22+ when the column landed — the version-gated rebuild is unreachable there, #73823).
# Same PK-rebuild constraint as gateway_routing above.
self._heal_session_model_usage_pk(cursor)
# Heal polluted gateway sessions: a main gateway row (session_key set)
# must never carry _delegate_from — it hides the row from every picker
# while the gateway keeps routing into it (#109073). This is the
# opposite direction of #103789/PR#105284 (which stripped markers from
# children); here the marker leaked ONTO the main row via upsert/
# merge paths and via historic delegation upserts. Idempotent, runs on
# every open so already-versioned DBs are repaired without a version
# bump.
self._heal_polluted_gateway_delegate_markers(cursor)

# Indexes referencing reconciler-added columns must be created AFTER _reconcile_columns
# (in SCHEMA_SQL the executescript would fail on legacy DBs).
Expand Down
27 changes: 26 additions & 1 deletion hermes_state_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,17 @@ def _insert_session_row(
"""
if not (profile_name or "").strip():
profile_name = self._own_profile_name()
# Invariant: a gateway main session (session_key set) must never carry
# _delegate_from — that marker hides the row from every picker while
# the gateway keeps routing into it (#109073).
if model_config and session_key and "_delegate_from" in model_config:
model_config = {k: v for k, v in model_config.items() if k != "_delegate_from"} # type: ignore[assignment]
logger.warning(
"Stripped _delegate_from from gateway session %s (session_key=%r) at insert",
session_id, session_key,
)
if not model_config:
model_config = None # type: ignore[assignment]
def _do(conn):
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
conn.execute(
Expand Down Expand Up @@ -676,7 +687,7 @@ def _merge_model_config_json(
"""SELECT + tolerant-parse + merge ``patch`` into model_config (the one place that keeps
``_branched_from``/``_delegate_from`` alive); ``None`` deletes a key. Returns serialized JSON
(``None`` when empty) or ``_MODEL_CONFIG_ROW_MISSING`` (``on_missing="raise"`` → ValueError)."""
row = conn.execute("SELECT model_config FROM sessions WHERE id = ?", (session_id,)).fetchone()
row = conn.execute("SELECT model_config, session_key FROM sessions WHERE id = ?", (session_id,)).fetchone()
if row is None:
if on_missing == "raise":
raise ValueError(f"Session not found: {session_id}")
Expand All @@ -687,6 +698,20 @@ def _merge_model_config_json(
config.pop(key, None)
else:
config[key] = value
# Invariant: gateway rows (session_key set) must never carry _delegate_from
# (#109073) — a polluted marker makes the main chat vanish from every
# picker while the gateway keeps routing into it.
if "_delegate_from" in config:
try:
sk = row[1] if len(row) > 1 else None
except Exception:
sk = None
if sk:
config.pop("_delegate_from", None)
logger.warning(
"Stripped _delegate_from from gateway session %s (session_key=%r) at merge",
session_id, sk,
)
return json.dumps(config) if config else None

def patch_session_model_config(self, session_id: str, patch: Dict[str, Any]) -> None:
Expand Down