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:
Corey Zumar
2026-08-03 17:44:34 -07:00
committed by GitHub
parent dabdd2a7b0
commit 2ce9c60bf5
6 changed files with 415 additions and 61 deletions
@@ -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",
+12 -2
View File
@@ -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()
+21 -30
View File
@@ -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
View File
@@ -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.