diff --git a/hermes_state.py b/hermes_state.py index ce0292708a16..2b8536e6e0b3 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2709,6 +2709,7 @@ class SessionDB: parent_session_id: str = None, cwd: str = None, profile_name: str = None, + git_repo_root: str = None, ) -> None: """Insert a session row, enriching NULL metadata on conflict. @@ -2727,14 +2728,25 @@ class SessionDB: a persisted, now-inactive row belongs to the caller's chat/thread before switching to it (IDOR scoping — without them the ``sessions`` table has no chat/thread to compare). + + When ``parent_session_id`` is set (compression fork, delegate/subagent + spawn, branch continuation) and this row's own ``cwd``/``git_repo_root`` + 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. """ def _do(conn): conn.execute( """INSERT INTO sessions ( id, source, user_id, session_key, chat_id, chat_type, thread_id, - model, model_config, system_prompt, parent_session_id, cwd, profile_name, started_at + model, model_config, system_prompt, parent_session_id, cwd, + profile_name, git_repo_root, started_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET model = COALESCE(sessions.model, excluded.model), model_config = COALESCE(sessions.model_config, excluded.model_config), @@ -2745,7 +2757,8 @@ class SessionDB: thread_id = COALESCE(sessions.thread_id, excluded.thread_id), parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id), cwd = COALESCE(sessions.cwd, excluded.cwd), - profile_name = COALESCE(sessions.profile_name, excluded.profile_name)""", + profile_name = COALESCE(sessions.profile_name, excluded.profile_name), + git_repo_root = COALESCE(sessions.git_repo_root, excluded.git_repo_root)""", ( session_id, source, @@ -2760,9 +2773,22 @@ class SessionDB: parent_session_id, cwd, profile_name, + git_repo_root, time.time(), ), ) + if parent_session_id: + conn.execute( + """UPDATE sessions + SET cwd = COALESCE(sessions.cwd, + (SELECT p.cwd FROM sessions p + WHERE p.id = sessions.parent_session_id)), + git_repo_root = COALESCE(sessions.git_repo_root, + (SELECT p.git_repo_root FROM sessions p + WHERE p.id = sessions.parent_session_id)) + WHERE id = ? AND parent_session_id IS NOT NULL""", + (session_id,), + ) self._execute_write(_do) def create_session(self, session_id: str, source: str, **kwargs) -> str: diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 1e7efa0115e5..db38dd2ce114 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -139,6 +139,47 @@ class TestSessionLifecycle: assert session["cwd"] == "/work/repo" assert session["git_branch"] == "pets-feature" + def test_child_session_inherits_cwd_and_git_repo_root_from_parent(self, db): + """A parent_session_id child born without cwd/git_repo_root (e.g. the + compression-fork path) must inherit both from its parent, so it + doesn't silently drop out of the project sidebar (#64709).""" + db.create_session(session_id="parent", source="cli") + db.update_session_cwd("parent", "/work/repo", git_repo_root="/work/repo") + + db.create_session(session_id="child", source="cli", parent_session_id="parent") + + child = db.get_session("child") + assert child["cwd"] == "/work/repo" + assert child["git_repo_root"] == "/work/repo" + + def test_child_session_explicit_cwd_is_not_overwritten_by_parent(self, db): + """A child that explicitly sets its own cwd/git_repo_root keeps it — + parent inheritance only fills in NULLs, never clobbers.""" + db.create_session(session_id="parent", source="cli") + db.update_session_cwd("parent", "/work/repo-a", git_repo_root="/work/repo-a") + + db.create_session( + session_id="child", source="cli", parent_session_id="parent", + cwd="/work/repo-b", git_repo_root="/work/repo-b", + ) + + child = db.get_session("child") + assert child["cwd"] == "/work/repo-b" + assert child["git_repo_root"] == "/work/repo-b" + + def test_multi_generation_lineage_inherits_cwd(self, db): + """cwd/git_repo_root propagate through a multi-hop compression chain + (root -> gen1 -> gen2), mirroring the multi-generation lineage from + the reported issue where a single conversation forked repeatedly.""" + db.create_session(session_id="root", source="cli") + db.update_session_cwd("root", "/work/repo", git_repo_root="/work/repo") + + 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")["cwd"] == "/work/repo" + assert db.get_session("gen2")["cwd"] == "/work/repo" + def test_update_session_cwd_empty_branch_does_not_clobber(self, db): """A failed branch probe (empty string) must not wipe a branch we already captured — only the cwd updates."""