refactor(file-tools): resolve paths against per-session cwd records (step 2)

Flips the read side of the cwd rearchitecture onto the _session_cwd
store introduced in the previous commit.

_authoritative_workspace_root now resolves:
  1. the session's own cwd record (get_session_cwd) — per-session by
     construction, so one session's cd can never leak into another
     session's file resolution, with no ownership heuristics at all
  2. registered override (fallback for cleared/never-written records)
  3. legacy shared-env live cwd + preserved anchor (transition-only,
     for commands that ran before this code loaded)
  4. sentinel-free absolute TERMINAL_CWD

delegate_task children get their record seeded from the parent's at
spawn: they keep starting in the parent's directory (current behavior)
but their subsequent cds stay isolated in their own record instead of
bleeding back through the shared env.

The wrong-worktree leak class is now solved structurally on this path —
there is no shared cwd for sessions to inherit. The legacy env-side
tracking (cwd_owner, _live_cwd_if_owned, _last_known_cwd) remains only
as a transition fallback and is deleted in the next step.
This commit is contained in:
ethernet 2026-07-15 16:53:12 -04:00 committed by Teknium
parent be2a1290de
commit 5461e0e098
3 changed files with 103 additions and 10 deletions

View file

@ -112,3 +112,65 @@ class TestPostCommandDualWrite:
result = self._run(monkeypatch, "sess-a", FakeEnv())
assert result["exit_code"] == 0
assert tt.get_session_cwd("sess-a") is None
class TestFileToolsReadTheRecord:
"""Step 2: file-tool path resolution prefers the session's own record."""
def test_two_sessions_resolve_into_their_own_recorded_cwds(self, tmp_path, monkeypatch):
import tools.file_tools as ft
wt_a = tmp_path / "wt_a"
wt_b = tmp_path / "wt_b"
for d in (wt_a, wt_b):
d.mkdir()
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(ft, "_file_ops_cache", {})
monkeypatch.setattr(ft, "_last_known_cwd", {})
monkeypatch.setattr(tt, "_active_environments", {})
# Each session ran commands that recorded its own cwd. No env alive,
# no ownership metadata, no registered overrides — just the records.
tt.record_session_cwd("sess-a", str(wt_a))
tt.record_session_cwd("sess-b", str(wt_b))
assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py")
assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (wt_b / "f.py")
def test_record_beats_foreign_env_cwd_without_ownership_metadata(self, tmp_path, monkeypatch):
"""The leak-A scenario, solved structurally: no cwd_owner consulted."""
import tools.file_tools as ft
wt_a = tmp_path / "wt_a"
wt_b = tmp_path / "wt_b"
for d in (wt_a, wt_b):
d.mkdir()
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(ft, "_file_ops_cache", {})
monkeypatch.setattr(ft, "_last_known_cwd", {})
class _Env:
cwd = str(wt_b)
cwd_owner = "" # unowned — the case the legacy guard let through
monkeypatch.setattr(tt, "_active_environments", {"default": _Env()})
tt.record_session_cwd("sess-a", str(wt_a))
resolved = ft._resolve_path_for_task("f.py", task_id="sess-a")
assert resolved == (wt_a / "f.py")
assert not str(resolved).startswith(str(wt_b))
class TestDelegateSeedsChildRecord:
def test_child_record_seeded_from_parent_then_isolated(self):
tt.record_session_cwd("parent-task", "/parent/worktree")
# what delegate_tool does at spawn:
tt.record_session_cwd("child-1", tt.get_session_cwd("parent-task"))
assert tt.get_session_cwd("child-1") == "/parent/worktree"
# child cds somewhere; parent record must be untouched.
tt.record_session_cwd("child-1", "/child/scratch")
assert tt.get_session_cwd("parent-task") == "/parent/worktree"
assert tt.get_session_cwd("child-1") == "/child/scratch"

View file

@ -1906,6 +1906,18 @@ def _run_single_child(
child_task_id = _subagent_id or f"subagent-{task_index}-{_uuid.uuid4().hex[:8]}"
parent_task_id = getattr(parent_agent, "_current_task_id", None)
# Seed the child's session-cwd record from the parent's (cwd rearch):
# children share the parent's container, and today they inherit the
# parent's live env.cwd implicitly. Seeding at spawn preserves that
# starting directory while keeping the child's subsequent `cd`s
# isolated in its own record (a child's cd no longer bleeds back into
# the parent once readers flip to the record store).
try:
from tools.terminal_tool import get_session_cwd, record_session_cwd
record_session_cwd(child_task_id, get_session_cwd(parent_task_id))
except Exception as e:
logger.debug("Child cwd seed failed: %s", e)
wall_start = time.time()
parent_reads_snapshot = (
list(file_state.known_reads(parent_task_id)) if parent_task_id else []

View file

@ -333,20 +333,36 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
def _authoritative_workspace_root(task_id: str = "default") -> str | None:
"""Best-effort absolute workspace root for divergence checks.
Prefers the live terminal cwd (the directory the agent is actually working
in). When no terminal command has run yet so the live registry is empty
falls back to a registered task/session cwd override (TUI/Desktop/ACP
sessions register a raw-keyed cwd before any tool runs), then to a
sentinel-free absolute ``$TERMINAL_CWD``. This is what lets a worktree or
Desktop session warn about (and resolve into) its workspace from the very
first ``write_file``/``patch``, before any ``cd`` has populated the live cwd.
Resolution (cwd rearch, step 2):
1. The session's own cwd RECORD (``terminal_tool.get_session_cwd``) —
written on every completed terminal command and seeded by workspace
registration, keyed by the raw session id. Because the record is
per-session, one session's ``cd`` can never leak into another
session's resolution — the property the legacy env-side tracking
(shared ``env.cwd`` + ownership stamping) could not guarantee.
2. A registered task/session cwd override (TUI/Desktop/ACP sessions
register a raw-keyed cwd before any tool runs). Normally already
mirrored into the record at registration; kept as a direct fallback
so a cleared/never-written record still resolves the workspace.
3. Legacy shared-env live cwd + preserved anchor (transition-only:
single-session flows whose commands ran before this code loaded).
4. A sentinel-free absolute ``$TERMINAL_CWD``.
Returns ``None`` only when there is genuinely no reliable anchor, in which
case callers fall back to the process cwd.
"""
live = _get_live_tracking_cwd(task_id)
if live:
return live
try:
from tools.terminal_tool import get_session_cwd
recorded = get_session_cwd(task_id)
except Exception:
recorded = None
if recorded:
# Keep the legacy mirror warm for the transition (readers of
# _last_known_cwd still exist until step 4 deletes them).
_get_live_tracking_cwd(task_id)
return recorded
# A session-specific registered override (TUI/Desktop/ACP workspace cwd)
# is more authoritative than the shared last-known anchor: it is keyed by
# the raw session id, so when two worktree sessions share the single
@ -356,6 +372,9 @@ def _authoritative_workspace_root(task_id: str = "default") -> str | None:
registered = _registered_task_cwd_override(task_id)
if registered:
return registered
live = _get_live_tracking_cwd(task_id)
if live:
return live
# When the terminal env was cleaned up mid-conversation, the live cwd is
# gone but the directory the agent navigated to is still recorded in the
# durable _last_known_cwd registry. Prefer it over the config/process