Make chat persistence crash-safe

Write serialized chats to a same-directory temporary file, fsync it, and atomically replace chat.json so interrupted saves preserve the previous chat data. Add regression coverage for interrupted replacement and document the persistence guarantee.

Alessandro committed Jul 10, 2026 at 13:08 UTC 811556d24e3c017866a6a86e9e6dab9827de84c5
3 files changed +64 -4
helpers/persist_chat.py
+28 -4
@@ -1,12 +1,15 @@
1 +import json
2 +import os
3 +import tempfile
4 +import uuid
5 from collections import OrderedDict
6 from datetime import datetime
7 from typing import Any
4 -import uuid
8 +
9 from agent import Agent, AgentConfig, AgentContext, AgentContextType
10 from helpers import files, history
11 from helpers.litellm_transport import delete_stored_response_ids
12 from helpers.localization import Localization
9 -import json
13 from initialize import initialize_agent
14
15 from helpers.log import Log, LogItem
@@ -51,10 +54,9 @@ def save_tmp_chat(context: AgentContext):
54 return
55
56 path = _get_chat_file_path(context.id)
54 - files.make_dirs(path)
57 data = _serialize_context(context)
58 js = _safe_json_serialize(data, ensure_ascii=False)
57 - files.write_file(path, js)
59 + _write_atomic(path, js)
60 mark_chat_saved(context)
61
62
@@ -94,6 +96,28 @@ def _get_chat_file_path(ctxid: str):
96 return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME)
97
98
99 +def _write_atomic(path: str, content: str) -> None:
100 + directory = os.path.dirname(path)
101 + os.makedirs(directory, exist_ok=True)
102 + fd, tmp_path = tempfile.mkstemp(
103 + prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory
104 + )
105 + try:
106 + with os.fdopen(fd, "w", encoding="utf-8") as handle:
107 + handle.write(content.encode("utf-8", "replace").decode("utf-8"))
108 + handle.flush()
109 + os.fsync(handle.fileno())
110 + os.replace(tmp_path, path)
111 + directory_fd = os.open(directory, os.O_RDONLY)
112 + try:
113 + os.fsync(directory_fd)
114 + finally:
115 + os.close(directory_fd)
116 + finally:
117 + if os.path.exists(tmp_path):
118 + os.unlink(tmp_path)
119 +
120 +
121 def mark_chat_saved(context: AgentContext) -> None:
122 context.data[SAVED_CHAT_CONTEXT_DATA_KEY] = True
123
helpers/persist_chat.py.dox.md
+1
@@ -45,6 +45,7 @@
45 - Serialized chats store `agent_profile` both at the context level for the main chat and on each serialized agent so subordinate profiles survive server restart.
46 - Deserialization must rebuild each agent with its serialized profile when present, falling back to the context profile for older chat files.
47 - Chat loading skips directories that do not contain `chat.json`; malformed existing chat files still report load errors.
48 +- Chat saves write and fsync a same-directory temporary file, atomically replace `chat.json`, and fsync the directory so an interrupted save cannot truncate the previous chat.
49 - Contexts are marked with private `SAVED_CHAT_CONTEXT_DATA_KEY` only after a successful save or load from disk so snapshot code can detect deleted chat files without hiding fresh unsaved chats.
50
51 ## Key Concepts
tests/test_persist_chat_log_ids.py
+35
@@ -1,8 +1,11 @@
1 from __future__ import annotations
2
3 import json
4 +from pathlib import Path
5 from types import SimpleNamespace
6
7 +import pytest
8 +
9
10 def test_deserialize_log_preserves_item_id() -> None:
11 from helpers.log import Log
@@ -53,3 +56,35 @@ def test_load_tmp_chats_skips_directories_without_chat_json(monkeypatch, capsys)
56
57 assert persist_chat.load_tmp_chats() == ["valid"]
58 assert "Error loading chat" not in capsys.readouterr().out
59 +
60 +
61 +def test_save_tmp_chat_preserves_existing_file_until_atomic_replace(
62 + monkeypatch, tmp_path
63 +) -> None:
64 + from agent import AgentContextType
65 + from helpers import persist_chat
66 +
67 + path = tmp_path / "chat.json"
68 + path.write_text("previous", encoding="utf-8")
69 + context = SimpleNamespace(id="chat", type=AgentContextType.USER, data={})
70 +
71 + monkeypatch.setattr(persist_chat, "_get_chat_file_path", lambda _ctxid: str(path))
72 + monkeypatch.setattr(persist_chat, "_serialize_context", lambda _context: {"new": True})
73 + real_replace = persist_chat.os.replace
74 +
75 + def interrupted_replace(_source: str, destination: str) -> None:
76 + assert Path(destination).read_text(encoding="utf-8") == "previous"
77 + raise OSError("simulated interruption")
78 +
79 + monkeypatch.setattr(persist_chat.os, "replace", interrupted_replace)
80 + with pytest.raises(OSError, match="simulated interruption"):
81 + persist_chat.save_tmp_chat(context)
82 +
83 + assert path.read_text(encoding="utf-8") == "previous"
84 + assert list(tmp_path.glob("*.tmp")) == []
85 +
86 + monkeypatch.setattr(persist_chat.os, "replace", real_replace)
87 + persist_chat.save_tmp_chat(context)
88 +
89 + assert json.loads(path.read_text(encoding="utf-8")) == {"new": True}
90 + assert context.data[persist_chat.SAVED_CHAT_CONTEXT_DATA_KEY] is True