Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion backend/apps/agents/manager/AgentLaunch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions backend/tests/test_launch_snapshots_session_at_birth.py
Original file line number Diff line number Diff line change
@@ -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)
80 changes: 80 additions & 0 deletions e2e/tests/parked-chat-survives-backend-crash.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});