From 75099ca0efbbec93b31d8814f2ae8341d6c6a7f2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:54:54 -0700 Subject: [PATCH] fix(state): inherit git_branch + gateway origin columns on compression children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on top of #64731's cwd/git_repo_root inheritance: - git_branch joins the parent-row backfill (same NULL-only COALESCE hop): the Desktop sidebar branch chip otherwise vanishes at every compaction boundary even though the workspace didn't change. - Belt-and-suspenders for #59527: compression forks (parent already ended with end_reason='compression') also inherit the gateway origin columns (user_id/session_key/chat_id/chat_type/thread_id/display_name/ origin_json) at DB-level child creation. The gateway re-records the peer after rotation (d5b4879d4), but a hard crash in the window between child creation and that write left the child unrecoverable by find_latest_gateway_session_for_peer. Scoped to compression forks only — delegate/subagent children (parent still live) must NOT inherit routing keys, or peer recovery could repoint gateway traffic into a subagent's session. - Behavioral test driving the real _compress_context rotation path, asserting the child row carries cwd/git_repo_root/git_branch and the origin columns. --- hermes_state.py | 68 +++++++++++++++--- .../agent/test_compression_rotation_state.py | 40 +++++++++++ tests/test_hermes_state.py | 70 +++++++++++++++++++ 3 files changed, 169 insertions(+), 9 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 2b8536e6e0b3..dc87a4fc9055 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2730,14 +2730,19 @@ class SessionDB: 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. + 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 + 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 + (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 + crash before the gateway re-records the peer can't strand the child + without a recoverable routing mapping (#59527). """ def _do(conn): conn.execute( @@ -2785,10 +2790,55 @@ class SessionDB: 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 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 id = ? AND parent_session_id IS NOT NULL""", (session_id,), ) + # Belt-and-suspenders for gateway routing metadata (#59527): + # the gateway re-records the peer on the child after rotation + # (d5b4879d4), but a hard crash between child creation and that + # write leaves the child row without origin columns, so + # ``find_latest_gateway_session_for_peer`` can't recover the + # mapping on restart. Inherit them from the parent at creation + # time — but ONLY for compression forks (parent already ended + # with end_reason='compression'). Delegate/subagent children + # are spawned while the parent is still live and must NOT + # inherit routing keys, or peer recovery could repoint gateway + # traffic into a subagent's session. + conn.execute( + """UPDATE sessions + SET user_id = COALESCE(sessions.user_id, + (SELECT p.user_id FROM sessions p + WHERE p.id = sessions.parent_session_id)), + session_key = COALESCE(sessions.session_key, + (SELECT p.session_key FROM sessions p + WHERE p.id = sessions.parent_session_id)), + chat_id = COALESCE(sessions.chat_id, + (SELECT p.chat_id FROM sessions p + WHERE p.id = sessions.parent_session_id)), + chat_type = COALESCE(sessions.chat_type, + (SELECT p.chat_type FROM sessions p + WHERE p.id = sessions.parent_session_id)), + thread_id = COALESCE(sessions.thread_id, + (SELECT p.thread_id FROM sessions p + WHERE p.id = sessions.parent_session_id)), + display_name = COALESCE(sessions.display_name, + (SELECT p.display_name FROM sessions p + WHERE p.id = sessions.parent_session_id)), + origin_json = COALESCE(sessions.origin_json, + (SELECT p.origin_json FROM sessions p + WHERE p.id = sessions.parent_session_id)) + WHERE id = ? AND parent_session_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM sessions p + WHERE p.id = sessions.parent_session_id + AND p.end_reason = 'compression' + )""", + (session_id,), + ) self._execute_write(_do) def create_session(self, session_id: str, source: str, **kwargs) -> str: diff --git a/tests/agent/test_compression_rotation_state.py b/tests/agent/test_compression_rotation_state.py index 039686b758de..7978d873c21a 100644 --- a/tests/agent/test_compression_rotation_state.py +++ b/tests/agent/test_compression_rotation_state.py @@ -143,6 +143,46 @@ class TestOrphanRollbackOnCreateFailure: _ = real_create # silence unused +class TestWorkspaceMetadataFollowsRotation: + def test_child_row_inherits_cwd_repo_and_origin_on_rotation(self, tmp_path: Path): + """Behavioral #64709/#59527: drive the REAL compression rotation path + and assert the child session row carries the parent's workspace and + gateway-origin metadata, so the project sidebar entry and the peer + routing mapping both survive the compaction boundary.""" + db = SessionDB(db_path=tmp_path / "state.db") + parent = "PARENT_CWD_ROT" + db.create_session( + parent, + source="telegram", + user_id="u1", + session_key="telegram:u1:c1", + chat_id="c1", + chat_type="private", + ) + db.update_session_cwd( + parent, "/work/repo", git_branch="main", git_repo_root="/work/repo" + ) + agent = _build_agent_with_db(db, parent, platform="telegram") + + agent._compress_context(_msgs(), "sys", approx_tokens=120_000) + child = agent.session_id + assert child != parent # rotation happened + + row = db.get_session(child) + assert row is not None + assert row["parent_session_id"] == parent + # Workspace metadata (#64709): sidebar grouping keys must survive. + assert row["cwd"] == "/work/repo" + assert row["git_repo_root"] == "/work/repo" + assert row["git_branch"] == "main" + # Gateway origin metadata (#59527): routing keys must survive even if + # the gateway never gets to re-record the peer (crash window). + assert row["session_key"] == "telegram:u1:c1" + assert row["chat_id"] == "c1" + assert row["chat_type"] == "private" + assert row["user_id"] == "u1" + + class TestPlatformForwardedAtBoundary: def test_on_session_start_receives_platform(self, tmp_path: Path): db = SessionDB(db_path=tmp_path / "state.db") diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index db38dd2ce114..38222176b973 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -180,6 +180,76 @@ class TestSessionLifecycle: assert db.get_session("gen1")["cwd"] == "/work/repo" assert db.get_session("gen2")["cwd"] == "/work/repo" + def test_child_session_inherits_git_branch_from_parent(self, db): + """git_branch travels with cwd/git_repo_root — the Desktop sidebar + shows the branch chip per session, so a compression child born + without it loses the chip even though the workspace didn't change.""" + db.create_session(session_id="parent", source="cli") + db.update_session_cwd( + "parent", "/work/repo", git_branch="feature-x", git_repo_root="/work/repo" + ) + + db.create_session(session_id="child", source="cli", parent_session_id="parent") + + child = db.get_session("child") + assert child["git_branch"] == "feature-x" + + 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 + the gateway re-records the peer can't strand it (#59527).""" + db.create_session( + session_id="parent", source="telegram", + user_id="u1", session_key="telegram:u1:c1", + chat_id="c1", chat_type="private", thread_id="t1", + ) + db.record_gateway_session_peer( + "parent", source="telegram", user_id="u1", + session_key="telegram:u1:c1", chat_id="c1", chat_type="private", + thread_id="t1", display_name="Chat One", origin_json='{"p":"telegram"}', + ) + # 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="telegram", parent_session_id="parent" + ) + + child = db.get_session("child") + assert child["user_id"] == "u1" + assert child["session_key"] == "telegram:u1:c1" + assert child["chat_id"] == "c1" + assert child["chat_type"] == "private" + assert child["thread_id"] == "t1" + assert child["display_name"] == "Chat One" + assert child["origin_json"] == '{"p":"telegram"}' + + def test_live_parent_child_does_not_inherit_gateway_origin(self, db): + """Delegate/subagent children (parent still live) must NOT inherit + routing keys — peer recovery could otherwise repoint gateway traffic + into a subagent's session.""" + db.create_session( + session_id="parent", source="telegram", + user_id="u1", session_key="telegram:u1:c1", + chat_id="c1", chat_type="private", + ) + + db.create_session( + session_id="sub", source="telegram", parent_session_id="parent" + ) + + sub = db.get_session("sub") + assert sub["session_key"] is None + assert sub["chat_id"] is None + assert sub["user_id"] is None + # Workspace metadata still inherits — that part is safe for any child. + db.update_session_cwd("parent", "/work/repo", git_repo_root="/work/repo") + db.create_session( + session_id="sub2", source="telegram", parent_session_id="parent" + ) + assert db.get_session("sub2")["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."""