fix(gateway): follow a session into the worktree it settled in

An agent told to work in a fresh git worktree does exactly that — creates
it, cds in, and runs every later command there — but the session stayed
pinned to the checkout it started in. The desktop kept labelling the chat
with the primary branch while all the work landed somewhere else.

The desktop half already existed: session.info carrying a moved cwd runs
followActiveSessionCwd, which refreshes the project tree and scopes the
sidebar into the new project. The backend just never reported the move.

Reconcile the session's cwd against terminal_tool's per-session record at
the end of a turn, when the agent has stopped moving and its recorded cwd
is a stable answer. A plain cd stays what it always was — not a workspace
move — so the reconcile only fires when the recorded cwd sits in a
different git working tree than the session's workspace.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 03:59:46 -05:00
parent d9f1043c33
commit 0158569ee7
2 changed files with 235 additions and 1 deletions

View file

@ -0,0 +1,160 @@
"""A session that settles into another git worktree re-anchors onto it.
The desktop already follows a session that moves (``followActiveSessionCwd``
refreshes the project tree and scopes into the new project), but it only ever
sees a move when the backend reports one on ``session.info``. These tests
exercise the backend half against real git worktrees on disk.
"""
from __future__ import annotations
import os
import subprocess
import pytest
import tools.terminal_tool as terminal_tool
import tui_gateway.server as server
def _git(cwd, *args):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
@pytest.fixture
def repo_with_worktree(tmp_path):
"""A real repo on ``main`` plus a linked worktree on ``feature``."""
repo = tmp_path / "proj"
repo.mkdir()
_git(repo, "init", "-b", "main")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
(repo / "README.md").write_text("hi\n")
_git(repo, "add", ".")
_git(repo, "commit", "-m", "init")
worktree = tmp_path / "proj-feature"
_git(repo, "worktree", "add", "-b", "feature", str(worktree))
from tui_gateway import git_probe
git_probe.invalidate()
yield repo, worktree
git_probe.invalidate()
@pytest.fixture
def session(repo_with_worktree):
repo, _ = repo_with_worktree
key = "sess-follow"
terminal_tool.clear_session_cwd(key)
yield {"session_key": key, "cwd": str(repo), "source": "desktop"}
terminal_tool.clear_session_cwd(key)
@pytest.fixture(autouse=True)
def _no_db(monkeypatch):
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_persist_session_git_meta", lambda *_a: None)
monkeypatch.setattr(server, "_register_session_cwd", lambda _s: None)
monkeypatch.setattr(server, "_is_local_terminal_backend", lambda: True)
def test_settling_in_a_worktree_reanchors_the_session(session, repo_with_worktree):
"""The whole reported bug: work goes to the worktree, the session says main."""
_, worktree = repo_with_worktree
terminal_tool.record_session_cwd(session["session_key"], str(worktree))
assert server._reconcile_session_cwd_from_terminal(session) is True
assert session["cwd"] == str(worktree)
# The move is the session's workspace now, so it earns a persisted row.
assert session["explicit_cwd"] is True
def test_a_subdirectory_of_the_same_checkout_is_not_a_move(session, repo_with_worktree):
repo, _ = repo_with_worktree
sub = repo / "src"
sub.mkdir()
terminal_tool.record_session_cwd(session["session_key"], str(sub))
assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)
def test_browsing_outside_a_repo_is_not_a_move(session, repo_with_worktree, tmp_path):
"""`cd /tmp` to read a log must not re-home the workspace."""
repo, _ = repo_with_worktree
scratch = tmp_path / "scratch"
scratch.mkdir()
terminal_tool.record_session_cwd(session["session_key"], str(scratch))
assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)
def test_a_deleted_directory_is_not_a_move(session, repo_with_worktree, tmp_path):
repo, _ = repo_with_worktree
terminal_tool.record_session_cwd(session["session_key"], str(tmp_path / "gone"))
assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)
def test_remote_backends_do_not_reanchor(session, repo_with_worktree, monkeypatch):
"""A remote cwd names a path on the host, not one this gateway can probe."""
repo, worktree = repo_with_worktree
monkeypatch.setattr(server, "_is_local_terminal_backend", lambda: False)
terminal_tool.record_session_cwd(session["session_key"], str(worktree))
assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)
def test_settled_session_info_reports_the_worktree_branch(
session, repo_with_worktree, monkeypatch
):
"""End of turn: the emitted session.info is what the desktop follows."""
_, worktree = repo_with_worktree
emitted: list[tuple[str, str, dict]] = []
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, sid, payload or {})))
terminal_tool.record_session_cwd(session["session_key"], str(worktree))
server._emit_settled_session_info("sid-1", session, agent=None)
assert len(emitted) == 1
event, sid, payload = emitted[0]
assert (event, sid) == ("session.info", "sid-1")
assert payload["cwd"] == str(worktree)
assert payload["branch"] == "feature"
def test_settled_session_info_still_emits_when_nothing_moved(
session, repo_with_worktree, monkeypatch
):
repo, _ = repo_with_worktree
emitted: list[dict] = []
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append(payload or {}))
server._emit_settled_session_info("sid-1", session, agent=None)
assert len(emitted) == 1
assert emitted[0]["cwd"] == str(repo)
assert emitted[0]["branch"] == "main"
def test_reconcile_ignores_a_foreign_sessions_record(session, repo_with_worktree):
"""cwd records are per session key — another chat's move must not leak in."""
repo, worktree = repo_with_worktree
terminal_tool.record_session_cwd("someone-else", str(worktree))
assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)
terminal_tool.clear_session_cwd("someone-else")
def test_os_normalized_paths_are_not_a_move(session, repo_with_worktree):
"""A trailing-slash / unnormalized record is the same dir, not a relocation."""
repo, _ = repo_with_worktree
terminal_tool.record_session_cwd(session["session_key"], str(repo) + os.sep)
assert server._reconcile_session_cwd_from_terminal(session) is False

View file

@ -2177,6 +2177,80 @@ def _display_session_cwd(session: dict | None) -> str:
return healed
def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool:
"""Re-anchor a session that SETTLED in another git checkout. Returns moved.
An agent told to work in a fresh worktree does exactly that `git worktree
add`, `cd` into it, and every later command runs there but the session
stayed pinned to wherever it started, so the desktop kept labelling the chat
with the primary checkout's branch while all the work landed elsewhere.
A plain `cd` is deliberately NOT a workspace move (see
``_apply_project_workspace``): browsing to /tmp to read a log must not
re-home the chat. What we adopt here is narrower the session's recorded
cwd is in a DIFFERENT git working tree than its workspace. That is a
relocation by any reading, and it is the only shape this reconciles.
Local backends only: a remote/SSH cwd names a path on the host, which this
gateway can neither stat nor probe with git.
"""
if not session or not _is_local_terminal_backend():
return False
try:
from tools.terminal_tool import get_session_cwd
recorded = get_session_cwd(session.get("session_key") or "")
except Exception:
return False
if not recorded:
return False
resolved = os.path.abspath(os.path.expanduser(str(recorded)))
current = os.path.abspath(os.path.expanduser(_session_cwd(session)))
if resolved == current or not os.path.isdir(resolved):
return False
# The worktree ROOT, not the common repo root: folding worktrees together
# here is exactly what hides the move we're looking for.
landed = _git_repo_root_for_cwd(resolved)
if not landed or landed == _git_repo_root_for_cwd(current):
return False
session["cwd"] = resolved
# The session works here now, so this is its workspace — a desktop chat
# whose cwd was an unpersisted launch artifact earns a real row.
session["explicit_cwd"] = True
_register_session_cwd(session)
with _session_db(session) as db:
if db is not None:
try:
db.update_session_cwd(session.get("session_key", ""), resolved)
except Exception:
logger.debug("failed to persist settled session cwd", exc_info=True)
_persist_session_git_meta(session, resolved)
return True
def _emit_settled_session_info(sid: str, session: dict, agent) -> None:
"""Emit end-of-turn ``session.info``, reconciling a settled cwd first.
The turn is over, so the agent has stopped moving: this is the one moment
where its recorded cwd is a stable answer to "where does this session
work". Reconciling before building the payload means the same event that
already tells the desktop the turn ended also carries the new cwd/branch
the client follows it with no new event type and no extra round trip.
"""
try:
_reconcile_session_cwd_from_terminal(session)
except Exception:
logger.debug("failed to reconcile settled session cwd", exc_info=True)
_emit("session.info", sid, _session_info(agent, session))
def _session_source(session: dict | None) -> str:
if session:
source = str(session.get("source") or "").strip()
@ -11860,7 +11934,7 @@ def _run_prompt_submit(
# frame paths retire the marker as they emit).
_retire_turn_marker(session, marker_key)
session.pop("_auto_continue_scheduled", None)
_emit("session.info", sid, _session_info(agent, session))
_emit_settled_session_info(sid, session, agent)
# A user prompt that arrived mid-turn (interrupt + queue) wins over
# every auto follow-up below — drain it first and skip them this cycle;