From 8c199ffdda42eea9d25a38bbf9e79a4f9499d756 Mon Sep 17 00:00:00 2001 From: Saidheerajgollu <158853598+Saidheerajgollu@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:13:07 -0700 Subject: [PATCH] Tolerate a corrupt line when loading a conversation .jsonl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_read_jsonl` parsed every line with a bare `json.loads` inside a list comprehension, so a single malformed line raised `JSONDecodeError` and took the whole `load()` down. An append interrupted mid-write (process crash, full disk) leaves exactly that: one truncated trailing line — and from then on every surface that opens the session errors on load, with no way back short of hand-editing the file. The session is effectively bricked, including its recoverable history. Skip unparseable lines and keep the good messages. This matches how the rest of this module already treats JSON (the inline-blob and roots/grants loaders all swallow `JSONDecodeError` and fall back) — `_read_jsonl` was the one strict outlier on the hot session-load path. --- coworker/conversations.py | 20 +++++-- tests/test_conversation_jsonl_robustness.py | 62 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/test_conversation_jsonl_robustness.py diff --git a/coworker/conversations.py b/coworker/conversations.py index fd2131bf..674d2c42 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -111,11 +111,21 @@ class ConversationStore: path = self._file(sid) if not path.exists(): return None - return [ - json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() - if line.strip() - ] + # Tolerate a corrupt/truncated line rather than failing the whole load. An append + # interrupted mid-write (crash, disk full) leaves one malformed trailing line; a + # bare `json.loads` in a comprehension would raise JSONDecodeError and make load() + # throw every time thereafter — bricking that session on every surface that opens + # it. Skip the bad line(s) and keep the recoverable history. (Every other JSON read + # in this module is already tolerant; this one was the outlier.) + messages: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + messages.append(json.loads(line)) + except json.JSONDecodeError: + continue + return messages def _count(self, sid: str) -> int: path = self._file(sid) diff --git a/tests/test_conversation_jsonl_robustness.py b/tests/test_conversation_jsonl_robustness.py new file mode 100644 index 00000000..30a553d3 --- /dev/null +++ b/tests/test_conversation_jsonl_robustness.py @@ -0,0 +1,62 @@ +"""ConversationStore: a corrupt line in a .jsonl must not brick session load. + +An append interrupted mid-write (crash, full disk) leaves one malformed trailing line. +load() must skip it and return the recoverable history, not raise on every open. +""" + +from __future__ import annotations + +from coworker.conversations import ConversationStore +from coworker.sessions import SessionRecord + + +def _seed(store: ConversationStore, sid: str, n: int) -> None: + store.save( + SessionRecord( + session_id=sid, + workspace="/tmp", + model="m", + mode="interactive", + messages=[{"role": "user", "content": f"m{i}"} for i in range(n)], + ) + ) + + +def test_load_skips_a_corrupt_trailing_line(tmp_path): + store = ConversationStore(tmp_path / "state") + sid = "abc123def456" + _seed(store, sid, 2) + + # Simulate a torn write: append a truncated JSON line to the session's log. + jsonl = tmp_path / "state" / "conversations" / f"{sid}.jsonl" + with open(jsonl, "a", encoding="utf-8") as f: + f.write('{"role": "user", "content": "unterm\n') # no closing brace/quote + + loaded = store.load(sid) # must not raise + assert loaded is not None + # The two good messages survive; the corrupt line is dropped. + assert [m["content"] for m in loaded.messages] == ["m0", "m1"] + + +def test_load_skips_a_corrupt_middle_line(tmp_path): + store = ConversationStore(tmp_path / "state") + sid = "def456abc123" + jsonl = tmp_path / "state" / "conversations" / f"{sid}.jsonl" + jsonl.parent.mkdir(parents=True, exist_ok=True) + jsonl.write_text( + '{"role": "user", "content": "first"}\n' + "not json at all\n" + '{"role": "assistant", "content": "third"}\n', + encoding="utf-8", + ) + # Register the session in the index so load() reaches the .jsonl. + store._conn.execute( + "INSERT INTO sessions (session_id, workspace, model, mode, title, n_msgs) " + "VALUES (?, '/tmp', 'm', 'interactive', 't', 2)", + (sid,), + ) + store._conn.commit() + + loaded = store.load(sid) + assert loaded is not None + assert [m["content"] for m in loaded.messages] == ["first", "third"]