From e9a243ef785b93b675639f51a72fe03aaa7934aa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 01:49:22 -0500 Subject: [PATCH] fix(state): inherit and stamp profile_name across rotation and branch children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile_name was only written on the agent's initial lazy create (e8b7ce8c1); every parented child row — compression rotation, TUI /branch, desktop branch first-persist — was created without it. A non-default profile's lineage therefore turned NULL on its first compression or branch and aggregated as "default" in unified session lists, completing the cross-profile session-jump. Fix the class at the DB layer: _insert_session_row's parent backfill now COALESCEs profile_name from the parent alongside cwd/git_* (#64709 pattern), so any parented child inherits its lineage's owning profile. Stamp it explicitly at the three create sites as well — compression rotation (mirroring _ensure_db_session), TUI session.branch, and the TUI first-prompt row persist — so rows are self-describing even when the parent row predates the profile_name column. --- agent/conversation_compression.py | 14 ++++++ hermes_state.py | 17 ++++--- .../run_agent/test_compression_persistence.py | 46 +++++++++++++++++++ tests/test_hermes_state.py | 38 +++++++++++++++ tests/test_tui_gateway_server.py | 44 ++++++++++++++++-- tui_gateway/server.py | 12 +++++ 6 files changed, 160 insertions(+), 11 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 59f95be9f291..88a5f4d7ab89 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1908,6 +1908,19 @@ def compress_context( except Exception: pass agent._session_db_created = False + # The rotation child must stay on the parent's profile — + # mirror _ensure_db_session's stamp ("default" persists as + # NULL). _insert_session_row's parent backfill additionally + # COALESCEs from the parent row, covering app-global remote + # sessions whose thread lacks the HERMES_HOME context. + try: + from hermes_cli.profiles import get_active_profile_name + + _profile_for_child = get_active_profile_name() + if _profile_for_child == "default": + _profile_for_child = None + except Exception: + _profile_for_child = None try: agent._session_db.create_session( session_id=agent.session_id, @@ -1915,6 +1928,7 @@ def compress_context( model=agent.model, model_config=agent._session_init_model_config, parent_session_id=old_session_id, + profile_name=_profile_for_child, ) except Exception as _cs_err: # The child row could not be created (e.g. FK constraint, diff --git a/hermes_state.py b/hermes_state.py index 3393e26c656b..4158a911ea2f 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3387,13 +3387,15 @@ class SessionDB: When ``parent_session_id`` is set (compression fork, delegate/subagent spawn, branch continuation) and this row's own ``cwd``/``git_repo_root``/ - ``git_branch`` are still NULL after the insert, they are backfilled from - the parent row. Callers of ``create_session`` for a child session - historically didn't propagate these fields themselves (e.g. the + ``git_branch``/``profile_name`` are still NULL after the insert, they are + backfilled from the parent row. Callers of ``create_session`` for a child + session historically didn't propagate these fields themselves (e.g. the compression-fork path), so a lineage could silently lose its working directory and drop out of the project sidebar every time it forked - (#64709). This only fills NULLs — an explicit ``cwd``/``git_repo_root`` - on the child is never overwritten. For compression forks specifically + (#64709), or lose its owning profile and be aggregated as "default" every + time it rotated or branched (the cross-profile session-jump bug). This + only fills NULLs — an explicit value on the child is never overwritten. + For compression forks specifically (parent ended with ``end_reason='compression'``), the gateway origin columns (``user_id``/``session_key``/``chat_id``/``chat_type``/ ``thread_id``/``display_name``/``origin_json``) are inherited too, so a @@ -3449,7 +3451,10 @@ class SessionDB: WHERE p.id = sessions.parent_session_id)), git_branch = COALESCE(sessions.git_branch, (SELECT p.git_branch FROM sessions p - WHERE p.id = sessions.parent_session_id)) + WHERE p.id = sessions.parent_session_id)), + profile_name = COALESCE(sessions.profile_name, + (SELECT p.profile_name FROM sessions p + WHERE p.id = sessions.parent_session_id)) WHERE id = ? AND parent_session_id IS NOT NULL""", (session_id,), ) diff --git a/tests/run_agent/test_compression_persistence.py b/tests/run_agent/test_compression_persistence.py index 20f8a442e2b9..9f0f6e70fa5f 100644 --- a/tests/run_agent/test_compression_persistence.py +++ b/tests/run_agent/test_compression_persistence.py @@ -322,6 +322,52 @@ class TestFlushAfterCompression: ) db.close() + def test_rotation_child_session_inherits_parent_profile_name(self): + """The rotation child must stay on the parent's owning profile. + + The rotate path used to create the child row with no profile_name, so + a compressed non-default-profile conversation migrated to the launch/ + default profile in unified session lists (the cross-profile + session-jump bug). Exercises the real rotation against a real + SessionDB: explicit stamp at the create site + the parent-backfill + COALESCE in _insert_session_row. + """ + from agent.conversation_compression import compress_context + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "test.db") + parent_sid = "20260701_152840_parent" + db.create_session( + parent_sid, "gateway", model="test/model", + profile_name="ai-engineer", + ) + + agent = self._make_agent(db) + agent.session_id = parent_sid + agent.compression_in_place = False + agent._ensure_db_session() + + messages = [ + { + "role": "user" if i % 2 == 0 else "assistant", + "content": f"message {i}", + "_db_persisted": True, + } + for i in range(12) + ] + + with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")): + compress_context( + agent, messages, approx_tokens=100_000, system_message="sys" + ) + + assert agent.session_id != parent_sid + child = db.get_session(agent.session_id) + assert child is not None + assert child["profile_name"] == "ai-engineer" + db.close() + # --------------------------------------------------------------------------- # Part 2: Gateway-side — history_offset after session split diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index d66d072e0e47..947973d3b6ee 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -194,6 +194,44 @@ class TestSessionLifecycle: child = db.get_session("child") assert child["git_branch"] == "feature-x" + def test_child_session_inherits_profile_name_from_parent(self, db): + """A parented child born without profile_name (compression rotation, + /branch) must inherit its parent's owning profile — otherwise the + lineage silently migrates to the launch/default profile in unified + session lists (the cross-profile session-jump bug).""" + db.create_session(session_id="parent", source="cli", profile_name="ai-engineer") + # Rotation path: parent is ended with 'compression' BEFORE the child + # row is created (agent/conversation_compression.py). + db.end_session("parent", "compression") + + db.create_session(session_id="child", source="cli", parent_session_id="parent") + + assert db.get_session("child")["profile_name"] == "ai-engineer" + + def test_child_session_explicit_profile_name_is_not_overwritten(self, db): + """Inheritance only fills NULLs — an explicit profile_name on the + child is never clobbered by the parent's.""" + db.create_session(session_id="parent", source="cli", profile_name="ai-engineer") + + db.create_session( + session_id="child", source="cli", parent_session_id="parent", + profile_name="other", + ) + + assert db.get_session("child")["profile_name"] == "other" + + def test_multi_generation_lineage_inherits_profile_name(self, db): + """profile_name survives a compress-then-branch chain (root -> rotation + child -> branch tip) — the exact lineage that used to land on default.""" + db.create_session(session_id="root", source="cli", profile_name="ai-engineer") + db.end_session("root", "compression") + + db.create_session(session_id="gen1", source="cli", parent_session_id="root") + db.create_session(session_id="gen2", source="cli", parent_session_id="gen1") + + assert db.get_session("gen1")["profile_name"] == "ai-engineer" + assert db.get_session("gen2")["profile_name"] == "ai-engineer" + def test_compression_child_inherits_gateway_origin_columns(self, db): """A compression fork's child inherits gateway routing metadata (session_key/chat_id/...) from the ended parent, so a crash before diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4e75e0543d8b..ca03dceb3958 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3893,7 +3893,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3914,7 +3914,7 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3935,7 +3935,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3964,7 +3964,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3996,7 +3996,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append({"model": model, "model_config": model_config}) monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) @@ -4007,6 +4007,36 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch): assert created == [{"model": "global/default", "model_config": None}] +def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path): + """A profile session's row carries its owning profile_name, so unified + multi-profile aggregation never has to guess from which state.db file the + row happened to be read (the cross-profile session-jump bug).""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + created = [] + + class _ProfileDB: + def __init__(self, db_path=None): + created.append({"db_path": db_path}) + + def create_session(self, key, **kwargs): + created[-1].update({"key": key, "profile_name": kwargs.get("profile_name")}) + + def close(self): + pass + + monkeypatch.setattr("hermes_state.SessionDB", _ProfileDB) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + server._ensure_session_db_row( + {"session_key": "k1", "profile_home": str(profile_home)} + ) + + assert created and created[0]["key"] == "k1" + assert created[0]["profile_name"] == "mlperf" + assert created[0]["db_path"] == profile_home / "state.db" + + def test_session_title_clears_pending_after_persist(monkeypatch): class _FakeDB: def __init__(self): @@ -9081,6 +9111,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): def create_session(self, new_key, **kwargs): seen["created"] = new_key seen["parent"] = kwargs.get("parent_session_id") + seen["profile_name"] = kwargs.get("profile_name") def append_message(self, **kwargs): seen["msgs"].append(kwargs) @@ -9142,6 +9173,9 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): assert "result" in resp, resp assert seen.get("created") assert seen.get("parent") == "parent-key" + # The branch row is self-describing: stamped with the parent's owning + # profile, not left NULL for aggregators to mis-tag as "default". + assert seen.get("profile_name") == "mlperf" assert seen.get("title") == (seen["created"], "forked") assert len(seen["msgs"]) == 1 assert seen.get("launch") is None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b7f757ab4485..6776606d5e60 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2144,6 +2144,10 @@ def _ensure_session_db_row(session: dict) -> None: model_config=model_config or None, parent_session_id=parent_session_id, cwd=_session_cwd(session) if session.get("explicit_cwd") else None, + # Self-describing rows: aggregators that merge multiple profile DBs + # into one list can't rely on which file a row came from alone. NULL + # means the launch/default profile (matches run_agent's convention). + profile_name=Path(profile_home).name if profile_home else None, ) except Exception: logger.debug("failed to persist desktop session row", exc_info=True) @@ -9706,6 +9710,14 @@ def _(rid, params: dict) -> dict: model_config={"_branched_from": old_key}, parent_session_id=old_key, cwd=_session_cwd(session), + # The branch stays on its parent's profile. Explicit stamp (not + # just the parent-backfill) so it holds even when the parent row + # predates the profile_name column. + profile_name=( + Path(session["profile_home"]).name + if session.get("profile_home") + else None + ), ) for msg in history: db.append_message(