From 0158569ee78b07270a55c96eccc4ddd6f4047304 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 03:59:46 -0500 Subject: [PATCH] fix(gateway): follow a session into the worktree it settled in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/tui_gateway/test_session_cwd_follow.py | 160 +++++++++++++++++++ tui_gateway/server.py | 76 ++++++++- 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 tests/tui_gateway/test_session_cwd_follow.py diff --git a/tests/tui_gateway/test_session_cwd_follow.py b/tests/tui_gateway/test_session_cwd_follow.py new file mode 100644 index 000000000000..e5fa5a7c2fbd --- /dev/null +++ b/tests/tui_gateway/test_session_cwd_follow.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b14fb079a6a2..30d7db35551b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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;