mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 12:19:48 +02:00
[eric] backend: boot restore keeps session files on disk instead of unlinking them
This commit is contained in:
@@ -161,11 +161,10 @@ class SessionLifecycle(AgentManagerProtocol):
|
||||
"""Return paginated, optionally filtered summaries of sessions, live ones included."""
|
||||
# A malformed file (a list, a bare string) would blow up data.get and 500 the whole endpoint.
|
||||
all_data = [pair for pair in load_all_session_data() if isinstance(pair[1], dict)]
|
||||
# Restore deletes the file of every still-open session at boot, so the live ones exist ONLY in memory; without merging them your current chats simply are not in history.
|
||||
on_disk = {data.get("id", sid) for sid, data in all_data}
|
||||
# Live sessions usually also have a disk copy (boot restore keeps the file), but the disk copy lags the turn in flight; memory wins the dedupe because it is never staler.
|
||||
in_memory = set(self.sessions.keys())
|
||||
all_data = [pair for pair in all_data if pair[0] not in in_memory and pair[1].get("id") not in in_memory]
|
||||
for sid, session in self.sessions.items():
|
||||
if sid in on_disk:
|
||||
continue
|
||||
all_data.append((sid, {
|
||||
"id": sid,
|
||||
"name": session.name,
|
||||
@@ -234,10 +233,10 @@ class SessionLifecycle(AgentManagerProtocol):
|
||||
return list(self.sessions.values())
|
||||
# Memory first, then promote on-disk sessions for this dashboard, but ONLY ones the dashboard's layout still has a card for. A session keeps its dashboard_id when its card is deleted, so promoting by tag alone resurrected deleted chats on every reopen; the layout's cards are the real source of truth for what's on the board. Imported sessions ARE in the layout, so they still surface, and this bounds the disk read to once per session per run, like resume_session.
|
||||
result = [s for s in self.sessions.values() if s.dashboard_id == dashboard_id]
|
||||
seen = {s.id for s in result}
|
||||
card_ids = self.p_dashboard_card_ids(dashboard_id)
|
||||
for sid, data in load_all_session_data():
|
||||
if sid in seen or sid not in card_ids:
|
||||
# Skip anything already in memory (not just this dashboard's slice): promoting a stale disk copy over a live session would clobber its in-flight state.
|
||||
if sid in self.sessions or sid not in card_ids:
|
||||
continue
|
||||
if data.get("dashboard_id") != dashboard_id:
|
||||
continue
|
||||
|
||||
@@ -10,7 +10,6 @@ from typeguard import typechecked
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
from backend.apps.agents.manager.session.session_store import (
|
||||
delete_session_file,
|
||||
load_all_session_data,
|
||||
save_session,
|
||||
)
|
||||
@@ -85,8 +84,8 @@ class SessionPersistence(AgentManagerProtocol):
|
||||
session.status = "completed" if (p_last is not None and p_last.role == "assistant") else "stopped"
|
||||
session.pending_approvals = []
|
||||
apply_context_window(session)
|
||||
# The file stays on disk: unlinking here made RAM the only copy, so any non-graceful shutdown (updater SIGKILL, crash) destroyed the chat.
|
||||
self.sessions[session.id] = session
|
||||
delete_session_file(sid)
|
||||
restored += 1
|
||||
# One summary line, not one per session (startups with hundreds of sessions flooded the console).
|
||||
if restored:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Boot restore must never destroy the durable copy of a session.
|
||||
|
||||
restore_all_sessions used to unlink every open session's JSON file after loading
|
||||
it into memory, making RAM the ONLY copy; any non-graceful shutdown (updater
|
||||
SIGKILL, crash, power loss) then wiped the chat permanently. This pins the
|
||||
invariant: restoring an open session loads it into memory AND leaves its file on
|
||||
disk, while closed sessions stay on disk untouched and out of memory.
|
||||
|
||||
Run with: backend/.venv/bin/python -m pytest backend/tests/test_session_restore_durability.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from backend.apps.agents import agent_manager as am
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.manager.session.session_store import save_session
|
||||
|
||||
|
||||
def p_session_path(sessions_dir: str, session_id: str) -> str:
|
||||
return os.path.join(sessions_dir, f"{session_id}.json")
|
||||
|
||||
|
||||
def test_restore_keeps_open_session_file_on_disk(tmp_path, monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(am, "SESSIONS_DIR", str(tmp_path))
|
||||
mgr = am.AgentManager()
|
||||
session = AgentSession(name="open chat", model="sonnet")
|
||||
assert session.closed_at is None
|
||||
save_session(session.id, session.model_dump(mode="json"))
|
||||
|
||||
asyncio.run(mgr.restore_all_sessions())
|
||||
|
||||
assert session.id in mgr.sessions
|
||||
assert os.path.exists(p_session_path(str(tmp_path), session.id)), (
|
||||
"restore must not unlink the durable session file; memory-only sessions "
|
||||
"are lost on any non-graceful shutdown"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_skips_closed_sessions_but_keeps_their_files(tmp_path, monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(am, "SESSIONS_DIR", str(tmp_path))
|
||||
mgr = am.AgentManager()
|
||||
closed = AgentSession(name="closed chat", model="sonnet", closed_at=datetime.now())
|
||||
save_session(closed.id, closed.model_dump(mode="json"))
|
||||
|
||||
asyncio.run(mgr.restore_all_sessions())
|
||||
|
||||
assert closed.id not in mgr.sessions
|
||||
assert os.path.exists(p_session_path(str(tmp_path), closed.id))
|
||||
|
||||
|
||||
def test_restore_settles_stale_running_status(tmp_path, monkeypatch: MonkeyPatch) -> None:
|
||||
# The app died mid-turn: restore settles the status, and the file still survives.
|
||||
monkeypatch.setattr(am, "SESSIONS_DIR", str(tmp_path))
|
||||
mgr = am.AgentManager()
|
||||
session = AgentSession(name="mid turn", model="sonnet", status="running")
|
||||
save_session(session.id, session.model_dump(mode="json"))
|
||||
|
||||
asyncio.run(mgr.restore_all_sessions())
|
||||
|
||||
assert mgr.sessions[session.id].status in ("completed", "stopped")
|
||||
assert os.path.exists(p_session_path(str(tmp_path), session.id))
|
||||
Reference in New Issue
Block a user