diff --git a/backend/apps/agents/manager/session/SessionPersistence.py b/backend/apps/agents/manager/session/SessionPersistence.py index f21ca50d..ad6f7373 100644 --- a/backend/apps/agents/manager/session/SessionPersistence.py +++ b/backend/apps/agents/manager/session/SessionPersistence.py @@ -10,7 +10,7 @@ from typing import Optional from typeguard import typechecked -from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.core.models import AgentSession, Message from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.agents.manager.session.session_store import ( load_all_session_data, @@ -49,6 +49,12 @@ def running_under_test() -> bool: from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol +SHUTDOWN_STOP_NOTE = ( + "This chat was still running when OpenSwarm's engine shut down, so it stopped here; that was not " + "your Stop. Send a message to continue from where it left off." +) + + class SessionPersistence(AgentManagerProtocol): @typechecked async def reconcile_on_startup(self) -> None: @@ -134,6 +140,10 @@ class SessionPersistence(AgentManagerProtocol): for session_id, session in list(self.sessions.items()): if session.status in ("running", "waiting_approval"): session.status = "stopped" + # Say who stopped it. A chat flushed as plain "stopped" reads exactly like the user's own + # Stop, and when something else killed the backend (an agent's pkill, 2026-09-01) the + # user's running work vanished with nothing saying why: silent loss, row 1. + session.messages.append(Message(role="system", content=SHUTDOWN_STOP_NOTE, branch_id=session.active_branch_id)) session.closed_at = None for req in list(session.pending_approvals): ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Server shutting down"}) diff --git a/backend/tests/test_shutdown_stop_note.py b/backend/tests/test_shutdown_stop_note.py new file mode 100644 index 00000000..af542800 --- /dev/null +++ b/backend/tests/test_shutdown_stop_note.py @@ -0,0 +1,38 @@ +"""A chat that was running when the backend shut down must say so, or the flush reads as the user's Stop. + +2026-09-01: an agent's `pkill -f uvicorn` SIGTERMed Eric's production backend twice; every running chat +was persisted as plain "stopped" and nothing in the transcript said why (row 1, silent work loss).""" +import asyncio +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.manager.session import SessionPersistence as sp + + +def p_session(status: str) -> AgentSession: + s = AgentSession(name="t", model="sonnet", status=status) + s.messages.append(Message(role="user", content="build the thing", branch_id="main")) + return s + + +def test_a_running_chat_gets_the_shutdown_note_and_stops(monkeypatch, tmp_path) -> None: + saved: dict = {} + monkeypatch.setattr(sp, "save_session", lambda sid, doc: saved.__setitem__(sid, doc)) + s = p_session("running") + agent_manager.sessions.clear(); agent_manager.sessions[s.id] = s + asyncio.run(agent_manager.persist_all_sessions()) + doc = saved[s.id] + assert doc["status"] == "stopped" + last = doc["messages"][-1] + assert last["role"] == "system", "the note is the platform speaking, never the model" + assert "not your Stop" in last["content"] and "Send a message to continue" in last["content"] + + +def test_a_settled_chat_is_flushed_untouched(monkeypatch) -> None: + saved: dict = {} + monkeypatch.setattr(sp, "save_session", lambda sid, doc: saved.__setitem__(sid, doc)) + s = p_session("completed") + agent_manager.sessions.clear(); agent_manager.sessions[s.id] = s + asyncio.run(agent_manager.persist_all_sessions()) + doc = saved[s.id] + assert doc["status"] == "completed" + assert doc["messages"][-1]["role"] == "user", "no note on a chat that was not running"