fix(state): inherit and stamp profile_name across rotation and branch children

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 01:49:22 -05:00
parent 40a80487c4
commit e9a243ef78
6 changed files with 160 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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