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
53 changes: 53 additions & 0 deletions hermes_state_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,50 @@ 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).
"""
safe_mc = (
"CASE WHEN json_valid(COALESCE(model_config, '{}')) "
"THEN COALESCE(model_config, '{}') ELSE json_object() END"
)
delegate_present = (
f"{_sql_json_extract('model_config', '$._delegate_from')} IS NOT NULL"
)
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 != '' "
f"AND {delegate_present} LIMIT 1"
).fetchone() is None:
return
cur = cursor.execute(
"UPDATE sessions SET model_config = "
f"CASE WHEN json_remove({safe_mc}, '$._delegate_from') = '{{}}' "
f"THEN NULL ELSE json_remove({safe_mc}, '$._delegate_from') END "
"WHERE session_key IS NOT NULL AND session_key != '' "
f"AND {delegate_present}"
)
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 @@ -937,6 +981,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
54 changes: 49 additions & 5 deletions hermes_state_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ def _parse_model_config(raw: Any) -> Dict[str, Any]:
return dict(raw) if isinstance(raw, dict) else {}


def _gateway_main_session_key(session_key: Optional[str]) -> bool:
return session_key is not None and session_key != ""


def _strip_delegate_from_gateway_config(
config: Optional[Dict[str, Any]],
session_key: Optional[str],
*,
session_id: str,
context: str,
) -> Optional[Dict[str, Any]]:
"""Remove ``_delegate_from`` from gateway main rows (``session_key`` set, #109073)."""
if not config or not _gateway_main_session_key(session_key) or "_delegate_from" not in config:
return config if config else None
stripped = {k: v for k, v in config.items() if k != "_delegate_from"}
logger.warning(
"Stripped _delegate_from from gateway session %s (session_key=%r) at %s",
session_id,
session_key,
context,
)
return stripped if stripped else None


def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
# ``_``/``%`` are LIKE wildcards but ordinary path characters: unescaped, a
Expand Down Expand Up @@ -333,6 +357,9 @@ def _insert_session_row(
"""
if not (profile_name or "").strip():
profile_name = self._own_profile_name()
model_config = _strip_delegate_from_gateway_config( # type: ignore[assignment]
model_config, session_key, session_id=session_id, context="insert",
)
def _do(conn):
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
conn.execute(
Expand Down Expand Up @@ -646,10 +673,24 @@ def update_session_meta(
) -> None:
"""Update model_config and (COALESCE) optionally model."""
self.flush_token_counts() # barrier against queued token deltas — see update_session_model
self._write_sql(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(model_config_json, model, session_id),
)
def _do(conn):
row = conn.execute(
"SELECT session_key FROM sessions WHERE id = ?", (session_id,),
).fetchone()
if row is None:
return
config = _strip_delegate_from_gateway_config(
_parse_model_config(model_config_json),
row["session_key"],
session_id=session_id,
context="replace",
)
stored = json.dumps(config) if config else None
conn.execute(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(stored, model, session_id),
)
self._execute_write(_do)

def update_system_prompt(self, session_id: str, system_prompt: Optional[str]) -> None:
"""Store the full assembled system prompt snapshot."""
Expand Down Expand Up @@ -725,7 +766,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 @@ -736,6 +777,9 @@ def _merge_model_config_json(
config.pop(key, None)
else:
config[key] = value
config = _strip_delegate_from_gateway_config(
config, row["session_key"], session_id=session_id, context="merge",
)
return json.dumps(config) if config else None

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