From 464f71b4883adaef6826fecb43184c0673c0ac12 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:46:20 +0800 Subject: [PATCH] fix(session): fall back to legacy paths in metadata reads Fixes #4940 --- nanobot/session/manager.py | 70 +++++++++++-------- .../test_session_list_repair_legacy.py | 38 +++++++++- 2 files changed, 78 insertions(+), 30 deletions(-) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 00135255..510b8af1 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -507,6 +507,39 @@ class SessionManager: return None return None + def _resolve_session_path(self, key: str, *, migrate: bool = False) -> Path | None: + """Resolve a session path, falling back to legacy storage locations.""" + path = self._get_session_path(key) + if path.exists(): + return path + + fallback_paths = [ + (self._get_legacy_lossy_path(key), "legacy lossy path"), + (self._get_legacy_session_path(key), "legacy path"), + ] + for fallback_path, description in fallback_paths: + if not fallback_path.exists(): + continue + stored_key = self._stored_key_for_path(fallback_path) + if stored_key and stored_key != key: + logger.info( + "Skipping session {} from {} because it belongs to {}", + key, + description, + stored_key, + ) + continue + if not migrate: + return fallback_path + try: + shutil.move(str(fallback_path), str(path)) + logger.info("Migrated session {} from {}", key, description) + except Exception: + logger.exception("Failed to migrate session {}", key) + return None + return path + return None + def get_or_create(self, key: str) -> Session: """ Get an existing session or create a new one. @@ -530,29 +563,8 @@ class SessionManager: def _load(self, key: str) -> Session | None: """Load a session from disk.""" - path = self._get_session_path(key) - if not path.exists(): - fallback_paths = [ - (self._get_legacy_lossy_path(key), "legacy lossy path"), - (self._get_legacy_session_path(key), "legacy path"), - ] - for fallback_path, description in fallback_paths: - if not fallback_path.exists(): - continue - stored_key = self._stored_key_for_path(fallback_path) - if stored_key and stored_key != key: - logger.info( - "Skipping migration for {} from {} because it belongs to {}", - key, - description, - stored_key, - ) - continue - shutil.move(str(fallback_path), str(path)) - logger.info("Migrated session {} from {}", key, description) - break - - if not path.exists(): + path = self._resolve_session_path(key, migrate=True) + if path is None: return None try: @@ -831,8 +843,8 @@ class SessionManager: Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or ``None`` when the session file does not exist or fails to parse. """ - path = self._get_session_path(key) - if not path.exists(): + path = self._resolve_session_path(key) + if path is None: return None try: messages: list[dict[str, Any]] = [] @@ -862,7 +874,7 @@ class SessionManager: } except _SESSION_DATA_ERRORS as e: logger.warning("Failed to read session {}: {}", key, e) - repaired = self._repair(key) + repaired = self._repair(key, path=path) if repaired is not None: logger.info("Recovered read-only session view {} from corrupt file", key) return self._session_payload(repaired) @@ -874,8 +886,8 @@ class SessionManager: This is used by WebUI routes that need session-level metadata but not the full conversation transcript. """ - path = self._get_session_path(key) - if not path.exists(): + path = self._resolve_session_path(key) + if path is None: return None try: with open(path, encoding="utf-8") as f: @@ -898,7 +910,7 @@ class SessionManager: return None except _SESSION_DATA_ERRORS as e: logger.warning("Failed to read session metadata {}: {}", key, e) - repaired = self._repair(key) + repaired = self._repair(key, path=path) if repaired is not None: logger.info("Recovered read-only session metadata {} from corrupt file", key) return { diff --git a/tests/session/test_session_list_repair_legacy.py b/tests/session/test_session_list_repair_legacy.py index 74118da5..0c590f24 100644 --- a/tests/session/test_session_list_repair_legacy.py +++ b/tests/session/test_session_list_repair_legacy.py @@ -1,4 +1,4 @@ -"""Reproduction test: list_sessions drops corrupt legacy-stem sessions during repair.""" +"""Regression tests for legacy-stem session handling.""" import json from datetime import datetime from pathlib import Path @@ -38,3 +38,39 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch) # actual legacy filename. The session is silently dropped. assert len(sessions) == 1, f"Expected 1 session, got {len(sessions)}" assert sessions[0]["key"] == "telegram:12345" + + +def test_read_session_methods_fall_back_to_legacy_lossy_stem( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.setattr( + "nanobot.session.manager.get_legacy_sessions_dir", + lambda: tmp_path / "legacy_sessions", + ) + manager = SessionManager(tmp_path / "workspace") + session_id = "123e4567-e89b-12d3-a456-426614174000" + key = f"websocket:{session_id}" + legacy_path = manager._get_legacy_lossy_path(key) + assert legacy_path.name == f"websocket_{session_id}.jsonl" + + metadata = { + "_type": "metadata", + "key": key, + "created_at": datetime(2025, 1, 1).isoformat(), + "updated_at": datetime(2025, 1, 1).isoformat(), + "metadata": { + "workspace_scope": "project", + "project_path": "/tmp/example-project", + }, + } + legacy_path.write_text(json.dumps(metadata) + "\n", encoding="utf-8") + + metadata_result = manager.read_session_metadata(key) + file_result = manager.read_session_file(key) + + assert metadata_result is not None + assert metadata_result["metadata"] == metadata["metadata"] + assert file_result is not None + assert file_result["metadata"] == metadata["metadata"] + assert file_result["messages"] == []