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:
Teknium 2026-07-22 05:54:54 -07:00
parent 3c74c12554
commit 75099ca0ef
3 changed files with 169 additions and 9 deletions

View file

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

View file

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

View file

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