From 2f19068eb0f420b5fb783ac6cebf8831213b7c22 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:00:40 +0800 Subject: [PATCH] fix(session): clear file state at deletion boundary --- nanobot/agent/loop.py | 8 ++++++-- nanobot/sdk/clients.py | 1 - nanobot/session/manager.py | 10 +++++++++- tests/agent/test_session_delete.py | 11 +++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 67b7c7f04..38e45a5f6 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -377,11 +377,15 @@ class AgentLoop: self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills) self.sessions = session_manager or SessionManager(workspace) - self.sessions.set_file_cap_archiver(self.context.memory.raw_archive) - self.tools = tool_registry if tool_registry is not None else ToolRegistry() # One file-read/write tracker per logical session. The tool registry is # shared by this loop, so tools resolve the active state via contextvars. self._file_state_store = FileStateStore(max_sessions=SESSION_CACHE_MAX_SIZE) + # SessionManager owns every durable deletion entrypoint, including the + # WebUI and fork rollback paths. Observe that boundary once instead of + # duplicating cleanup in each consumer. + self.sessions.set_delete_observer(self._file_state_store.discard) + self.sessions.set_file_cap_archiver(self.context.memory.raw_archive) + self.tools = tool_registry if tool_registry is not None else ToolRegistry() self._exec_session_manager = ExecSessionManager() self.runner = AgentRunner() self.subagents = SubagentManager( diff --git a/nanobot/sdk/clients.py b/nanobot/sdk/clients.py index 52fb22ce8..afd975764 100644 --- a/nanobot/sdk/clients.py +++ b/nanobot/sdk/clients.py @@ -146,7 +146,6 @@ class SessionClient: def delete(self, session_key: str) -> bool: """Delete one session from disk and cache.""" - self._loop.discard_session_file_state(session_key) return self._loop.sessions.delete_session(session_key) def flush(self) -> int: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index fa46f8e3b..3a8e5f95d 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1523,6 +1523,7 @@ class SessionManager: self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary() self._max_cached_sessions = SESSION_CACHE_MAX_SIZE self._file_cap_archiver: Callable[..., None] | None = None + self._delete_observer: Callable[[str], None] | None = None def _remember(self, session: Session) -> None: """Keep recent sessions strongly cached without duplicating live objects.""" @@ -1552,6 +1553,10 @@ class SessionManager: """Archive unconsolidated overflow whenever a session is persisted.""" self._file_cap_archiver = archiver + def set_delete_observer(self, observer: Callable[[str], None]) -> None: + """Observe explicit session deletion for process-local state cleanup.""" + self._delete_observer = observer + @staticmethod def safe_key(key: str) -> str: """Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem.""" @@ -1684,7 +1689,10 @@ class SessionManager: def delete_session(self, key: str) -> bool: """Delete a persisted session and invalidate its cache entry.""" self.invalidate(key) - return self._store.delete(key) + deleted = self._store.delete(key) + if self._delete_observer is not None: + self._delete_observer(key) + return deleted def restore_sessions_to_workspace(self) -> SessionRestoreResult: """Restore session files to the pre-relocation path for an explicit rollback.""" diff --git a/tests/agent/test_session_delete.py b/tests/agent/test_session_delete.py index 97955fc1b..74b7b0a46 100644 --- a/tests/agent/test_session_delete.py +++ b/tests/agent/test_session_delete.py @@ -34,6 +34,17 @@ def test_delete_session_returns_false_when_missing(tmp_path: Path) -> None: assert sm.delete_session("nope:none") is False +def test_delete_session_notifies_process_local_state_observer(tmp_path: Path) -> None: + sm = _seed(tmp_path, "websocket:abc") + deleted_keys: list[str] = [] + sm.set_delete_observer(deleted_keys.append) + + assert sm.delete_session("websocket:abc") is True + assert sm.delete_session("websocket:missing") is False + + assert deleted_keys == ["websocket:abc", "websocket:missing"] + + def test_read_session_file_returns_metadata_and_messages(tmp_path: Path) -> None: sm = _seed(tmp_path, "telegram:abc") data = sm.read_session_file("telegram:abc")