From 56a779c12878450c0bfead580daecee7c7b0fa97 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 19 Apr 2026 15:25:37 +0000 Subject: [PATCH] fix(session): repair read-only corrupt session paths --- nanobot/session/manager.py | 23 ++++++++++++++++ tests/agent/test_session_atomic.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index c264649f..4add4fd3 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -252,6 +252,16 @@ class SessionManager: logger.warning("Repair failed for session {}: {}", key, e) return None + @staticmethod + def _session_payload(session: Session) -> dict[str, Any]: + return { + "key": session.key, + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + "metadata": session.metadata, + "messages": session.messages, + } + def save(self, session: Session) -> None: """Save a session to disk atomically.""" path = self._get_session_path(session.key) @@ -335,6 +345,10 @@ class SessionManager: } except Exception as e: logger.warning("Failed to read session {}: {}", key, e) + repaired = self._repair(key) + if repaired is not None: + logger.info("Recovered read-only session view {} from corrupt file", key) + return self._session_payload(repaired) return None def list_sessions(self) -> list[dict[str, Any]]: @@ -347,6 +361,7 @@ class SessionManager: sessions = [] for path in self.sessions_dir.glob("*.jsonl"): + fallback_key = path.stem.replace("_", ":", 1) try: # Read just the metadata line with open(path, encoding="utf-8") as f: @@ -362,6 +377,14 @@ class SessionManager: "path": str(path) }) except Exception: + repaired = self._repair(fallback_key) + if repaired is not None: + sessions.append({ + "key": repaired.key, + "created_at": repaired.created_at.isoformat(), + "updated_at": repaired.updated_at.isoformat(), + "path": str(path) + }) continue return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True) diff --git a/tests/agent/test_session_atomic.py b/tests/agent/test_session_atomic.py index 4b84d8b6..4720c028 100644 --- a/tests/agent/test_session_atomic.py +++ b/tests/agent/test_session_atomic.py @@ -208,6 +208,49 @@ class TestRepairCorruptFile: assert session.last_consolidated == 5 assert isinstance(session.created_at, datetime) + def test_read_session_file_repairs_corrupt_jsonl(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:read-repair") + + self._write_corrupt_jsonl(path, [ + json.dumps({ + "_type": "metadata", + "key": "test:read-repair", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "metadata": {"source": "repair"}, + "last_consolidated": 0, + }), + json.dumps({"role": "user", "content": "survived"}), + '{"role": "assistant", "content": "partial...', + ]) + + payload = mgr.read_session_file("test:read-repair") + assert payload is not None + assert payload["key"] == "test:read-repair" + assert payload["metadata"] == {"source": "repair"} + assert payload["messages"] == [{"role": "user", "content": "survived"}] + + def test_list_sessions_keeps_repaired_corrupt_file(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:list-repair") + + self._write_corrupt_jsonl(path, [ + "NOT VALID JSON", + json.dumps({ + "_type": "metadata", + "key": "test:list-repair", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "metadata": {}, + "last_consolidated": 0, + }), + json.dumps({"role": "user", "content": "hello"}), + ]) + + sessions = mgr.list_sessions() + assert any(s["key"] == "test:list-repair" for s in sessions) + def test_get_or_create_returns_new_session_for_corrupt_file(self, tmp_path: Path): mgr = SessionManager(tmp_path) path = mgr._get_session_path("test:fallback")