mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(state): inherit git_branch + gateway origin columns on compression children
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.
This commit is contained in:
parent
3c74c12554
commit
75099ca0ef
3 changed files with 169 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue