From 1088868c804259605ca59bafc1d34ed0ab0b2854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B5=E6=B0=B5?= <982026408@qq.com> Date: Sun, 27 Sep 2026 12:34:17 +0800 Subject: [PATCH 01/10] fix(agent): normalize the workspace pin key to the resolved path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _workspace_pin_key() compared the probe directory as a raw string, but the two builds it is meant to unify spell the same directory differently: a launch-dir build keys on os.getcwd() — which reports the physical path (/private/var/... on macOS) — while a build that binds the cwd explicitly (TUI /compress, gateway session cwd) keys on the session's spelling of the same directory (/var/... through the symlink). The spelling mismatch misses the pin, so the rebuild re-probes git live and rewrites the session-start snapshot mid-session, changing the system prompt bytes and invalidating the cached prompt prefix for a workspace that never changed. On Linux /tmp is a real directory, so the suite stayed green there and the class hid. Normalize the pin key (and _seed_workspace_pin's persisted-cwd comparison) with Path.resolve(), the same normalization the persisted-block matcher already applies. On symlink-free paths resolve() is the identity, so Linux behavior is unchanged. This also turns the three macOS failures in TestWorkspaceSnapshotPinnedAcrossCompaction green: the two '- Root:' assertions now expect the git-resolved root (git reports toplevels resolved), and the replay/binding tests pass through the normalized pin. Red on macOS main, green with the fix; unchanged on Linux. --- agent/system_prompt.py | 19 +++++++++++++++---- tests/agent/test_compaction_prompt_rebuild.py | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 853462571cc2..b89417f5eac9 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -595,9 +595,16 @@ def _alibaba_identity_part(agent: Any) -> List[str]: def _workspace_pin_key() -> str: """The directory the workspace probe inspects, which is also the prompt's ``Current working directory``: a build with no cwd bound (launch dir) and a later one binding that same dir - (TUI ``/compress``) are one workspace, not two.""" + (TUI ``/compress``) are one workspace, not two. + + Normalized to the resolved real path: a launch-dir key comes from ``os.getcwd()`` (which + reports the physical path — ``/private/var/...`` on macOS) while a bound cwd arrives as the + session spelled it (``/var/...`` through the symlink). Comparing those spellings misses the + pin, the rebuild re-probes git live, and the session-start snapshot is rewritten mid-session + — invalidating the cached prompt prefix for a workspace that never changed.""" try: - return str(resolve_context_cwd() or resolve_agent_cwd()) + raw = str(resolve_context_cwd() or resolve_agent_cwd()) + return str(Path(raw).resolve()) if raw else "" except OSError: # deleted cwd return "" @@ -647,8 +654,12 @@ def _seed_workspace_pin(agent: Any, key: str) -> None: if not prompt: return stored_cwd = runtime_host_value(prompt, "Current working directory") - if stored_cwd and stored_cwd != key: - return + if stored_cwd: + # Same spelling normalization as _workspace_pin_key: the persisted hints carry the + # cwd as that surface spelled it, which can be a symlink spelling of the same + # resolved workspace. + if not key or Path(stored_cwd).resolve() != Path(key).resolve(): + return block = _persisted_workspace_block(prompt, key) # Only a real snapshot is adopted: a prompt without one (built on a surface without the # coding posture, or with tools off) leaves the pin open so this build captures one. diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index c6f4545d5ef7..addc1a613a8e 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -279,7 +279,7 @@ def env(cwd): with patch("agent.prompt_builder.load_soul_md", return_value=""), env(other), \ patch("agent.system_prompt.resolve_context_cwd", return_value=other): moved = self._pin_agent(_cached_system_prompt=None, _session_db=db) - self.assertIn(f"- Root: {other}", build_system_prompt(moved)) + self.assertIn(f"- Root: {other.resolve()}", build_system_prompt(moved)) finally: shutil.rmtree(tmp, ignore_errors=True) @@ -300,7 +300,7 @@ def test_persisted_prompt_without_a_snapshot_does_not_pin_an_empty_one(self): return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {repo}"), \ patch("agent.system_prompt.resolve_context_cwd", return_value=repo): resumed = self._pin_agent(_cached_system_prompt=None, _session_db=db) - self.assertIn(f"- Root: {repo}", build_system_prompt(resumed)) + self.assertIn(f"- Root: {repo.resolve()}", build_system_prompt(resumed)) finally: shutil.rmtree(tmp, ignore_errors=True) From e1b2ed35dbb038392d11b9010f874f3a57b395c6 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:45:02 -0700 Subject: [PATCH 02/10] test(agent): pin the symlink-spelling workspace case on any POSIX host The three red-on-macOS tests only fire where the temp dir sits behind a symlink (/var -> /private/var), so Linux CI never saw the spelling class and cannot catch a regression of the resolved-path pin key. Build the same shape explicitly: chdir into the physical repo for the launch-dir build, then bind the cwd through a symlink for the rebuild; the pin must replay. Red with main's system_prompt.py (the rebuild re-probes and reports "Status: 1 untracked"), green with the normalized key. Skipped on Windows, where directory symlinks need privileges. --- tests/agent/test_compaction_prompt_rebuild.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index addc1a613a8e..389de8130a78 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -252,6 +252,36 @@ def test_binding_the_launch_dir_explicitly_replays_the_pin(self): os.chdir(old_cwd) shutil.rmtree(tmp, ignore_errors=True) + def test_symlink_spelling_of_the_launch_dir_replays_the_pin(self): + """The macOS shape on any POSIX host: os.getcwd() reports the physical dir while the bound + cwd arrives through a symlink (/var -> /private/var). Same workspace, so the pin must hit.""" + import os, sys, tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt, invalidate_system_prompt + + if sys.platform == "win32": + self.skipTest("directory symlinks need privileges on Windows") + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-symlink-")) + old_cwd = os.getcwd() + try: + repo = _init_repo(tmp / "proj", "init commit") + link = tmp / "link" + link.symlink_to(repo, target_is_directory=True) + os.chdir(repo) # os.getcwd() is the physical spelling + agent = self._pin_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"): + with patch("agent.system_prompt.resolve_context_cwd", return_value=None): + p1 = build_system_prompt(agent) + self.assertIn("Status: clean", p1) + (repo / "untracked.txt").write_text("wip\n") + invalidate_system_prompt(agent) + with patch("agent.system_prompt.resolve_context_cwd", return_value=link): + self.assertEqual(build_system_prompt(agent), p1) + finally: + os.chdir(old_cwd) + shutil.rmtree(tmp, ignore_errors=True) + def test_agent_that_did_not_build_the_prompt_replays_the_persisted_snapshot(self): """Resume / gateway / TUI shape: a fresh agent rebuilds (compaction, a first /compress) after the repo moved and replays the snapshot its session row already holds — unless that prompt From 9fef6b1fc51059267b1916140412dc5653bb6189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B5=E6=B0=B5?= <982026408@qq.com> Date: Tue, 29 Sep 2026 11:23:39 +0800 Subject: [PATCH 03/10] fix(agent): compare live workspace paths by filesystem identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path.resolve() follows symlinks but preserves the caller's casing, and the default macOS APFS filesystem is case-insensitive: the same directory bound as .../MixedCase and .../mixedcase yields two resolved strings, so the workspace pin missed, the rebuild re-probed git live, and the session-start snapshot was rewritten mid-session — the exact drift the resolved-path normalization was added to prevent (review of e1b2ed3 by ehz0ah, reproduced through the real set_session_cwd path). Compare live paths by filesystem identity (Path.samefile) at the three pin seams — the replay decision, the persisted-prompt adoption gate, and the persisted-block root match — with resolved-string equality as the fallback when a path no longer exists. A pin key of "" (no workspace) still only equals itself. --- agent/system_prompt.py | 31 +++++++++++++--- tests/agent/test_compaction_prompt_rebuild.py | 37 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index b89417f5eac9..f4e2469738f3 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -609,6 +609,27 @@ def _workspace_pin_key() -> str: return "" +def _same_live_dir(a: Path, b: Path) -> bool: + """Directory identity that survives spelling aliases resolve() cannot normalize. On a + case-insensitive filesystem (default macOS APFS) resolve() follows symlinks but keeps the + caller's casing, so one directory spelled ``MixedCase`` and ``mixedcase`` yields two + resolved strings — and two pin keys, so the rebuild re-probes git and rewrites the + session-start snapshot mid-session. When both paths exist the filesystem itself + (``samefile``) is the authority; a missing path (deleted cwd, vanished root) falls back + to the string comparison.""" + if a == b: + return True + try: + return a.samefile(b) + except OSError: + return False + + +def _same_pin_key(a: str, b: str) -> bool: + """Pin-key equality; "" is a real pinned value (no workspace) and only equals itself.""" + return a == b or (bool(a) and bool(b) and _same_live_dir(Path(a), Path(b))) + + def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: """The workspace snapshot inside ``prompt`` taken for ``key`` (its ``- Root:`` is ``key`` or an ancestor); "" when the prompt has none; None when it has one for another root.""" @@ -621,7 +642,7 @@ def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: while start >= 0: block = prompt[start + 2:].split("\n\n", 1)[0] root = Path(block.split("\n", 2)[1][len("- Root: "):]).resolve() - if root == cwd or root in cwd.parents: + if _same_live_dir(root, cwd) or any(_same_live_dir(root, p) for p in cwd.parents): return block start = prompt.find(head, start + 2) return None @@ -656,9 +677,9 @@ def _seed_workspace_pin(agent: Any, key: str) -> None: stored_cwd = runtime_host_value(prompt, "Current working directory") if stored_cwd: # Same spelling normalization as _workspace_pin_key: the persisted hints carry the - # cwd as that surface spelled it, which can be a symlink spelling of the same - # resolved workspace. - if not key or Path(stored_cwd).resolve() != Path(key).resolve(): + # cwd as that surface spelled it, which can be a symlink — or, on a case-insensitive + # filesystem, a case-alias — spelling of the same resolved workspace. + if not key or not _same_live_dir(Path(stored_cwd).resolve(), Path(key).resolve()): return block = _persisted_workspace_block(prompt, key) # Only a real snapshot is adopted: a prompt without one (built on a surface without the @@ -688,7 +709,7 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: _seed_workspace_pin(agent, cwd_key) pinned = getattr(agent, "_frozen_workspace_snapshot", None) # "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes. - replay = pinned[1] if pinned is not None and pinned[0] == cwd_key else None + replay = pinned[1] if pinned is not None and _same_pin_key(pinned[0], cwd_key) else None parts = coding_system_prompt_parts(platform=agent.platform, cwd=cwd, model=agent.model, valid_tool_names=agent.valid_tool_names, workspace_block=replay) if replay is None: diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index 389de8130a78..bfca96cb00e6 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -282,6 +282,43 @@ def test_symlink_spelling_of_the_launch_dir_replays_the_pin(self): os.chdir(old_cwd) shutil.rmtree(tmp, ignore_errors=True) + def test_case_alias_spelling_of_the_launch_dir_replays_the_pin(self): + """macOS default APFS is case-insensitive but case-preserving: resolve() normalizes the + symlink spelling yet keeps the caller's casing, so a directory bound under a differently + cased spelling is the same workspace and the pin must hit. Skipped where the filesystem + is case-sensitive (Linux CI) — there the alias genuinely is a second directory.""" + import os, sys, tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt, invalidate_system_prompt + + if sys.platform == "win32": + self.skipTest("case-alias probing needs a case-insensitive filesystem") + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-case-alias-")) + old_cwd = os.getcwd() + try: + repo = _init_repo(tmp / "Repo", "init commit") + alias = repo.with_name(repo.name.lower()) + try: + alias_same = alias.samefile(repo) + except OSError: + alias_same = False # case-sensitive filesystem: the alias does not exist + if not alias_same: + self.skipTest("case-sensitive filesystem: the alias is a different directory") + os.chdir(repo) # os.getcwd() reports the on-disk casing + agent = self._pin_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"): + with patch("agent.system_prompt.resolve_context_cwd", return_value=None): + p1 = build_system_prompt(agent) + self.assertIn("Status: clean", p1) + (repo / "untracked.txt").write_text("wip\n") + invalidate_system_prompt(agent) + with patch("agent.system_prompt.resolve_context_cwd", return_value=alias): + self.assertEqual(build_system_prompt(agent), p1) + finally: + os.chdir(old_cwd) + shutil.rmtree(tmp, ignore_errors=True) + def test_agent_that_did_not_build_the_prompt_replays_the_persisted_snapshot(self): """Resume / gateway / TUI shape: a fresh agent rebuilds (compaction, a first /compress) after the repo moved and replays the snapshot its session row already holds — unless that prompt From f6871b36bc3460be8796752fefe7ee0436a49dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B5=E6=B0=B5?= <982026408@qq.com> Date: Tue, 29 Sep 2026 12:48:57 +0800 Subject: [PATCH 04/10] chore: map contributor email for the attribution check --- contributors/emails/982026408@qq.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/982026408@qq.com diff --git a/contributors/emails/982026408@qq.com b/contributors/emails/982026408@qq.com new file mode 100644 index 000000000000..99b59f23b402 --- /dev/null +++ b/contributors/emails/982026408@qq.com @@ -0,0 +1 @@ +lihongyuan99 From 6eb71ac8fc829092d68fda836fcc0100518148ef Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:08:03 +0800 Subject: [PATCH 05/10] test: gate the case-alias regression onto the macOS lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The samefile self-guard alone meant the test executed on no CI lane: the macOS lane only imports files the platforms-marker selector lists, Linux skipped via the self-guard on its case-sensitive volume, and the win32 guard skipped the rest — so a _same_live_dir regression would keep every job green. Mark it platforms("macos") so the selector ships the file to the lane that has the case-insensitive-and-case-preserving filesystem the scenario discriminates on; the self-guard stays as the fallback for a case-sensitive macOS volume. The win32 guard was redundant under the host gate and mis-named the requirement (NTFS is case-insensitive too); dropped. --- tests/agent/test_compaction_prompt_rebuild.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index bfca96cb00e6..502aa89273f7 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -12,6 +12,9 @@ back to its last good bytes (fail-open), never silently vanishes """ import unittest + +import pytest + from types import SimpleNamespace from unittest.mock import patch @@ -282,17 +285,18 @@ def test_symlink_spelling_of_the_launch_dir_replays_the_pin(self): os.chdir(old_cwd) shutil.rmtree(tmp, ignore_errors=True) + @pytest.mark.platforms("macos") def test_case_alias_spelling_of_the_launch_dir_replays_the_pin(self): - """macOS default APFS is case-insensitive but case-preserving: resolve() normalizes the - symlink spelling yet keeps the caller's casing, so a directory bound under a differently - cased spelling is the same workspace and the pin must hit. Skipped where the filesystem - is case-sensitive (Linux CI) — there the alias genuinely is a second directory.""" - import os, sys, tempfile, shutil + """macOS default APFS is case-insensitive and case-preserving: resolve() follows the + symlink yet keeps the caller's casing, so a directory bound under a differently cased + spelling is the same workspace and the pin must hit. The platforms marker brings the + file into the macOS lane (an unmarked file runs on no OS lane at all); the samefile + self-guard below still skips on a case-sensitive volume, where the alias genuinely + is a second directory.""" + import os, tempfile, shutil from pathlib import Path from agent.system_prompt import build_system_prompt, invalidate_system_prompt - if sys.platform == "win32": - self.skipTest("case-alias probing needs a case-insensitive filesystem") tmp = Path(tempfile.mkdtemp(prefix="test-pinned-case-alias-")) old_cwd = os.getcwd() try: From a52ee4cb2c96e2f114cc6bd13ffc83efb9433ba6 Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:40:57 +0800 Subject: [PATCH 06/10] fix(agent): contain the symlink-loop raise family at the pin seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symlink loop reaching the pin seams from persisted session bytes (the Current working directory hint or a parsed - Root: line — session-DB data, not a live getcwd) makes Path.resolve raise RuntimeError on Python <= 3.12, which the OSError handlers did not cover: it escaped _seed_workspace_pin/_persisted_workspace_block into _coding_parts' blanket handler and silently dropped the whole coding block for that build. Verified on 3.11: _persisted_workspace_block('…- Root: …') escaped 'Symlink loop from …' before, returns None now. _same_live_dir takes raw spellings and runs resolve() itself under (OSError, RuntimeError) — one containment for all three seams (the callers pass persisted text straight through). _workspace_pin_key's handler names both raise families. _same_pin_key's empty-key contract is unchanged. Docstring first sentence no longer claims the key IS the prompt's cwd line (teknium1 review note) — it is the resolved form of the directory that line spells. --- agent/system_prompt.py | 54 ++++++++++++------- tests/agent/test_compaction_prompt_rebuild.py | 38 +++++++++++++ 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index f4e2469738f3..48a75491567d 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -593,9 +593,10 @@ def _alibaba_identity_part(agent: Any) -> List[str]: def _workspace_pin_key() -> str: - """The directory the workspace probe inspects, which is also the prompt's ``Current working - directory``: a build with no cwd bound (launch dir) and a later one binding that same dir - (TUI ``/compress``) are one workspace, not two. + """The directory the workspace probe inspects, in resolved form — the prompt's + ``Current working directory`` line carries the session's own spelling of it. Under that + normalization a build with no cwd bound (launch dir) and a later one binding that same + dir (TUI ``/compress``) are one workspace, not two. Normalized to the resolved real path: a launch-dir key comes from ``os.getcwd()`` (which reports the physical path — ``/private/var/...`` on macOS) while a bound cwd arrives as the @@ -605,29 +606,43 @@ def _workspace_pin_key() -> str: try: raw = str(resolve_context_cwd() or resolve_agent_cwd()) return str(Path(raw).resolve()) if raw else "" - except OSError: # deleted cwd + except (OSError, RuntimeError): # deleted cwd, or a symlink loop in a bound spelling return "" -def _same_live_dir(a: Path, b: Path) -> bool: - """Directory identity that survives spelling aliases resolve() cannot normalize. On a - case-insensitive filesystem (default macOS APFS) resolve() follows symlinks but keeps the - caller's casing, so one directory spelled ``MixedCase`` and ``mixedcase`` yields two - resolved strings — and two pin keys, so the rebuild re-probes git and rewrites the - session-start snapshot mid-session. When both paths exist the filesystem itself - (``samefile``) is the authority; a missing path (deleted cwd, vanished root) falls back - to the string comparison.""" - if a == b: +def _same_live_dir(a: str, b: str) -> bool: + """Directory identity that survives spelling aliases a plain string comparison misses. + + *Symlink* spellings: a launch-dir key comes from ``os.getcwd()`` (the physical path — + ``/private/var/...`` on macOS) while a bound cwd or a persisted ``- Root:`` line carries + the session's own spelling (``/var/...`` through the symlink); comparing those raw + misses the pin, the rebuild re-probes git live, and the session-start snapshot is + rewritten mid-session — invalidating the cached prompt prefix for a workspace that + never changed. + *Case* spellings: resolve() follows symlinks but keeps the caller's casing, so on a + case-insensitive filesystem (default macOS APFS) one directory spelled ``MixedCase`` + and ``mixedcase`` still yields two strings. + + When both paths exist the filesystem itself (``samefile``) is the authority; a missing + path (deleted cwd, vanished root) falls back to the string comparison. Both inputs may + be persisted session bytes, so resolve() runs here under (OSError, RuntimeError): a + symlink loop raises the latter on Python <= 3.12, and letting it escape the pin seams + would drop the whole coding block to the blanket handler.""" + try: + pa, pb = Path(a).resolve(), Path(b).resolve() + except (OSError, RuntimeError): + return False + if pa == pb: return True try: - return a.samefile(b) + return pa.samefile(pb) except OSError: return False def _same_pin_key(a: str, b: str) -> bool: """Pin-key equality; "" is a real pinned value (no workspace) and only equals itself.""" - return a == b or (bool(a) and bool(b) and _same_live_dir(Path(a), Path(b))) + return a == b or (bool(a) and bool(b) and _same_live_dir(a, b)) def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: @@ -641,8 +656,11 @@ def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: cwd = Path(key).resolve() while start >= 0: block = prompt[start + 2:].split("\n\n", 1)[0] - root = Path(block.split("\n", 2)[1][len("- Root: "):]).resolve() - if _same_live_dir(root, cwd) or any(_same_live_dir(root, p) for p in cwd.parents): + # The Root line is persisted session bytes: resolve it inside _same_live_dir so a + # symlink loop in it cannot escape into the caller's blanket handler. + root_line = block.split("\n", 2)[1][len("- Root: "):] + if _same_live_dir(root_line, str(cwd)) or any( + _same_live_dir(root_line, str(p)) for p in cwd.parents): return block start = prompt.find(head, start + 2) return None @@ -679,7 +697,7 @@ def _seed_workspace_pin(agent: Any, key: str) -> None: # Same spelling normalization as _workspace_pin_key: the persisted hints carry the # cwd as that surface spelled it, which can be a symlink — or, on a case-insensitive # filesystem, a case-alias — spelling of the same resolved workspace. - if not key or not _same_live_dir(Path(stored_cwd).resolve(), Path(key).resolve()): + if not key or not _same_live_dir(stored_cwd, key): return block = _persisted_workspace_block(prompt, key) # Only a real snapshot is adopted: a prompt without one (built on a surface without the diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index 502aa89273f7..93bf11557854 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -354,6 +354,44 @@ def env(cwd): finally: shutil.rmtree(tmp, ignore_errors=True) + def test_loop_cwd_spelling_in_persisted_bytes_cannot_drop_the_coding_block(self): + """A symlink loop in persisted session bytes (the Current working directory hint or a + ``- Root:`` line) makes Path.resolve raise RuntimeError on Python <= 3.12; the pin + seams must contain it instead of letting it reach the blanket handler that would + silently drop the whole coding block for that build.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import _same_live_dir, build_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-loop-")) + try: + loop = tmp / "loop" + loop.symlink_to(loop) # symlink pointing at itself + # Contained at the comparison seam on every supported interpreter. + self.assertIs(_same_live_dir(str(loop), str(tmp)), False) + + repo, other = _init_repo(tmp / "proj", "init commit"), _init_repo(tmp / "other", "init other") + + def env(cwd): + return patch("agent.prompt_builder.build_environment_hints", + return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {cwd}") + + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + stored = build_system_prompt(self._pin_agent()) + self.assertIn("Status: clean", stored) + # A persisted prompt whose cwd hint spells a symlink loop: seeding must neither + # adopt nor raise — the rebuilt prompt keeps its coding block. + poisoned = stored.replace(f"Current working directory: {repo}", + f"Current working directory: {loop}") + db = SimpleNamespace(get_session=lambda sid: {"system_prompt": poisoned}) + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(other), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=other): + self.assertIn("- Root: ", build_system_prompt( + self._pin_agent(_cached_system_prompt=None, _session_db=db))) + finally: + shutil.rmtree(tmp, ignore_errors=True) + def test_persisted_prompt_without_a_snapshot_does_not_pin_an_empty_one(self): """A session row built where no workspace block was emitted (a messaging surface) and resumed in the same repo must capture a real snapshot, not pin "no workspace" for good.""" From 4a90a34bad6f7c7b4438b9bd085ff453254a4f92 Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:47:37 +0800 Subject: [PATCH 07/10] fix(agent): treat pin-key equality as unproven provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _same_live_dir answers "are these two spellings the same directory today", but the pin also feeds it a historical path stored with a snapshot. Renaming the captured directory and reusing its old pathname as a symlink to an independent repo makes today's resolution of the stored key land on the new directory, so the equality helper replays the old snapshot there — the consumer then skips its fresh probe and the prompt keeps A's root/commit after an explicit switch to B. The persisted seam re-identifies the same way: _seed_workspace_pin adopts A's bytes for B because the stored cwd hint and the persisted - Root: line both resolve into B through the replacement symlink, and an initially empty live pin suppresses B's snapshot entirely. Two pieces of provenance evidence close it without reverting the stable-alias and case-alias replay the earlier reviews pinned: - the live pin records (st_dev, st_ino) at capture and the replay gate requires the stored key to still stat to that identity — a rebound spelling stats to the replacement directory, a stable alias still stats to the recorded one; legacy 2-tuples keep the key-match contract - a persisted - Root: line is git rev-parse --show-toplevel output, i.e. a resolve() fixpoint at capture; when resolving it today traverses a symlink, the directory it named was moved or rebound and the bytes are not adopted Red→green: three new tests (live replay, persisted adoption, empty-pin suppression) fail at the parent head and pass with the fix; test_compaction_prompt_rebuild 15/15, prompt/compaction battery (coding_context, anti_thrash, cache policy, profile isolation) 132/132. --- agent/system_prompt.py | 57 +++++++++- tests/agent/test_compaction_prompt_rebuild.py | 101 ++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 48a75491567d..886508a89e0b 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -645,6 +645,39 @@ def _same_pin_key(a: str, b: str) -> bool: return a == b or (bool(a) and bool(b) and _same_live_dir(a, b)) +def _dir_identity(path: str) -> Optional[Tuple[int, int]]: + """``(st_dev, st_ino)`` of a directory, recorded when a snapshot is pinned to it. + + A pathname only proves which directory a snapshot was taken in until the path + is rebound: renaming the directory and reusing its old name for a symlink to + somewhere else makes today's resolution of the stored spelling land on the + new directory, and a current-filesystem comparison then replays the snapshot + there. Recording the identity at capture time lets the replay gate tell a + stable alias (same directory behind the spelling) from a rebound one. None + when the directory cannot be stat'd.""" + try: + st = Path(path).stat() + return (st.st_dev, st.st_ino) + except OSError: + return None + + +def _persisted_root_still_physical(root_line: str) -> bool: + """Provenance evidence carried by a persisted ``- Root:`` line. + + The line is ``git rev-parse --show-toplevel`` output — the physical, fully + resolved spelling of the repo root on the day the snapshot was taken, so it + is a fixpoint of ``resolve()`` at capture. If resolving it today traverses a + symlink, the physical directory it named has been renamed or its pathname + rebound, and today's ``samefile`` agreement proves nothing about the + snapshot's origin; the gate must take a fresh snapshot instead of adopting + the bytes.""" + try: + return Path(root_line).resolve() == Path(root_line) + except (OSError, RuntimeError): + return False + + def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: """The workspace snapshot inside ``prompt`` taken for ``key`` (its ``- Root:`` is ``key`` or an ancestor); "" when the prompt has none; None when it has one for another root.""" @@ -659,8 +692,12 @@ def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: # The Root line is persisted session bytes: resolve it inside _same_live_dir so a # symlink loop in it cannot escape into the caller's blanket handler. root_line = block.split("\n", 2)[1][len("- Root: "):] - if _same_live_dir(root_line, str(cwd)) or any( - _same_live_dir(root_line, str(p)) for p in cwd.parents): + # Provenance first: a Root line that no longer resolves to itself names a + # directory that was renamed or rebound after capture, so today's samefile + # agreement is not evidence this block belongs to the current workspace. + if _persisted_root_still_physical(root_line) and ( + _same_live_dir(root_line, str(cwd)) or any( + _same_live_dir(root_line, str(p)) for p in cwd.parents)): return block start = prompt.find(head, start + 2) return None @@ -703,7 +740,7 @@ def _seed_workspace_pin(agent: Any, key: str) -> None: # Only a real snapshot is adopted: a prompt without one (built on a surface without the # coding posture, or with tools off) leaves the pin open so this build captures one. if block: - agent._frozen_workspace_snapshot = (key, block) + agent._frozen_workspace_snapshot = (key, block, _dir_identity(key)) def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: @@ -727,11 +764,21 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: _seed_workspace_pin(agent, cwd_key) pinned = getattr(agent, "_frozen_workspace_snapshot", None) # "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes. - replay = pinned[1] if pinned is not None and _same_pin_key(pinned[0], cwd_key) else None + # A key match alone is not provenance: the captured key names its directory only + # until the path is rebound (renamed + reused for a symlink), so a pinned snapshot + # whose recorded directory identity no longer matches the key's today replays as + # None and the build probes fresh. Legacy 2-tuples carry no identity and keep the + # key-match contract. + replay = None + if pinned is not None and _same_pin_key(pinned[0], cwd_key): + identity = pinned[2] if len(pinned) > 2 else None + if identity is None or _dir_identity(pinned[0]) == identity: + replay = pinned[1] parts = coding_system_prompt_parts(platform=agent.platform, cwd=cwd, model=agent.model, valid_tool_names=agent.valid_tool_names, workspace_block=replay) if replay is None: - agent._frozen_workspace_snapshot = (cwd_key, parts[1][0] if parts[1] else "") + agent._frozen_workspace_snapshot = (cwd_key, parts[1][0] if parts[1] else "", + _dir_identity(cwd_key)) return parts except Exception: pass diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index 93bf11557854..7af496618f31 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -413,6 +413,107 @@ def test_persisted_prompt_without_a_snapshot_does_not_pin_an_empty_one(self): finally: shutil.rmtree(tmp, ignore_errors=True) + def _rebind(self, captured, other): + """Rename the captured directory and reuse its pathname as a symlink to `other`.""" + import sys + if sys.platform == "win32": + self.skipTest("directory symlinks need privileges on Windows") + saved = captured.with_name(captured.name + "-saved") + captured.rename(saved) + captured.symlink_to(other, target_is_directory=True) + return saved + + def test_rebound_path_cannot_replay_the_captured_snapshot(self): + """Capturing in physical A, renaming it and reusing the pathname A as a symlink to + independent repo B, then binding B: today's resolution of the captured key lands on + B, but the snapshot was taken in A-saved — the build must probe B instead of + replaying A's frozen bytes under B's name.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt, invalidate_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-rebind-")) + try: + captured = _init_repo(tmp / "captured", "init captured") + other = _init_repo(tmp / "other", "init other") + captured_key = captured.resolve() + agent = self._pin_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=captured): + self.assertIn("Status: clean", build_system_prompt(agent)) + self._rebind(captured, other) + invalidate_system_prompt(agent) + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=other): + rebuilt = build_system_prompt(agent) + self.assertIn(f"- Root: {other.resolve()}", rebuilt) + self.assertNotIn(f"- Root: {captured_key}", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_rebound_path_cannot_adopt_the_persisted_snapshot(self): + """Fresh agent, session row built in physical A, A rebound to B, agent bound to B: + the persisted ``- Root:`` line was git's physical spelling at capture — a resolve() + that now traverses a symlink proves the directory was rebound, so the bytes must + not be adopted for B.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-adopt-rebind-")) + try: + captured = _init_repo(tmp / "captured", "init captured") + other = _init_repo(tmp / "other", "init other") + captured_key = captured.resolve() + + def env(cwd): + return patch("agent.prompt_builder.build_environment_hints", + return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {cwd}") + + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(captured), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=captured): + stored = build_system_prompt(self._pin_agent()) + self.assertIn("Status: clean", stored) + self._rebind(captured, other) + db = SimpleNamespace(get_session=lambda sid: {"system_prompt": stored}) + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(other), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=other): + rebuilt = build_system_prompt(self._pin_agent(_cached_system_prompt=None, _session_db=db)) + self.assertIn(f"- Root: {other.resolve()}", rebuilt) + self.assertNotIn(f"- Root: {captured_key}", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_rebound_nonworkspace_path_must_not_pin_its_emptiness_onto_the_new_repo(self): + """Capturing "no workspace" in a plain directory, then reusing its pathname as a + symlink to a git repository: an empty pin must not suppress the new directory's + snapshot — the build emits B's workspace block.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt, invalidate_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-empty-rebind-")) + try: + plain = tmp / "plain" + plain.mkdir() + other = _init_repo(tmp / "other", "init other") + agent = self._pin_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=plain): + build_system_prompt(agent) + self.assertEqual(agent._frozen_workspace_snapshot[1], "") + self._rebind(plain, other) + invalidate_system_prompt(agent) + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=other): + self.assertIn(f"- Root: {other.resolve()}", build_system_prompt(agent)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + if __name__ == "__main__": unittest.main() From dad6ca1eb93696bcf57a5978f857460866a36227 Mon Sep 17 00:00:00 2001 From: lihongyuan99 <64824864+lihongyuan99@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:07:15 +0800 Subject: [PATCH 08/10] fix(agent): bind workspace snapshots to a directory generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin's equality checks proved path identity, not snapshot provenance: a pathname whose occupant was replaced still satisfied every one of them, and the persisted parser could be impersonated by earlier prompt prose. - Generation: the live pin's identity now records the owning .git marker's (st_dev, st_ino) beside the directory's — ordinary git work never deletes .git, so the pair is stable across a workspace's life and changes when a new generation takes over the pathname (a removed-and-recreated path can reuse the directory's own pair; observed on overlayfs). The published pin samples the generation on both sides of collection and publishes nothing on a mismatch, so a replacement landing between the producer's snapshot and a post-hoc stat can no longer certify old bytes with a new occupant's identity — the pin stays unseeded and the next build probes fresh. - Owner: adoption now requires the persisted - Root: line to be the workspace that owns the cwd — the producer's own git/marker-root resolution — not merely a physical ancestor; a nested independent repository under the captured root is a different workspace. - Capture-time evidence: the bytes must still describe today's occupant — a git workspace by its history tip (the same git log -3 render the producer emits), a marker-only workspace by its project facts. An independent repository created at the same pathname fails even when every spelling and ancestry check passes. - Framing: the persisted parser reads the LAST workspace-shaped block. Project context is rendered before the canonical snapshot and its AGENTS.md content is repo-controlled prose, so an earlier copy naming the real root can impersonate the frame — it cannot move itself after the canonical block. Five regressions pin the shapes with real repositories and the real builder: collection-time replacement publishes no pin and the rebuild probes the replacement; a nested-repo rebind, a same-path replacement and a recycled-identity empty pin each force a fresh probe; an AGENTS.md copy of the frame is never adopted and cannot outlive its file through the pin. All five fail on the parent head and pass here; the pin suites (test_compaction_prompt_rebuild 20/20, test_system_prompt, test_coding_context, test_plugin_prompt_sections, test_gitspawn_config_injection) run 129/129. --- agent/coding_context.py | 23 +- agent/system_prompt.py | 163 ++++++++++--- tests/agent/test_compaction_prompt_rebuild.py | 226 ++++++++++++++++++ 3 files changed, 378 insertions(+), 34 deletions(-) diff --git a/agent/coding_context.py b/agent/coding_context.py index 111ac910d203..06ed1b2bf40f 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -514,6 +514,21 @@ def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, An } +def _facts_lines(facts: ProjectFacts) -> list[str]: + """The workspace-block lines that carry project facts, rendered exactly as + ``build_coding_workspace_block`` emits them — the evidence a pin re-derives to tell + a marker-only workspace's generation from its successor's.""" + lines: list[str] = [] + if facts.manifests: + managers = f" ({'/'.join(facts.package_managers)})" if facts.package_managers else "" + lines.append(f"- Project: {', '.join(facts.manifests[:6])}{managers}") + if facts.verify_commands: + lines.append(f"- Verify: {'; '.join(facts.verify_commands)}") + if facts.context_files: + lines.append(f"- Context files: {', '.join(facts.context_files)}") + return lines + + WORKSPACE_BLOCK_HEADER = "Workspace (snapshot at session start — re-check with `git` before acting on it):" @@ -553,13 +568,7 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str: lines.extend(["- Recent commits:", *(f" {c}" for c in recent.splitlines())]) f = detect_project_facts(root) - if f.manifests: - managers = f" ({'/'.join(f.package_managers)})" if f.package_managers else "" - lines.append(f"- Project: {', '.join(f.manifests[:6])}{managers}") - if f.verify_commands: - lines.append(f"- Verify: {'; '.join(f.verify_commands)}") - if f.context_files: - lines.append(f"- Context files: {', '.join(f.context_files)}") + lines.extend(_facts_lines(f)) return "\n".join(lines) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 886508a89e0b..75a6a3ec6c3e 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -662,6 +662,44 @@ def _dir_identity(path: str) -> Optional[Tuple[int, int]]: return None +def _nearest_git_marker(path: str) -> Optional[Path]: + """The nearest ``.git`` (directory or linked-worktree pointer file) at or above + *path* — git's own discovery rule, derived by stat alone so the replay gate can + consult it on every rebuild without shelling out.""" + try: + start = Path(path).resolve() + except (OSError, RuntimeError): + return None + for candidate in (start, *start.parents): + marker = candidate / ".git" + try: + if marker.exists(): + return marker + except OSError: + continue + return None + + +def _workspace_generation(path: str) -> Optional[Tuple[int, int, Optional[Tuple[int, int]]]]: + """Directory identity bound to the workspace lifetime that owned it at capture: + ``(st_dev, st_ino)`` of *path* plus the identity of the ``.git`` marker that owns it + (None when no workspace marker sits above the path). + + The bare directory pair alone cannot tell a directory lifetime from its successor: + a removed-and-recreated pathname can reuse the pair (observed on overlayfs), and a + pin recorded before the replacement then replays old bytes — or an old emptiness — + onto the new occupant. Ordinary git work never deletes ``.git``, so the marker's + identity is stable across a workspace's life and changes whenever a different + generation takes over the pathname (a fresh ``git init`` or clone among them); the + replay gate re-probes on any doubt. None when the path cannot be stat'd.""" + base = _dir_identity(path) + if base is None: + return None + marker = _nearest_git_marker(path) + marker_identity = _dir_identity(str(marker)) if marker is not None else None + return (base[0], base[1], marker_identity) + + def _persisted_root_still_physical(root_line: str) -> bool: """Provenance evidence carried by a persisted ``- Root:`` line. @@ -678,29 +716,88 @@ def _persisted_root_still_physical(root_line: str) -> bool: return False +def _persisted_git_history(block: str) -> Optional[List[str]]: + """The snapshot's git-history tip as its bytes recorded it at capture: the + ``- Recent commits:`` entries when the block carries git state, ``[]`` for a + repository without commits, None when the block describes a workspace without + git (marker-only project). The tip is the bytes' own capture-time generation + evidence — it cannot be manufactured at resume.""" + lines = block.splitlines() + if not any(line.startswith("- Branch: ") for line in lines): + return None + history: List[str] = [] + in_commits = False + for line in lines: + if line.startswith("- Recent commits:"): + in_commits = True + continue + if in_commits: + if line.startswith(" "): + history.append(line.strip()) + else: + break + return history + + +def _persisted_generation_matches(block: str, owner: Path, expect_git: bool) -> bool: + """Whether the snapshot's capture-time evidence still describes today's occupant of + the workspace. A git workspace is identified by its history tip (the same + ``git log -3`` render the producer emits); a marker-only workspace by its project + facts. A pathname whose previous occupant was replaced by an independent workspace + fails even when every spelling and ancestry check passes.""" + from agent.coding_context import _facts_lines, _git, detect_project_facts + persisted_history = _persisted_git_history(block) + if (persisted_history is not None) != expect_git: + return False + if persisted_history is not None: + today = [line for line in _git(owner, "log", "-3", "--pretty=%h %s").splitlines() + if line.strip()] + return persisted_history == today + persisted_facts = [line for line in block.splitlines() + if line.startswith(("- Project: ", "- Verify: ", "- Context files: "))] + return persisted_facts == _facts_lines(detect_project_facts(owner)) + + def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: - """The workspace snapshot inside ``prompt`` taken for ``key`` (its ``- Root:`` is ``key`` or an - ancestor); "" when the prompt has none; None when it has one for another root.""" - from agent.coding_context import WORKSPACE_BLOCK_HEADER + """The workspace snapshot inside ``prompt`` taken for ``key`` (its ``- Root:`` is + the workspace that owns ``key``); "" when the prompt has none; None when its only + candidate is not provably this workspace's snapshot. + + Framing first: project context (repo-controlled AGENTS.md content) is rendered + before the canonical snapshot, so the LAST workspace-shaped block in the prompt is + the renderer's — earlier copies are project-context prose and are never adopted, + however exactly they imitate the frame (a context block can name the real root and + copy its history, but it cannot move itself after the canonical block). + + The candidate then has to prove it still describes this workspace: + * the persisted ``- Root:`` line is git's physical spelling at capture — if it no + longer resolves to itself, the directory was renamed or rebound and today's + ``samefile`` agreement proves nothing (resolved here so a loop in persisted + bytes cannot escape into the caller's blanket handler); + * that root must be the workspace that OWNS the cwd today — the producer's own + git/marker-root resolution, not merely an ancestor: a nested independent + repository under the captured root is a different workspace; + * the bytes' git history tip (or project facts, for marker-only workspaces) must + still match the occupant's — a pathname reused by an independent workspace is a + new generation even when the spelling is unchanged.""" + from agent.coding_context import WORKSPACE_BLOCK_HEADER, _workspace_roots head = f"\n\n{WORKSPACE_BLOCK_HEADER}\n- Root: " - start = prompt.find(head) + start = prompt.rfind(head) if start < 0: return "" - cwd = Path(key).resolve() - while start >= 0: - block = prompt[start + 2:].split("\n\n", 1)[0] - # The Root line is persisted session bytes: resolve it inside _same_live_dir so a - # symlink loop in it cannot escape into the caller's blanket handler. - root_line = block.split("\n", 2)[1][len("- Root: "):] - # Provenance first: a Root line that no longer resolves to itself names a - # directory that was renamed or rebound after capture, so today's samefile - # agreement is not evidence this block belongs to the current workspace. - if _persisted_root_still_physical(root_line) and ( - _same_live_dir(root_line, str(cwd)) or any( - _same_live_dir(root_line, str(p)) for p in cwd.parents)): - return block - start = prompt.find(head, start + 2) - return None + block = prompt[start + 2:].split("\n\n", 1)[0] + root_line = block.split("\n", 2)[1][len("- Root: "):] + if not _persisted_root_still_physical(root_line): + return None + try: + git_root, owner = _workspace_roots(key) + except Exception: + return None + if owner is None or not _same_live_dir(root_line, str(owner)): + return None + if not _persisted_generation_matches(block, Path(owner), git_root is not None): + return None + return block def _session_prompt(agent: Any) -> Optional[str]: @@ -740,7 +837,7 @@ def _seed_workspace_pin(agent: Any, key: str) -> None: # Only a real snapshot is adopted: a prompt without one (built on a surface without the # coding posture, or with tools off) leaves the pin open so this build captures one. if block: - agent._frozen_workspace_snapshot = (key, block, _dir_identity(key)) + agent._frozen_workspace_snapshot = (key, block, _workspace_generation(key)) def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: @@ -765,20 +862,32 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: pinned = getattr(agent, "_frozen_workspace_snapshot", None) # "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes. # A key match alone is not provenance: the captured key names its directory only - # until the path is rebound (renamed + reused for a symlink), so a pinned snapshot - # whose recorded directory identity no longer matches the key's today replays as - # None and the build probes fresh. Legacy 2-tuples carry no identity and keep the - # key-match contract. + # until the path is rebound (renamed + reused for a symlink), and the recorded + # directory identity alone does not prove a lifetime — a removed-and-recreated + # pathname can reuse the (st_dev, st_ino) pair. The generation adds the owning + # .git marker's identity, which ordinary git work never deletes and every new + # workspace generation changes; a pin whose recorded generation no longer + # matches today's replays as None and the build probes fresh. Legacy tuples + # carry no generation and keep the key-match contract. replay = None if pinned is not None and _same_pin_key(pinned[0], cwd_key): identity = pinned[2] if len(pinned) > 2 else None - if identity is None or _dir_identity(pinned[0]) == identity: + if identity is None or _workspace_generation(pinned[0]) == identity: replay = pinned[1] + if replay is None: + # Pairing: the published pin must bind the collected bytes to the directory + # generation they were taken in. Sampling only after the producer runs lets a + # replacement that lands in between certify old bytes with the new + # occupant's identity, so the generation is sampled on both sides and a + # mismatched pairing publishes nothing — the pin stays unseeded and the + # next build probes fresh. + pre = _workspace_generation(cwd_key) parts = coding_system_prompt_parts(platform=agent.platform, cwd=cwd, model=agent.model, valid_tool_names=agent.valid_tool_names, workspace_block=replay) if replay is None: - agent._frozen_workspace_snapshot = (cwd_key, parts[1][0] if parts[1] else "", - _dir_identity(cwd_key)) + post = _workspace_generation(cwd_key) + if pre is not None and pre == post: + agent._frozen_workspace_snapshot = (cwd_key, parts[1][0] if parts[1] else "", post) return parts except Exception: pass diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index 7af496618f31..189bcba5b819 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -514,6 +514,232 @@ def test_rebound_nonworkspace_path_must_not_pin_its_emptiness_onto_the_new_repo( finally: shutil.rmtree(tmp, ignore_errors=True) + def test_pin_is_not_published_when_the_directory_is_replaced_during_collection(self): + """Bytes and identity must be paired at collection: a replacement that lands between + the workspace producer's snapshot and a post-hoc stat must not publish a pin that + pairs one directory's bytes with another's identity — the pin stays unseeded and + the next build probes fresh.""" + import tempfile, shutil + from pathlib import Path + import agent.coding_context as cc + from agent.system_prompt import build_system_prompt, invalidate_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-pairing-")) + try: + captured = _init_repo(tmp / "captured", "init captured") + other = _init_repo(tmp / "other", "init other") + captured_key = captured.resolve() + real_parts = cc.coding_system_prompt_parts + state = {"armed": True} + + def renderer(**kwargs): + parts = real_parts(**kwargs) + if state["armed"] and parts[1]: + state["armed"] = False + self._rebind(captured, other) # replacement lands after collection + return parts + + agent = self._pin_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=captured), \ + patch("agent.coding_context.coding_system_prompt_parts", side_effect=renderer): + build_system_prompt(agent) + # No publishable pairing: the stat that would certify the collected bytes + # describes the replacement, not the directory they were taken in. + self.assertIsNone(getattr(agent, "_frozen_workspace_snapshot", None)) + invalidate_system_prompt(agent) + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=other): + rebuilt = build_system_prompt(agent) + self.assertIn(f"- Root: {other.resolve()}\n", rebuilt) + self.assertNotIn(f"- Root: {captured_key}\n", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_persisted_adoption_requires_the_owner_not_just_an_ancestor(self): + """Capture beneath repository A's root (cwd = A/src), rebind that subdirectory to an + independent nested repository B, resume bound to B: A's root is still a physical + ancestor of B, but it is not the workspace that owns B — the persisted snapshot + must not be adopted, and the resumed agent must probe B.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-owner-")) + try: + import subprocess + outer = _init_repo(tmp / "outer", "init outer") + src, inner = outer / "src", outer / "inner" + src.mkdir() + inner.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=inner, check=True) + subprocess.run(["git", "-C", str(inner), "config", "user.email", "t@t"], check=True) + subprocess.run(["git", "-C", str(inner), "config", "user.name", "t"], check=True) + (inner / "inner.py").write_text("print(2)\n") + subprocess.run(["git", "-C", str(inner), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(inner), "commit", "-qm", "init inner"], check=True) + + def env(cwd): + return patch("agent.prompt_builder.build_environment_hints", + return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {cwd}") + + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(src), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=src): + stored = build_system_prompt(self._pin_agent()) + self.assertIn(f"- Root: {outer.resolve()}", stored) + self._rebind(src, inner) + # Control: an unchanged subdirectory resume still replays the captured snapshot + # (cwd src-saved is the same workspace the snapshot was taken in). + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(outer / "src-saved"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=outer / "src-saved"): + same = build_system_prompt(self._pin_agent(_cached_system_prompt=None, + _session_db=SimpleNamespace( + get_session=lambda sid: {"system_prompt": stored}))) + self.assertIn(f"- Root: {outer.resolve()}\n", same) + # The rebind: the persisted root is an ancestor of the new cwd, not its owner. + db = SimpleNamespace(get_session=lambda sid: {"system_prompt": stored}) + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(inner), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=inner): + rebuilt = build_system_prompt(self._pin_agent(_cached_system_prompt=None, _session_db=db)) + self.assertIn(f"- Root: {inner.resolve()}\n", rebuilt) + self.assertNotIn(f"- Root: {outer.resolve()}\n", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_same_path_replacement_cannot_adopt_the_previous_workspace(self): + """Repository A is captured at pathname P, its lifetime ends, and an independent + repository B is created at the same P (no symlink involved): every spelling and + ancestry check passes, so adoption needs the bytes' own capture-time evidence — + B's git history differs from the snapshot's, and B must be probed fresh.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import build_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-same-path-")) + try: + repo = _init_repo(tmp / "proj", "init captured") + + def env(cwd): + return patch("agent.prompt_builder.build_environment_hints", + return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {cwd}") + + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + stored = build_system_prompt(self._pin_agent()) + self.assertIn("init captured", stored) + shutil.rmtree(repo) + _init_repo(tmp / "proj", "init replaced") + db = SimpleNamespace(get_session=lambda sid: {"system_prompt": stored}) + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + rebuilt = build_system_prompt(self._pin_agent(_cached_system_prompt=None, _session_db=db)) + self.assertIn("init replaced", rebuilt) + self.assertNotIn("init captured", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_recycled_pathname_cannot_replay_an_empty_pin_onto_a_new_workspace(self): + """The live sibling of the same-path replacement: a plain directory's empty pin, + pathname recreated as an independent repository. Directory identity can be + recycled by the filesystem (observed on overlayfs), so the replay gate must not + certify the old emptiness onto the new occupant — B is probed.""" + import tempfile, shutil + from pathlib import Path + from agent.system_prompt import (_dir_identity, build_system_prompt, + invalidate_system_prompt) + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-recycled-")) + try: + plain = tmp / "plain" + plain.mkdir() + agent = self._pin_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=plain): + build_system_prompt(agent) + pin = agent._frozen_workspace_snapshot + self.assertEqual(pin[1], "") + shutil.rmtree(plain) + _init_repo(tmp / "plain", "init replaced") + # The recycled-identity shape the gate must survive: the new occupant stats + # to the recorded (st_dev, st_ino) pair. + agent._frozen_workspace_snapshot = (pin[0], pin[1], _dir_identity(str(plain))) + invalidate_system_prompt(agent) + with patch("agent.prompt_builder.load_soul_md", return_value=""), \ + patch("agent.prompt_builder.build_environment_hints", return_value="ENV HINTS"), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=plain): + rebuilt = build_system_prompt(agent) + self.assertIn(f"- Root: {plain.resolve()}", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_project_context_copy_cannot_impersonate_the_workspace_snapshot(self): + """The persisted parser must not discover workspace state from unframed prompt + prose: project context (repo-controlled AGENTS.md content) is rendered BEFORE the + canonical snapshot, and a copy there — naming the real root — must never be + adopted or pinned. The canonical block is what gets frozen, and once the + context file is gone its bytes cannot survive through the workspace pin.""" + import tempfile, shutil, subprocess + from pathlib import Path + from agent.coding_context import WORKSPACE_BLOCK_HEADER + from agent.system_prompt import build_system_prompt, invalidate_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-impersonation-")) + try: + repo = _init_repo(tmp / "proj", "init captured") + (repo / "AGENTS.md").write_text( + "# Agent notes\n\n" + f"{WORKSPACE_BLOCK_HEADER}\n" + f"- Root: {repo.resolve()}\n" + "- Status: FAKE-CONTEXT\n") + # Commit so the canonical snapshot reads a clean worktree. + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "add agents notes"], cwd=repo, check=True) + + def env(cwd): + return patch("agent.prompt_builder.build_environment_hints", + return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {cwd}") + + def ctx_agent(**over): + # _pin_agent skips context files; the impersonating copy lives in AGENTS.md. + return _agent( + load_soul_identity=False, skip_context_files=False, valid_tool_names={"terminal"}, + platform="cli", model="gpt-4o", _task_completion_guidance=False, + _parallel_tool_call_guidance=False, _tool_use_enforcement=False, _execution_guidance=False, + _environment_probe=False, _bot_mode_protocol=False, _kanban_worker_guidance="", + pass_session_id=False, session_id="s1", _emit_status=lambda *a, **k: None, **over) + + agent = ctx_agent() + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + stored = build_system_prompt(agent) + # Precondition: the impersonating copy really is in the persisted prompt, + # ahead of the canonical snapshot. + head = f"\n\n{WORKSPACE_BLOCK_HEADER}\n- Root: " + self.assertIn("FAKE-CONTEXT", stored) + self.assertLess(stored.find(head), stored.rfind(head)) + db = SimpleNamespace(get_session=lambda sid: {"system_prompt": stored}) + resumed = ctx_agent(_cached_system_prompt=None, _session_db=db) + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + build_system_prompt(resumed) + # The pin froze the canonical snapshot, not the project-context copy. + self.assertIn("Status: clean", resumed._frozen_workspace_snapshot[1]) + self.assertNotIn("FAKE-CONTEXT", resumed._frozen_workspace_snapshot[1]) + # Removing the context file takes its copy out of the prompt; the pinned + # snapshot is the canonical one, so the impersonating bytes cannot survive. + (repo / "AGENTS.md").unlink() + invalidate_system_prompt(resumed) + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + rebuilt = build_system_prompt(resumed) + self.assertNotIn("FAKE-CONTEXT", rebuilt) + self.assertIn("Status: clean", rebuilt) + finally: + shutil.rmtree(tmp, ignore_errors=True) + if __name__ == "__main__": unittest.main() From 80deae4c83f83d57076d6588cf03c95c12d6dcc4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:55:00 -0700 Subject: [PATCH 09/10] fix(agent): persisted-snapshot adoption checks captured HEAD membership, not tip equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dad6ca1 gated persisted-snapshot adoption on `git log -3` of the occupant being byte-equal to the snapshot's `- Recent commits:` lines. Any commit landed since session start therefore made the resumed/adopting agent re-probe and rewrite the prompt prefix — the exact rebuild the pin exists to prevent, and a regression of the base contract (`test_agent_that_did_not_build_the_prompt_replays_the_persisted_snapshot` on a repo that gained a commit was green on main and red on dad6ca1). The same-path-replacement evidence the check was added for survives: the captured HEAD's short hash is an object only the repository that made it can contain, so `git rev-parse --verify ^{commit}` in today's occupant distinguishes repo A from an independent repo B at the same pathname while accepting A after new commits. An empty captured history (pre-first-commit) adopts, as before. Regression: test_resumed_session_replays_the_snapshot_after_commits_landed_in_the_same_repo (red at dad6ca1, green here); test_same_path_replacement_cannot_adopt_the_previous_workspace stays green. --- agent/system_prompt.py | 22 ++++++++----- tests/agent/test_compaction_prompt_rebuild.py | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 75a6a3ec6c3e..126a4cfb47e0 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -741,18 +741,26 @@ def _persisted_git_history(block: str) -> Optional[List[str]]: def _persisted_generation_matches(block: str, owner: Path, expect_git: bool) -> bool: """Whether the snapshot's capture-time evidence still describes today's occupant of - the workspace. A git workspace is identified by its history tip (the same - ``git log -3`` render the producer emits); a marker-only workspace by its project - facts. A pathname whose previous occupant was replaced by an independent workspace - fails even when every spelling and ancestry check passes.""" + the workspace. A git workspace is identified by its captured HEAD: the first + ``- Recent commits:`` entry names the commit the snapshot was taken at, and that + object exists only in the repository that made it — an independent repository + created at the same pathname cannot contain it. Membership, not tip equality: the + pin exists so ordinary work (commits since session start) does NOT rewrite the + prompt, so a captured HEAD that has since gained descendants is the same + generation. A marker-only workspace is identified by its project facts.""" from agent.coding_context import _facts_lines, _git, detect_project_facts persisted_history = _persisted_git_history(block) if (persisted_history is not None) != expect_git: return False if persisted_history is not None: - today = [line for line in _git(owner, "log", "-3", "--pretty=%h %s").splitlines() - if line.strip()] - return persisted_history == today + if not persisted_history: + # Captured before the first commit: no object to look up, and nothing a + # replacement could impersonate beyond an equally empty history. + return True + captured_head = persisted_history[0].split(" ", 1)[0] + # rev-parse prints the full hash when the object is present and unambiguous; + # "" (missing, ambiguous, or git failure) falls back to a fresh snapshot. + return bool(_git(owner, "rev-parse", "--verify", "--quiet", f"{captured_head}^{{commit}}")) persisted_facts = [line for line in block.splitlines() if line.startswith(("- Project: ", "- Verify: ", "- Context files: "))] return persisted_facts == _facts_lines(detect_project_facts(owner)) diff --git a/tests/agent/test_compaction_prompt_rebuild.py b/tests/agent/test_compaction_prompt_rebuild.py index 189bcba5b819..9d3cdb8e0177 100644 --- a/tests/agent/test_compaction_prompt_rebuild.py +++ b/tests/agent/test_compaction_prompt_rebuild.py @@ -354,6 +354,37 @@ def env(cwd): finally: shutil.rmtree(tmp, ignore_errors=True) + def test_resumed_session_replays_the_snapshot_after_commits_landed_in_the_same_repo(self): + """The pin's reason to exist, on the adoption seam: a session resumed after the user + committed must replay the session-start snapshot, not re-probe (a fresh probe emits + a different ``- Recent commits:`` tip and rewrites the cached prompt prefix). + Generation evidence is membership of the captured HEAD in today's repository, not + equality with its tip.""" + import tempfile, shutil, subprocess + from pathlib import Path + from agent.system_prompt import build_system_prompt + + tmp = Path(tempfile.mkdtemp(prefix="test-pinned-resume-after-commit-")) + try: + repo = _init_repo(tmp / "proj", "init commit") + + def env(cwd): + return patch("agent.prompt_builder.build_environment_hints", + return_value=f"Host: x\nUser home directory: /h\nCurrent working directory: {cwd}") + + with patch("agent.prompt_builder.load_soul_md", return_value=""), env(repo), \ + patch("agent.system_prompt.resolve_context_cwd", return_value=repo): + stored = build_system_prompt(self._pin_agent()) + self.assertIn("init commit", stored) + (repo / "feature.py").write_text("x = 1\n") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-q", "-m", "second commit"], cwd=repo, check=True) + db = SimpleNamespace(get_session=lambda sid: {"system_prompt": stored}) + resumed = self._pin_agent(_cached_system_prompt=None, _session_db=db) + self.assertEqual(build_system_prompt(resumed), stored) + finally: + shutil.rmtree(tmp, ignore_errors=True) + def test_loop_cwd_spelling_in_persisted_bytes_cannot_drop_the_coding_block(self): """A symlink loop in persisted session bytes (the Current working directory hint or a ``- Root:`` line) makes Path.resolve raise RuntimeError on Python <= 3.12; the pin From 35ab806fe20a4150a8dec68fb3c662d4d3caea8a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:58:20 -0700 Subject: [PATCH 10/10] fix(agent): name the raise family at the persisted-block owner lookup The code-health ratchet (blocking on CI) rejects a blind except Exception in _persisted_workspace_block. _workspace_roots only raises OSError (vanished key) or RuntimeError (symlink loop on Python <= 3.12) from Path.resolve; name those, matching the sibling seams in this module. --- agent/system_prompt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 126a4cfb47e0..249b060c08db 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -799,7 +799,7 @@ def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: return None try: git_root, owner = _workspace_roots(key) - except Exception: + except (OSError, RuntimeError): # vanished key, or a symlink loop under Python <= 3.12 return None if owner is None or not _same_live_dir(root_line, str(owner)): return None