fix(prefix-cache): drop the workspace pin at session boundaries; bind session cwd for /context
The pin from the previous commit lives in _SESSION_STATE but nothing cleared it, so a CLI /new, /resume or /branch (same AIAgent, reset_session_state + _invalidate_system_prompt) replayed the previous session's git snapshot into the new session's prompt. Clear it in reset_session_state next to the other session anchors; one invariant test (red without it). The TUI/Desktop session.context_breakdown RPC ran the prompt builder on the RPC thread with no session cwd bound, so it re-probed against the backend's cwd and overwrote the session's pin — one /context between compactions restored the divergence this fix removes. Bind the session context around the build like the live rebuild in server.py does. Also: trim _coding_parts' docstring to the WHY, drop the isinstance/len guard on a value only this function writes, and remove the tests' assertions on the private pin shape.
This commit is contained in:
+6
-12
@@ -540,17 +540,11 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
|
||||
"""``(prefix, workspace, trailing)`` coding-posture blocks; all empty
|
||||
without tools or when probing fails (it must never block prompt build).
|
||||
|
||||
The workspace block is a LIVE ``git status``/``git log`` probe and it leads the
|
||||
context tier — ahead of the entire volatile band. Since #98426 the builder runs
|
||||
again at every compaction boundary, so re-probing here re-emits different bytes on
|
||||
any session whose repo moved (a commit, one edited or untracked file), pushing the
|
||||
prefix-cache divergence point in front of skills, memory and the timestamp line and
|
||||
making #98426's byte-equality keep-prompt fast path unreachable in a coding session.
|
||||
It also contradicts the block's own "snapshot at session start" wording and the
|
||||
freeze ``coding_context`` documents. So the first build pins the bytes on the agent,
|
||||
keyed by the resolved cwd (one gateway serves many cwds, and the per-turn terminal
|
||||
scope can move it), and later rebuilds replay them instead of shelling out again.
|
||||
A resumed process has no pin and legitimately re-snapshots at its own session start.
|
||||
The workspace block is a live git probe that leads the context tier, ahead of the whole
|
||||
volatile band; re-probing at the compaction rebuild re-emits different bytes for any
|
||||
repo that moved and defeats the keep-prompt fast path. So the bytes are pinned per
|
||||
session on the agent, keyed by the resolved cwd (a gateway serves many cwds), and
|
||||
replayed on rebuilds; ``reset_session_state`` drops the pin at a session boundary.
|
||||
"""
|
||||
try:
|
||||
from agent.coding_context import coding_system_prompt_parts
|
||||
@@ -560,7 +554,7 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
|
||||
cwd_key = str(cwd) if cwd is not None else ""
|
||||
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 isinstance(pinned, tuple) and len(pinned) == 2 and pinned[0] == cwd_key else None
|
||||
replay = pinned[1] if pinned is not None and 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:
|
||||
|
||||
@@ -398,6 +398,9 @@ class AIAgent(
|
||||
# Session boundary: the usage anchor describes the OLD transcript; fall back to full estimation.
|
||||
self._usage_anchor = None
|
||||
self._turn_base_usage_anchor = None
|
||||
# The workspace snapshot is pinned per session (agent/system_prompt.py::_coding_parts); a
|
||||
# /new, /resume or /branch on the same agent must re-snapshot at its own session start.
|
||||
self._frozen_workspace_snapshot = None
|
||||
|
||||
# Turn counter (added after reset_session_state was first written — #2635)
|
||||
self._user_turn_count = 0
|
||||
|
||||
@@ -70,6 +70,22 @@ class TestPluginRerenderFailOpen(unittest.TestCase):
|
||||
self.assertEqual(rendered, ())
|
||||
|
||||
|
||||
def _init_repo(path, first_commit):
|
||||
import subprocess
|
||||
path.mkdir()
|
||||
for cmd in (
|
||||
["git", "init", "-q", "-b", "main"],
|
||||
["git", "config", "user.email", "t@t"],
|
||||
["git", "config", "user.name", "t"],
|
||||
["git", "config", "core.autocrlf", "false"],
|
||||
):
|
||||
subprocess.run(cmd, cwd=path, check=True)
|
||||
(path / "main.py").write_text("print(1)\n")
|
||||
subprocess.run(["git", "add", "-A"], cwd=path, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", first_commit], cwd=path, check=True)
|
||||
return path
|
||||
|
||||
|
||||
class TestCommitAlwaysRebuilds(unittest.TestCase):
|
||||
"""Source-level contract pins for the commit-site semantics."""
|
||||
|
||||
@@ -105,18 +121,7 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-ws-"))
|
||||
try:
|
||||
repo = tmp / "proj"
|
||||
repo.mkdir()
|
||||
for cmd in (
|
||||
["git", "init", "-q", "-b", "main"],
|
||||
["git", "config", "user.email", "t@t"],
|
||||
["git", "config", "user.name", "t"],
|
||||
["git", "config", "core.autocrlf", "false"],
|
||||
):
|
||||
subprocess.run(cmd, cwd=repo, check=True)
|
||||
(repo / "main.py").write_text("print(1)\n")
|
||||
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", "init commit"], cwd=repo, check=True)
|
||||
repo = _init_repo(tmp / "proj", "init commit")
|
||||
|
||||
agent = _agent(
|
||||
load_soul_identity=False,
|
||||
@@ -145,8 +150,6 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
|
||||
# First build: pins the snapshot
|
||||
p1 = build_system_prompt(agent)
|
||||
self.assertIn("Workspace (snapshot at session start", p1)
|
||||
self.assertIsNotNone(agent._frozen_workspace_snapshot)
|
||||
self.assertEqual(agent._frozen_workspace_snapshot[0], str(repo))
|
||||
|
||||
# Now repo mutates (agent touched and committed new files)
|
||||
(repo / "new_file.py").write_text("print(2)\n")
|
||||
@@ -172,19 +175,8 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-cwd-"))
|
||||
try:
|
||||
repo1 = tmp / "r1"; repo1.mkdir()
|
||||
repo2 = tmp / "r2"; repo2.mkdir()
|
||||
for r in (repo1, repo2):
|
||||
for cmd in (
|
||||
["git", "init", "-q", "-b", "main"],
|
||||
["git", "config", "user.email", "t@t"],
|
||||
["git", "config", "user.name", "t"],
|
||||
["git", "config", "core.autocrlf", "false"],
|
||||
):
|
||||
subprocess.run(cmd, cwd=r, check=True)
|
||||
(r / "main.py").write_text("print(1)\n")
|
||||
subprocess.run(["git", "add", "-A"], cwd=r, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", f"init {r.name}"], cwd=r, check=True)
|
||||
repo1 = _init_repo(tmp / "r1", "init r1")
|
||||
repo2 = _init_repo(tmp / "r2", "init r2")
|
||||
|
||||
agent = _agent(
|
||||
load_soul_identity=False,
|
||||
@@ -217,11 +209,41 @@ class TestWorkspaceSnapshotPinnedAcrossCompaction(unittest.TestCase):
|
||||
patch("agent.system_prompt.resolve_context_cwd", return_value=repo2):
|
||||
p2 = build_system_prompt(agent)
|
||||
self.assertIn(f"init {repo2.name}", p2)
|
||||
self.assertEqual(agent._frozen_workspace_snapshot[0], str(repo2))
|
||||
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def test_session_boundary_drops_the_pin_so_a_new_session_resnapshots(self):
|
||||
"""A /new, /resume or /branch reuses the AIAgent; the next session must see the live repo."""
|
||||
import tempfile, shutil, subprocess
|
||||
from pathlib import Path
|
||||
from agent.system_prompt import build_system_prompt, invalidate_system_prompt
|
||||
from run_agent import AIAgent
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="test-pinned-boundary-"))
|
||||
try:
|
||||
repo = _init_repo(tmp / "proj", "init commit")
|
||||
agent = _agent(
|
||||
load_soul_identity=False, skip_context_files=True, 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,
|
||||
_frozen_workspace_snapshot=None, context_compressor=None, _session_db=None,
|
||||
_transition_context_engine_session=lambda **kw: None,
|
||||
)
|
||||
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=repo):
|
||||
build_system_prompt(agent)
|
||||
subprocess.run(["git", "commit", "-qm", "second commit", "--allow-empty"], cwd=repo, check=True)
|
||||
# The CLI session boundary (cli_session_mixin.new_session) on the same agent object.
|
||||
AIAgent.reset_session_state(agent)
|
||||
invalidate_system_prompt(agent)
|
||||
self.assertIn("second commit", build_system_prompt(agent))
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1154,11 +1154,16 @@ def _(rid, params: dict, session: dict) -> dict:
|
||||
"model": _metadata_mirror(session).get("model", "")})
|
||||
with session["history_lock"]:
|
||||
history = list(session.get("history", []))
|
||||
# Bind the session context: on the RPC thread the session cwd is unset, so the prompt build
|
||||
# inside would key its workspace pin on the backend's cwd and overwrite the session's pin.
|
||||
tokens = _set_session_context(session["session_key"])
|
||||
try:
|
||||
from agent.context_breakdown import compute_session_context_breakdown
|
||||
return _ok(rid, compute_session_context_breakdown(agent, history))
|
||||
except Exception as exc:
|
||||
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
|
||||
finally:
|
||||
_clear_session_context(tokens)
|
||||
|
||||
|
||||
# ── pet ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user