From 4b1e28354ce94b3444e4ffc88ec7a0743891919f Mon Sep 17 00:00:00 2001 From: Kai <300677314+kai-openswarm@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:52:49 -0700 Subject: [PATCH] agents: a launched chat is snapshotted at birth, so a backend crash cannot lose a chat the user has not typed into yet Until its first turn ended (the turn snapshot), the chat was closed, or the backend shut down gracefully (persist_all_sessions), a launched session lived only in memory. After a crash or SIGKILL the respawned backend had no file to promote into the dashboard's list; with the renderer's own memory gone too (app restart, window reload) the board came back without the card, and the debounced layout save persisted the loss. A normal quit kept the same chat. Reproduced on 1.7.7 with the packaged app: open a chat, kill the backend, let it respawn, reload -> cards []; with this change the card is back. Launch now writes the same snapshot the turn end writes, so a respawn finds the session (reconcile_on_startup marks it stopped, the card returns as the parked chat it was). One atomic JSON write per launch. Backend tests cover the file at birth and the respawned list; an e2e spec kills the packaged backend for real. Co-Authored-By: Claude Opus 5 --- backend/apps/agents/manager/AgentLaunch.py | 14 +++- .../test_launch_snapshots_session_at_birth.py | 69 ++++++++++++++++ ...parked-chat-survives-backend-crash.spec.ts | 80 +++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_launch_snapshots_session_at_birth.py create mode 100644 e2e/tests/parked-chat-survives-backend-crash.spec.ts diff --git a/backend/apps/agents/manager/AgentLaunch.py b/backend/apps/agents/manager/AgentLaunch.py index 3b5445563..83a7648ab 100644 --- a/backend/apps/agents/manager/AgentLaunch.py +++ b/backend/apps/agents/manager/AgentLaunch.py @@ -17,7 +17,7 @@ ) from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.settings.settings import load_settings -from backend.apps.agents.manager.session.session_store import load_session_data +from backend.apps.agents.manager.session.session_store import load_session_data, save_session from backend.apps.agents.manager.session.apply_context_window import apply_context_window from backend.apps.agents.manager.session.workspace_git import ( detect_git_identity, @@ -145,6 +145,18 @@ async def launch_agent(self, config: AgentConfig) -> AgentSession: apply_context_window(session, global_settings) self.sessions[session_id] = session + # Snapshot at birth. Until now a launched-but-quiet session lived only in memory until its + # first turn ended (the turn snapshot), the chat was closed, or the backend shut down + # gracefully (persist_all_sessions). A crash or SIGKILL in between left no file, so the + # respawned backend could not promote the session into its dashboard's list, the renderer + # treated that scoped list as authority, stripped the card, and the debounced layout save + # persisted the loss: the board forgot a chat the user had just opened. With the file on + # disk a respawn finds it (reconcile_on_startup marks it stopped, the card returns as the + # parked chat it was), which is exactly what a graceful shutdown already gave it. + try: + save_session(session_id, session.model_dump(mode="json")) + except Exception: + logger.warning(f"launch: could not snapshot session {session_id}", exc_info=True) await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, diff --git a/backend/tests/test_launch_snapshots_session_at_birth.py b/backend/tests/test_launch_snapshots_session_at_birth.py new file mode 100644 index 000000000..7c4ed9889 --- /dev/null +++ b/backend/tests/test_launch_snapshots_session_at_birth.py @@ -0,0 +1,69 @@ +"""A launched-but-quiet session survives a backend crash. + +Until its first turn ended (turn snapshot), the chat was closed, or the backend shut down +gracefully, a launched session lived only in memory. After a crash or SIGKILL the respawned +backend had no file to promote into the dashboard's list, the renderer treated that scoped list +as authority, stripped the card, and the debounced layout save persisted the loss (reproduced on +1.7.7 by killing the packaged backend after opening a chat). Launch now snapshots at birth, so a +respawn finds the session the same way it finds one that outlived a graceful shutdown. +""" + +import asyncio +import json +import os +from typing import Any + +import pytest + +import backend.config.paths as config_paths +from backend.apps.agents import agent_manager as agent_manager_module +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentConfig +from backend.apps.agents.core.ws_manager import ws_manager +from backend.apps.agents.manager.session.session_store import load_session_data + + +@pytest.fixture() +def isolated_stores(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> dict: + sessions = tmp_path / "sessions" + dashboards = tmp_path / "dashboards" + sessions.mkdir() + dashboards.mkdir() + monkeypatch.setattr(agent_manager_module, "SESSIONS_DIR", str(sessions)) + monkeypatch.setattr(config_paths, "DASHBOARDS_DIR", str(dashboards)) + # No socket, no analytics, no git in a temp cwd: the launch path's side channels stay quiet. + async def p_silent(*args: Any, **kwargs: Any) -> None: + return None + monkeypatch.setattr(ws_manager, "send_to_session", p_silent) + monkeypatch.setattr(agent_manager_module.agent_manager, "prewarm_client", p_silent, raising=False) + return {"sessions": str(sessions), "dashboards": str(dashboards), "cwd": str(tmp_path / "work")} + + +def p_launch(config: AgentConfig): + return asyncio.run(agent_manager.launch_agent(config)) + + +def test_a_fresh_launch_is_on_disk_before_any_turn(isolated_stores: dict) -> None: + session = p_launch(AgentConfig(name="parked", dashboard_id="d1", target_directory=isolated_stores["cwd"])) + try: + data = load_session_data(session.id) + assert data is not None, "launch must snapshot the session file at birth" + assert data["id"] == session.id + assert data["dashboard_id"] == "d1" + assert data["messages"] == [] + finally: + agent_manager.sessions.pop(session.id, None) + + +def test_a_respawned_backend_lists_the_parked_session_for_its_dashboard(isolated_stores: dict) -> None: + session = p_launch(AgentConfig(name="parked", dashboard_id="d1", target_directory=isolated_stores["cwd"])) + # The renderer's layout save already put the card on the board (that PUT is what made the wipe + # permanent before); the respawned backend has an empty session map. + with open(os.path.join(isolated_stores["dashboards"], "d1.json"), "w", encoding="utf-8") as f: + json.dump({"layout": {"cards": {session.id: {"session_id": session.id}}}}, f) + agent_manager.sessions.pop(session.id, None) + try: + listed = agent_manager.get_all_sessions(dashboard_id="d1") + assert [s.id for s in listed] == [session.id] + finally: + agent_manager.sessions.pop(session.id, None) diff --git a/e2e/tests/parked-chat-survives-backend-crash.spec.ts b/e2e/tests/parked-chat-survives-backend-crash.spec.ts new file mode 100644 index 000000000..0dea943ad --- /dev/null +++ b/e2e/tests/parked-chat-survives-backend-crash.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, ElectronApplication, Page } from '@playwright/test'; +import { execSync } from 'child_process'; +import { launchApp, waitForMainWindow } from '../helpers/launch'; + +// A chat you open and have not typed into yet must survive a backend crash the same way it survives +// a normal quit. Until launch snapshotted the session at birth it lived only in memory until its +// first turn ended, so a crash (here: the packaged backend is really killed; the app respawns it) +// followed by a fresh renderer left the respawned backend with nothing to promote into the +// dashboard's list, and the board came back without the card. No provider key needed. +test.describe.configure({ mode: 'serial' }); +test.describe('a parked chat survives a backend crash', () => { + let app: ElectronApplication; + let win: Page; + + test.beforeAll(async () => { + app = await launchApp(); + win = await waitForMainWindow(app); + }); + test.afterAll(async () => { await app?.close().catch(() => {}); }); + + const dashboardReady = () => win.waitForFunction(() => { + const store = (window as any).__OPENSWARM_STORE__; + if (!store || !/^#\/dashboard\//.test(window.location.hash)) return false; + const layout = store.getState().dashboardLayout; + return layout.initialized === true && layout.loading === false; + }, null, { timeout: 120_000 }); + + // The packaged backend is a child of the app: uvicorn on the port the renderer reports. + function backendPid(port: number): number | null { + try { + if (process.platform === 'win32') { + const out = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*uvicorn*backend.main:app*--port ${port}*' } | Select-Object -ExpandProperty ProcessId"`, { encoding: 'utf8', timeout: 20_000 }); + const pid = parseInt(out.trim().split(/\s+/)[0] || '', 10); + return Number.isFinite(pid) ? pid : null; + } + const out = execSync(`ps -axo pid=,command= | grep -F -- "uvicorn backend.main:app" | grep -F -- "--port ${port}" | grep -v grep`, { encoding: 'utf8' }); + const pid = parseInt(out.trim().split(/\s+/)[0] || '', 10); + return Number.isFinite(pid) ? pid : null; + } catch { return null; } + } + + const health = () => win.evaluate(async () => { + const port = (window as any).openswarm.getBackendPort(); + const host = window.location.hostname || 'localhost'; + try { return (await fetch(`http://${host}:${port}/api/health/check`, { cache: 'no-store' })).status; } catch { return 0; } + }); + + test('a chat opened before the crash is still on the board after the app comes back', async () => { + await dashboardReady(); + const gotIt = win.getByRole('button', { name: 'Got it' }); + if (await gotIt.count()) await gotIt.first().click({ timeout: 5000 }).catch(() => {}); + const port: number = await win.evaluate(() => (window as any).openswarm.getBackendPort()); + const pid = backendPid(port); + test.skip(pid === null, 'could not identify the packaged backend process on this host'); + + // Open a chat through the app's own launch route and reducer; do not send anything. + const sessionId: string = await win.evaluate(async () => { + const port = (window as any).openswarm.getBackendPort(); + const host = window.location.hostname || 'localhost'; + const dashboardId = window.location.hash.match(/^#\/dashboard\/([^/?#]+)/)![1]; + const res = await fetch(`http://${host}:${port}/api/agents/launch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Parked before the crash', dashboard_id: dashboardId }) }); + const data = await res.json(); + (window as any).__OPENSWARM_STORE__.dispatch({ type: 'agents/launchAgent/fulfilled', payload: data.session }); + return data.session.id; + }); + await expect(win.locator(`[data-select-type="agent-card"][data-select-id="${sessionId}"]`)).toBeVisible({ timeout: 15_000 }); + // Let the renderer's debounced layout save land the card on the persisted board. + await win.waitForTimeout(3000); + + // Crash the backend for real; the app respawns it (bounded, backoff). + process.kill(pid!, 'SIGKILL'); + await expect.poll(async () => (await health()) === 200 && backendPid(port) !== pid, { timeout: 120_000, intervals: [1000] }).toBe(true); + + // A fresh renderer has no memory of the chat: what comes back is what the backend can list. + await win.reload(); + await dashboardReady(); + await expect.poll(() => win.evaluate(() => Object.keys((window as any).__OPENSWARM_STORE__.getState().dashboardLayout.cards)), { timeout: 30_000 }).toContain(sessionId); + await expect(win.locator(`[data-select-type="agent-card"][data-select-id="${sessionId}"]`)).toBeVisible({ timeout: 15_000 }); + }); +});