perf(sessions): stop and archive in parallel so archiving isn't gated on stop timeouts (#3783)
* perf(sessions): stop and archive in parallel so archiving isn't gated on stop timeouts Archiving a live session took 5-10s: the sidebar serialized stop -> archive, and the PATCH handler awaited its own best-effort stop (5s runner / 10s host teardown ceilings per running session) before flipping the flag — even though the archive proceeds regardless of the stop's outcome. Fire the client legs in parallel and detach the server-side stop into a retained background task; the stop still runs to completion, it just no longer holds the response. Signed-off-by: dbczumar <corey.zumar@databricks.com> * fix(sessions): let the server own the archive stop so it can't race the client's Review follow-ups on the parallel-archive change: - The client no longer sends its own stop_session alongside the archive PATCH. Two concurrent stops raced the same runner, and because the runner's stop handlers are not idempotent (kill_session raises once the pane is gone -> 503), the loser's failure aborted the client stop before it reached the host-runner teardown -- orphaning a host-spawned session's dedicated runner. Archive now sends one PATCH. - The server's detached stop carries the host-runner teardown that only the client stop used to do, so archiving still drops the runner's tunnel and flips runner_online. Bulk archive gains this too; it never sent a client stop. - The stop is spawned only after the archived flag commits. It ran ahead of later validations, so a PATCH rejected after that point (reserved label, runner_id permission) could stop a session it did not archive. Adds an e2e_ui browser test for the archive flow plus server coverage for the teardown and the rejected-PATCH case. Signed-off-by: dbczumar <corey.zumar@databricks.com> --------- Signed-off-by: dbczumar <corey.zumar@databricks.com>
This commit is contained in:
@@ -603,6 +603,104 @@ async def _best_effort_stop(
|
||||
await _stop(descendant_id)
|
||||
|
||||
|
||||
# Strong references to detached archive stops so the tasks can't be
|
||||
# garbage-collected mid-stop (asyncio only holds weak refs to tasks).
|
||||
_detached_stop_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
async def _archive_stop(
|
||||
session_id: str,
|
||||
conversation_store: ConversationStore,
|
||||
runner_router: Any,
|
||||
host_registry: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Stop an archived session and tear down its host-launched runner.
|
||||
|
||||
Archive carries the whole teardown: it is the one lifecycle action
|
||||
with no client-side stop, so both halves run here. Killing the pane
|
||||
alone leaves a host-spawned session's dedicated runner connected,
|
||||
which keeps ``runner_online`` true and hangs a later message on
|
||||
"working" against a dead pane — the failure
|
||||
:func:`_stop_session_host_runner` exists to prevent.
|
||||
|
||||
Every step is best-effort: a wedged, offline, or already-stopped
|
||||
runner must not leave the session un-archived.
|
||||
|
||||
:param session_id: Session/conversation identifier.
|
||||
:param conversation_store: Store for descendant and row lookups.
|
||||
:param runner_router: The ``RunnerRouter`` for runner-client
|
||||
resolution, or ``None`` in tests / in-process setups.
|
||||
:param host_registry: The ``HostRegistry`` tracking live host
|
||||
tunnels, or ``None`` when host support is not wired.
|
||||
"""
|
||||
# Resolve through the facade so a test's monkeypatch is honored here.
|
||||
from omnigent.server.routes import sessions as _facade
|
||||
|
||||
await _facade._best_effort_stop(session_id, conversation_store, runner_router)
|
||||
try:
|
||||
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.debug(
|
||||
"Archive host-runner teardown lookup failed for %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
if conv is None or not conv.host_id or not conv.runner_id:
|
||||
return
|
||||
# Mark the tunnel drop intentional BEFORE tearing it down so the relay
|
||||
# renders a quiet stopped state rather than "runner_disconnected".
|
||||
_intentional_stop_sessions.add(session_id)
|
||||
try:
|
||||
delivered = await _facade._stop_session_host_runner(
|
||||
session_id,
|
||||
conv.host_id,
|
||||
conv.runner_id,
|
||||
host_registry,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.debug(
|
||||
"Archive host-runner teardown failed for %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
delivered = False
|
||||
if not delivered:
|
||||
# No tunnel drop will follow, so the marker would outlive this stop
|
||||
# and later swallow a genuine runner_disconnected as a quiet idle.
|
||||
_intentional_stop_sessions.discard(session_id)
|
||||
|
||||
|
||||
def _spawn_archive_stop(
|
||||
session_id: str,
|
||||
conversation_store: ConversationStore,
|
||||
runner_router: Any,
|
||||
host_registry: Any = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run :func:`_archive_stop` as a retained background task.
|
||||
|
||||
Archiving needs the stop to *happen*, not to have happened before
|
||||
the response is written: awaiting it inline held the PATCH for the
|
||||
stop's per-runner timeouts (seconds per running session against a
|
||||
wedged or asleep runner) even though the archive proceeds
|
||||
regardless of the stop's outcome.
|
||||
|
||||
:param session_id: Session/conversation identifier.
|
||||
:param conversation_store: Store for descendant and row lookups.
|
||||
:param runner_router: The ``RunnerRouter`` for runner-client
|
||||
resolution, or ``None`` in tests / in-process setups.
|
||||
:param host_registry: The ``HostRegistry`` tracking live host
|
||||
tunnels, or ``None`` when host support is not wired.
|
||||
"""
|
||||
task = asyncio.create_task(
|
||||
_archive_stop(session_id, conversation_store, runner_router, host_registry)
|
||||
)
|
||||
_detached_stop_tasks.add(task)
|
||||
task.add_done_callback(_detached_stop_tasks.discard)
|
||||
|
||||
|
||||
def _labels_for_viewer(labels: dict[str, str], user_id: str | None) -> dict[str, str]:
|
||||
"""
|
||||
Collapse per-user pin keys to the canonical pin label for one viewer.
|
||||
@@ -7045,6 +7143,7 @@ async def _get_session_snapshot(
|
||||
|
||||
__all__ = [
|
||||
"_accumulate_session_usage",
|
||||
"_archive_stop",
|
||||
"_best_effort_stop",
|
||||
"_bind_and_launch_managed_runner",
|
||||
"_build_native_terminal_message_event",
|
||||
@@ -7053,6 +7152,7 @@ __all__ = [
|
||||
"_child_session_summaries_from_conversations",
|
||||
"_create_session_from_bundle",
|
||||
"_create_session_from_existing_agent",
|
||||
"_detached_stop_tasks",
|
||||
"_dispatch_session_event_to_runner",
|
||||
"_drive_terminal_resolved_elicitation",
|
||||
"_enrich_idle_status_with_subagent_output",
|
||||
@@ -7098,6 +7198,7 @@ __all__ = [
|
||||
"_run_managed_launch",
|
||||
"_run_managed_wake",
|
||||
"_schedule_deferred_elicitation_clear",
|
||||
"_spawn_archive_stop",
|
||||
"_spawn_native_approval_popup_forward",
|
||||
"_spawn_native_blocked_notice_forward",
|
||||
"_wait_for_host_bound_runner_client",
|
||||
|
||||
@@ -1521,8 +1521,6 @@ def register_core_routes(
|
||||
await _require_access(
|
||||
user_id, session_id, required_level, permission_store, conversation_store
|
||||
)
|
||||
if body.archived is True:
|
||||
await _best_effort_stop(session_id, conversation_store, runner_router)
|
||||
if body.runner_id is not None and permission_store is not None:
|
||||
if not check_session_access(
|
||||
user_id, session_id, LEVEL_OWNER, permission_store, conversation_store
|
||||
@@ -1761,6 +1759,18 @@ def register_core_routes(
|
||||
# Only on archive→true; unarchiving leaves it pruned (reads as seen).
|
||||
if body.archived is True:
|
||||
_prune_session_read_state(session_id)
|
||||
# Stop the session now that the flag is committed, so a request
|
||||
# rejected after this point can't leave a stopped-but-unarchived
|
||||
# session. Detached, not awaited: the response must not wait out
|
||||
# the stop's per-runner timeouts (seconds against a wedged or
|
||||
# asleep runner). Archive has no client-side stop, so this also
|
||||
# carries the host-runner teardown.
|
||||
_spawn_archive_stop(
|
||||
session_id,
|
||||
conversation_store,
|
||||
runner_router,
|
||||
getattr(request.app.state, "host_registry", None),
|
||||
)
|
||||
# Notify the runner of effort / model changes so harnesses
|
||||
# that can't re-read these from store at turn boundaries
|
||||
# (today: claude-native, whose ``claude`` binary has
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Browser e2e for archiving a session from the sidebar.
|
||||
|
||||
The row kebab's "Archive" item fires a single
|
||||
``PATCH /v1/sessions/{id}`` with ``archived: true``; the row shows a
|
||||
transient "Archiving…" status and then drops out of the default list
|
||||
(archived sessions live on the Settings page). The runner stop is the
|
||||
server's job, spawned in the background once the flag commits.
|
||||
|
||||
This asserts both halves of a real archive:
|
||||
|
||||
- The row leaves the sidebar and the store reports ``archived: true``,
|
||||
so the removal is durable rather than a cache splice a refetch would
|
||||
resurrect.
|
||||
- The client sends **no** ``stop_session`` event. Archiving used to
|
||||
send one and wait for it, which put the runner's stop timeouts
|
||||
(5s pane kill + 10s host-teardown ack) in front of the flag flip.
|
||||
Sending one *concurrently* would be worse: it would race the
|
||||
server's own stop against the same runner, and the loser gets a 503
|
||||
from the already-killed pane — which on a host-spawned session
|
||||
skips the host-runner teardown and orphans the runner.
|
||||
|
||||
The teardown half ("archiving stops its runner") isn't asserted here
|
||||
for the same reason ``test_sidebar_delete`` doesn't: the e2e harness
|
||||
binds a tunneled, non-host runner, so there is no host-launched runner
|
||||
to tear down. Server-side coverage for that lives in
|
||||
``tests/server/integration/test_sessions_archive.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Locator, Page, Request, expect
|
||||
|
||||
|
||||
def _row(page: Page, session_id: str) -> Locator:
|
||||
"""Locate the sidebar row (``<li>``) for *session_id* by its href."""
|
||||
return page.locator("li").filter(has=page.locator(f'a[href="/c/{session_id}"]'))
|
||||
|
||||
|
||||
def test_archive_session_removes_row_without_stop_event(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Archiving removes the row, archives the store row, and sends no stop.
|
||||
|
||||
Failure modes this catches:
|
||||
|
||||
- The PATCH never fires (or 4xxs) so the row lingers in the sidebar.
|
||||
- The row is hidden client-side but the store still reports
|
||||
``archived: false``, so a reload brings it back.
|
||||
- The client re-grows a ``stop_session`` leg, putting the runner's
|
||||
stop timeouts back on the archive path (when serialized) or
|
||||
racing the server's stop against the same runner (when parallel).
|
||||
|
||||
:param page: Playwright page fixture (fresh context per test).
|
||||
:param seeded_session: ``(base_url, session_id)`` for a pre-created
|
||||
runner-bound session.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
title = f"e2e-archive-{uuid.uuid4().hex[:8]}"
|
||||
resp = httpx.patch(
|
||||
f"{base_url}/v1/sessions/{session_id}",
|
||||
json={"title": title},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
stop_events: list[str] = []
|
||||
|
||||
def _record_stop(request: Request) -> None:
|
||||
"""Record any client-sent ``stop_session`` event."""
|
||||
if request.method != "POST" or not request.url.endswith("/events"):
|
||||
return
|
||||
if "stop_session" in (request.post_data or ""):
|
||||
stop_events.append(request.url)
|
||||
|
||||
page.on("request", _record_stop)
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
row = _row(page, session_id)
|
||||
expect(row).to_be_visible()
|
||||
|
||||
# Open the kebab → Archive. Hover first so the desktop
|
||||
# hover-revealed kebab trigger is interactable.
|
||||
row.hover()
|
||||
row.get_by_test_id("conversation-actions").click()
|
||||
page.get_by_test_id("archive-conversation").click()
|
||||
|
||||
# The row drops out of the default sidebar list: it first swaps to a
|
||||
# transient "Archiving…" status (no href) while the PATCH is in
|
||||
# flight, then unmounts entirely once the list refetches without it.
|
||||
expect(page.locator(f'a[href="/c/{session_id}"]')).to_have_count(0)
|
||||
|
||||
# And the archive is durable: the store row carries the flag, not
|
||||
# just the client cache. Poll — the list refetch that unmounts the
|
||||
# row can land marginally before the snapshot reflects it.
|
||||
deadline = time.monotonic() + 15.0
|
||||
archived = None
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = httpx.get(f"{base_url}/v1/sessions/{session_id}", timeout=10.0)
|
||||
if snapshot.status_code == 200:
|
||||
archived = snapshot.json()["archived"]
|
||||
if archived is True:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
assert archived is True, f"archived session should report archived=true, got {archived}"
|
||||
|
||||
# The whole point of the change: archiving is one PATCH. A client
|
||||
# stop here would either serialize the runner's timeouts ahead of
|
||||
# the flag flip or race the server's own stop against the runner.
|
||||
assert stop_events == [], f"archive must not send a stop_session event, sent {stop_events}"
|
||||
@@ -12,6 +12,7 @@ pipeline without subprocesses.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import io
|
||||
import tarfile
|
||||
@@ -106,6 +107,19 @@ async def test_unarchive_restores_session_to_default_listing(
|
||||
# ── Best-effort stop before archive ───────────────────────
|
||||
|
||||
|
||||
async def _drain_detached_stops() -> None:
|
||||
"""
|
||||
Wait out the archive PATCH's detached best-effort stop.
|
||||
|
||||
The handler spawns the stop as a retained background task and responds
|
||||
immediately, so assertions about the stop must let it finish first.
|
||||
"""
|
||||
await asyncio.gather(
|
||||
*list(sessions_module._detached_stop_tasks),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_archive_running_session_attempts_stop(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
@@ -121,6 +135,7 @@ async def test_archive_running_session_attempts_stop(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
mock_stop.assert_awaited_once()
|
||||
@@ -128,6 +143,52 @@ async def test_archive_running_session_attempts_stop(
|
||||
sessions_module._session_status_cache.pop(session_id, None)
|
||||
|
||||
|
||||
async def test_archive_does_not_block_on_slow_stop(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""
|
||||
The PATCH responds while the best-effort stop is still in flight.
|
||||
|
||||
The stop carries per-runner timeouts of several seconds against a
|
||||
wedged or asleep runner; awaiting it inline made every archive of a
|
||||
running session eat those timeouts before the flag flipped. The
|
||||
handler detaches the stop instead — the response must not wait for
|
||||
it, and the stop must still run.
|
||||
"""
|
||||
session = await create_test_session(client, name="archive-slow-stop")
|
||||
session_id = session["id"]
|
||||
|
||||
release = asyncio.Event()
|
||||
stopped: list[str] = []
|
||||
|
||||
async def _parked_stop(sid: str, *_args: object) -> None:
|
||||
stopped.append(sid)
|
||||
await release.wait()
|
||||
|
||||
sessions_module._session_status_cache[session_id] = "running"
|
||||
try:
|
||||
with patch.object(sessions_module, "_best_effort_stop", _parked_stop):
|
||||
# Would exhaust the timeout here if the handler awaited the
|
||||
# stop inline (the fake stop parks until released below).
|
||||
resp = await asyncio.wait_for(
|
||||
client.patch(f"/v1/sessions/{session_id}", json={"archived": True}),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
# The detached task starts on a subsequent loop pass and parks
|
||||
# on the release gate — the stop still runs.
|
||||
for _ in range(100):
|
||||
if stopped:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
assert stopped == [session_id]
|
||||
release.set()
|
||||
await _drain_detached_stops()
|
||||
finally:
|
||||
sessions_module._session_status_cache.pop(session_id, None)
|
||||
|
||||
|
||||
async def test_archive_idle_parent_stops_running_child(
|
||||
client: httpx.AsyncClient,
|
||||
db_uri: str,
|
||||
@@ -160,6 +221,7 @@ async def test_archive_idle_parent_stops_running_child(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
# The child must be the one stopped, not the (idle) parent.
|
||||
@@ -170,6 +232,87 @@ async def test_archive_idle_parent_stops_running_child(
|
||||
sessions_module._session_status_cache.pop(child.id, None)
|
||||
|
||||
|
||||
async def test_archive_tears_down_host_spawned_runner(
|
||||
client: httpx.AsyncClient,
|
||||
db_uri: str,
|
||||
) -> None:
|
||||
"""
|
||||
Archiving a host-spawned session tears down its dedicated runner.
|
||||
|
||||
Killing the pane alone leaves the host-launched runner connected, so
|
||||
``/health`` keeps reporting ``runner_online: true`` and a later
|
||||
message hangs on "working" against a dead pane. Archive is the one
|
||||
lifecycle action with no client-side stop, so the server carries the
|
||||
teardown itself rather than racing a second stop against the same
|
||||
runner.
|
||||
"""
|
||||
session = await create_test_session(client, name="archive-host-spawned")
|
||||
session_id = session["id"]
|
||||
|
||||
conv_store = SqlAlchemyConversationStore(db_uri)
|
||||
conv_store.set_host_id(
|
||||
session_id, "a1b2c3d4e5f61234567890abcdef0123", workspace="/tmp/archive-ws"
|
||||
)
|
||||
conv_store.set_runner_id(session_id, "b1b2c3d4e5f61234567890abcdef0123")
|
||||
|
||||
mock_teardown = AsyncMock(return_value=True)
|
||||
sessions_module._session_status_cache[session_id] = "running"
|
||||
try:
|
||||
with (
|
||||
patch.object(
|
||||
sessions_module, "_stop_session_via_runner", AsyncMock(return_value=True)
|
||||
),
|
||||
patch.object(sessions_module, "_stop_session_host_runner", mock_teardown),
|
||||
):
|
||||
resp = await client.patch(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
mock_teardown.assert_awaited_once()
|
||||
assert mock_teardown.await_args is not None
|
||||
assert mock_teardown.await_args.args[:3] == (
|
||||
session_id,
|
||||
"a1b2c3d4e5f61234567890abcdef0123",
|
||||
"b1b2c3d4e5f61234567890abcdef0123",
|
||||
)
|
||||
finally:
|
||||
sessions_module._session_status_cache.pop(session_id, None)
|
||||
sessions_module._intentional_stop_sessions.discard(session_id)
|
||||
|
||||
|
||||
async def test_failed_archive_leaves_session_running(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""
|
||||
A rejected archive PATCH must not stop the session.
|
||||
|
||||
The stop is spawned only after the archived flag commits, so a
|
||||
request that fails a later validation (here a server-derived
|
||||
per-user pin key) leaves the session both unarchived and untouched.
|
||||
"""
|
||||
session = await create_test_session(client, name="archive-rejected")
|
||||
session_id = session["id"]
|
||||
|
||||
mock_stop = AsyncMock(return_value=True)
|
||||
sessions_module._session_status_cache[session_id] = "running"
|
||||
try:
|
||||
with patch.object(sessions_module, "_stop_session_via_runner", mock_stop):
|
||||
resp = await client.patch(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True, "labels": {"omnigent.pinned.someone": "1"}},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code >= 400
|
||||
mock_stop.assert_not_awaited()
|
||||
listed = await client.get(f"/v1/sessions/{session_id}")
|
||||
assert listed.json()["archived"] is False
|
||||
finally:
|
||||
sessions_module._session_status_cache.pop(session_id, None)
|
||||
|
||||
|
||||
async def test_archive_proceeds_when_stop_fails(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
@@ -185,6 +328,7 @@ async def test_archive_proceeds_when_stop_fails(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
finally:
|
||||
@@ -220,6 +364,7 @@ async def test_archive_proceeds_when_child_lookup_fails(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
finally:
|
||||
@@ -239,6 +384,7 @@ async def test_archive_idle_session(
|
||||
f"/v1/sessions/{session_id}",
|
||||
json={"archived": True},
|
||||
)
|
||||
await _drain_detached_stops()
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
mock_stop.assert_not_awaited()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Tests for the archive flow in the sidebar. Contract: archiving runs
|
||||
// stop→archive — it stops the runner first (best-effort resource hygiene,
|
||||
// NOT the user-facing Stop action, which is the kebab's own "Stop session"
|
||||
// item covered by Sidebar.stop.test.tsx) and then fires
|
||||
// `useArchiveConversation` with `archived: true` (with an onSettled that
|
||||
// clears the "Archiving…" status row). Unarchiving flips the flag back with
|
||||
// no stop and no status row. See ConversationRow.runArchive in Sidebar.tsx.
|
||||
// Tests for the archive flow in the sidebar. Contract: archiving sends ONLY
|
||||
// the archive PATCH (`archived: true`, with an onSettled that clears the
|
||||
// "Archiving…" status row). The runner stop is the server's job once the
|
||||
// flag commits — a client stop would race the server's against the same
|
||||
// runner, and it would also put the runner's stop timeouts in front of the
|
||||
// flag flip. The kebab's user-facing "Stop session" action is a separate
|
||||
// affordance covered by Sidebar.stop.test.tsx. Unarchiving flips the flag
|
||||
// back with no status row. See ConversationRow.runArchive in Sidebar.tsx.
|
||||
//
|
||||
// Archived sessions are no longer listed in the sidebar (they moved to the
|
||||
// Settings page), so unarchiving is covered by SettingsPage.test.tsx; this
|
||||
// file exercises the archive (stop→archive) path from a row's kebab.
|
||||
// file exercises the archive path from a row's kebab.
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
@@ -132,21 +133,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("archive flow", () => {
|
||||
it("archives via stop→archive: stops the runner first, then flips the flag", () => {
|
||||
it("archives with a single PATCH and no client-side stop", () => {
|
||||
mockConversations([CONV]);
|
||||
renderSidebar();
|
||||
clickArchive();
|
||||
|
||||
// Stop fires first (best-effort runner teardown) with the row's id.
|
||||
expect(mocks.stop.mutate).toHaveBeenCalledTimes(1);
|
||||
const stopArgs = mocks.stop.mutate.mock.calls[0];
|
||||
expect(stopArgs[0]).toBe("conv_1");
|
||||
// Archive waits for the stop to settle — it hasn't fired yet.
|
||||
expect(mocks.archive.mutate).not.toHaveBeenCalled();
|
||||
|
||||
// Settle the stop → archive fires with archived:true + an onSettled
|
||||
// that clears the "Archiving…" flag.
|
||||
act(() => (stopArgs[1] as { onSettled: () => void }).onSettled());
|
||||
expect(mocks.archive.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.archive.mutate).toHaveBeenCalledWith(
|
||||
{ id: "conv_1", archived: true },
|
||||
@@ -155,6 +146,9 @@ describe("archive flow", () => {
|
||||
onSettled: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// The server owns the stop. A client stop here would race it against
|
||||
// the same runner and put its timeouts in front of the flag flip.
|
||||
expect(mocks.stop.mutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts a pointer to Settings once the archive succeeds", () => {
|
||||
@@ -162,9 +156,7 @@ describe("archive flow", () => {
|
||||
renderSidebar();
|
||||
clickArchive();
|
||||
|
||||
// Drive stop→archive to the success callback.
|
||||
const stopArgs = mocks.stop.mutate.mock.calls[0];
|
||||
act(() => (stopArgs[1] as { onSettled: () => void }).onSettled());
|
||||
// Drive the archive to its success callback.
|
||||
const archiveArgs = mocks.archive.mutate.mock.calls[0];
|
||||
act(() => (archiveArgs[1] as { onSuccess: () => void }).onSuccess());
|
||||
|
||||
@@ -181,9 +173,9 @@ describe("archive flow", () => {
|
||||
// covered by SettingsPage.test.tsx instead.
|
||||
|
||||
it("shows an 'Archiving…' status row while the archive is in flight", () => {
|
||||
// The stop mock never settles (vi.fn() stub), so the row stays in its
|
||||
// in-flight state — the window the user sees. Without the indicator the
|
||||
// row would look idle while the stop→archive ran.
|
||||
// The archive mock never settles (vi.fn() stub), so the row stays in
|
||||
// its in-flight state — the window the user sees. Without the indicator
|
||||
// the row would look idle while the archive ran.
|
||||
mockConversations([CONV]);
|
||||
renderSidebar();
|
||||
clickArchive();
|
||||
@@ -195,17 +187,16 @@ describe("archive flow", () => {
|
||||
expect(screen.queryByRole("link", { name: /My Session/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the 'Archiving…' row once stop→archive settles", () => {
|
||||
it("clears the 'Archiving…' row once the archive settles", () => {
|
||||
mockConversations([CONV]);
|
||||
renderSidebar();
|
||||
clickArchive();
|
||||
|
||||
expect(screen.getByTestId("conversation-archiving")).toBeInTheDocument();
|
||||
|
||||
// Settle the stop → archive fires; then settle the archive → the row
|
||||
// returns to its interactive state (onSettled runs on success or error).
|
||||
const stopOnSettled = mocks.stop.mutate.mock.calls[0][1].onSettled as () => void;
|
||||
act(() => stopOnSettled());
|
||||
// Settle the archive → the row returns to its interactive state
|
||||
// (onSettled runs on success or error). The stop's settle is irrelevant
|
||||
// to the row state.
|
||||
const archiveOnSettled = mocks.archive.mutate.mock.calls[0][1].onSettled as () => void;
|
||||
act(() => archiveOnSettled());
|
||||
|
||||
|
||||
+21
-29
@@ -2862,14 +2862,9 @@ function ConversationRow({
|
||||
const del = useStopAndDeleteConversation();
|
||||
const archive = useArchiveConversation();
|
||||
const moveToProject = useMoveToProject();
|
||||
// Archive stops the runner first (resource hygiene): a hidden session
|
||||
// shouldn't keep a runner alive. This is NOT the user-facing Stop action
|
||||
// (the kebab's "Stop session" item below, backed by its own mutation) —
|
||||
// it's an internal step of archiving. Unarchive + a message relaunches
|
||||
// on the live host under the non-sticky-stop model.
|
||||
const stopForArchive = useStopSession();
|
||||
// The kebab's user-facing "Stop session" action — separate mutation
|
||||
// instance so its pending/error state can't bleed into archiving's.
|
||||
// The kebab's user-facing "Stop session" action. Archiving does NOT go
|
||||
// through here — the server stops the session itself once the archived
|
||||
// flag commits, so a hidden session never keeps a runner alive.
|
||||
const stopSession = useStopSession();
|
||||
const isArchived = conversation.archived === true;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -3067,8 +3062,9 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
// Archiving runs stop→archive (see runArchive); show a status row for
|
||||
// the whole span instead of leaving the row looking idle. On success
|
||||
// Archiving is a single PATCH (see runArchive); show a
|
||||
// status row for the span instead of leaving the row looking idle. On
|
||||
// success
|
||||
// the list refetches and the row drops out of the default view (or
|
||||
// flips to its archived state under "Show archived"); on failure the
|
||||
// flag clears and the interactive row returns so the user can retry.
|
||||
@@ -3110,26 +3106,22 @@ function ConversationRow({
|
||||
archive.mutate({ id: conversation.id, archived: false });
|
||||
return;
|
||||
}
|
||||
// Archiving runs stop→archive: stop the runner first (best-effort) so a
|
||||
// hidden session doesn't leave a runner orphaned, then flip the flag.
|
||||
// Show "Archiving…" for the whole span; cleared on the archive's settle
|
||||
// (success → row leaves the default list or shows archived; failure →
|
||||
// interactive row returns for a retry). The stop is best-effort — an
|
||||
// already-offline / wedged runner must not block the archive.
|
||||
// Archiving sends only the PATCH: the server stops the session (and
|
||||
// tears down a host-spawned runner) in the background once the flag is
|
||||
// committed. Sending a client stop too would race that one against the
|
||||
// same runner, and the loser gets a 503 from the already-killed pane.
|
||||
// "Archiving…" shows until the archive settles (success → row leaves
|
||||
// the default list; failure → interactive row returns for a retry).
|
||||
setIsArchiving(true);
|
||||
stopForArchive.mutate(conversation.id, {
|
||||
onSettled: () => {
|
||||
archive.mutate(
|
||||
{ id: conversation.id, archived: true },
|
||||
{
|
||||
// Point the user at where the session went — it's no longer in
|
||||
// the sidebar list, so surface its new home in Settings.
|
||||
onSuccess: showArchivedToast,
|
||||
onSettled: () => setIsArchiving(false),
|
||||
},
|
||||
);
|
||||
archive.mutate(
|
||||
{ id: conversation.id, archived: true },
|
||||
{
|
||||
// Point the user at where the session went — it's no longer in
|
||||
// the sidebar list, so surface its new home in Settings.
|
||||
onSuccess: showArchivedToast,
|
||||
onSettled: () => setIsArchiving(false),
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
// Shared by the kebab dropdown and the right-click context menu so the two
|
||||
@@ -3649,7 +3641,7 @@ function DeletingRow({
|
||||
|
||||
/**
|
||||
* In-flight status row shown while a session is being archived (the
|
||||
* stop→archive sequence in ConversationRow.runArchive). Mirrors the
|
||||
* archive PATCH in ConversationRow.runArchive). Mirrors the
|
||||
* non-error arm of {@link DeletingRow}; archive failures fall back to
|
||||
* the interactive row rather than a persistent error state, so there's
|
||||
* no retry/dismiss affordance here.
|
||||
|
||||
Reference in New Issue
Block a user