From e86b5d09137ff75d1bdd3c031d1efca2da684cbf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 14:23:19 -0400 Subject: [PATCH 1/5] Release route holds after opening services and attaching agents --- src/toad/acp/agent_process.py | 3 --- src/toad/comms_root.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/toad/acp/agent_process.py b/src/toad/acp/agent_process.py index 54c7b624c..e1ce7831a 100644 --- a/src/toad/acp/agent_process.py +++ b/src/toad/acp/agent_process.py @@ -37,7 +37,6 @@ def __init__(self, agent) -> None: self.connection: ClientSideConnection | None = None self.runner = None self.retirement = None - self.cleanup = ExitStack() self.sessions = set() self.initialization = None self.command = None @@ -92,7 +91,6 @@ async def start(self, agent, processes: tuple[AgentProcess, ...]=()): connection.sessions.add(agent.session) agent.controller.replace_terminal_session() connection.runner = asyncio.create_task(connection.run()) - connection.cleanup.enter_context(acquisition.pop_all()) return connection async def prompt(self, agent, content, metadata: dict[str, JsonValue], require: Callable[[], None]): @@ -243,7 +241,6 @@ async def _retire(self) -> None: await self.process.stop() self.process = None self.sessions.clear() - self.cleanup.close() async def detach(self, session: AgentSession): async with self.attachment_lock: diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index f67a9affd..97fb9cd50 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -334,7 +334,6 @@ def __init__(self, app: ToadApp) -> None: self.revision: ReadState = NoRevision() self.route_task: asyncio.Task[None] | None = None self.timer = None - self.cleanup = ExitStack() self.opening = threading.RLock() self.preparation = app.preparation self.retiring: set[asyncio.Task[None]] = set() @@ -380,7 +379,6 @@ def open_selected(self, selected: RouteSelection) -> Observing: observed = Observing( selected, service, SidebarModel(self.app.call_next), ObservationProcess(service.route), self.app.running_loop, access=self) - self.cleanup.enter_context(acquisition.pop_all()) observed.subscriptions.callback(observed.retire, self) self.observation = observed observed.loop.call_soon_threadsafe(observed.attach, self, previous) @@ -586,7 +584,6 @@ async def stop_observing(self) -> None: async def close(self) -> None: """Retire the observation once the views it served have closed.""" async with AsyncResourceRelease() as release: - release.callback(self.cleanup.close) try: with ResourceRelease() as observation: observation.callback(lambda: self.observation.subscriptions.close()) From 118a06e26fa0f852f335a3f58ee6a761826fd87d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:33:37 -0400 Subject: [PATCH 2/5] Store ordered open-tab rows beside saved sessions --- src/toad/db.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/toad/db.py b/src/toad/db.py index f6520687b..858d74a72 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -52,6 +52,12 @@ class Session(ToadTables, TypedTable, declared_name="sessions"): meta_json: SessionMeta = field(default_factory=SessionMeta) +@dataclass(frozen=True) +class OpenTabRow(ToadTables, TypedTable, declared_name="open_tabs"): + session_id: Annotated[int, Column(primary_key=True)] + position: int + + @dataclass(frozen=True) class ModelHistory(ToadTables, TypedTable, declared_name="model_history"): agent_identity: Annotated[str, Column(primary_key=True)] @@ -90,6 +96,23 @@ def transaction(): return await asyncio.to_thread(transaction) + async def tab_write(self, session_id: int, position: int) -> None: + def write(db): + OpenTabRow.delete(db, where="{session_id} = ?", parameters=(session_id,)) + OpenTabRow(session_id, position).insert(db) + + await self._transaction(write) + + async def tab_delete(self, session_id: int) -> None: + await self._transaction(lambda db: OpenTabRow.delete( + db, where="{session_id} = ?", parameters=(session_id,), + )) + + async def tab_list(self) -> list[OpenTabRow]: + return await self._transaction(lambda db: OpenTabRow.select( + db, order_by=("position", "session_id"), + )) + async def record_model_usage( self, agent_identity: str, model_id: str, *, last_used: int | None = None, ) -> None: From db80a1675946546a9718324994e1e294a23594bb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:39:02 -0400 Subject: [PATCH 3/5] Revert "Store ordered open-tab rows beside saved sessions" This reverts commit 118a06e26fa0f852f335a3f58ee6a761826fd87d. --- src/toad/db.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/toad/db.py b/src/toad/db.py index 858d74a72..f6520687b 100644 --- a/src/toad/db.py +++ b/src/toad/db.py @@ -52,12 +52,6 @@ class Session(ToadTables, TypedTable, declared_name="sessions"): meta_json: SessionMeta = field(default_factory=SessionMeta) -@dataclass(frozen=True) -class OpenTabRow(ToadTables, TypedTable, declared_name="open_tabs"): - session_id: Annotated[int, Column(primary_key=True)] - position: int - - @dataclass(frozen=True) class ModelHistory(ToadTables, TypedTable, declared_name="model_history"): agent_identity: Annotated[str, Column(primary_key=True)] @@ -96,23 +90,6 @@ def transaction(): return await asyncio.to_thread(transaction) - async def tab_write(self, session_id: int, position: int) -> None: - def write(db): - OpenTabRow.delete(db, where="{session_id} = ?", parameters=(session_id,)) - OpenTabRow(session_id, position).insert(db) - - await self._transaction(write) - - async def tab_delete(self, session_id: int) -> None: - await self._transaction(lambda db: OpenTabRow.delete( - db, where="{session_id} = ?", parameters=(session_id,), - )) - - async def tab_list(self) -> list[OpenTabRow]: - return await self._transaction(lambda db: OpenTabRow.select( - db, order_by=("position", "session_id"), - )) - async def record_model_usage( self, agent_identity: str, model_id: str, *, last_used: int | None = None, ) -> None: From bc5160908139677a78305ca0b8818e7c57096fc0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:31:39 -0400 Subject: [PATCH 4/5] Finish a viewer on the runtime its selected root records --- src/toad/app.py | 27 ++++++++++++++++++++--- src/toad/application_lifetime.py | 27 +++++++++++++++++++++++ src/toad/cli.py | 2 +- src/toad/comms_root.py | 37 ++++++++++++++++++-------------- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index 12b14e7dd..f7b1d10fe 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -17,9 +17,10 @@ from functools import cached_property, partial from pathlib import Path from time import monotonic -from typing import TYPE_CHECKING, Callable, TypeVar, cast +from typing import TYPE_CHECKING, Callable, Never, TypeVar, cast from agent_comms.acp_extension import TranscriptChangedUpdate +from rich.console import RenderableType from textual._time import after_input from textual import events, on, work from textual.app import App @@ -58,10 +59,11 @@ from toad.textual_colours import ThemedApp from toad.application_actions import ApplicationCommands from toad.widget_actions import ApplicationAction, DeclaredWidgetActions -from toad.application_lifetime import ApplicationLifetime +from toad.application_lifetime import ApplicationLifetime, Ended, Succeeded, TerminalOutcome from toad.resource_release import AsyncResourceRelease if TYPE_CHECKING: + from agent_comms.launch_runtime import LaunchRuntime from toad.db import DB from toad.render_backend import RenderTask from toad.screens.main import MainScreen @@ -127,7 +129,7 @@ def __new__(cls, minimum_width: int, side_panel_width: Scalar | None): WIDE = (100, Scalar(50, Unit.PERCENT, Unit.WIDTH)) -class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App, inherit_bindings=False): +class ToadApp(CoreEventReceiver, DeclaredWidgetActions, WorkspaceSessionShutdown, ThemedApp, App[TerminalOutcome], inherit_bindings=False): """The top level app.""" CSS_PATH = ["toad.tcss", "screens/comms.tcss"] @@ -193,9 +195,28 @@ def __init__( from toad.file_kind import FileKind FileKind.prepare_terminal() super().__init__() + self._return_value: TerminalOutcome = Ended(self.application.version) self.session_navigation.bind_events() self.project_dir = Path(project_dir or "./").expanduser().resolve() + @property + def return_value(self) -> TerminalOutcome: + return self._return_value + + def exit( + self, result: TerminalOutcome | None = None, return_code: int = 0, + message: RenderableType | None = None, + ) -> None: + """Textual's omitted result is a normal terminal ending.""" + super().exit( + Ended(self.application.version) if result is None else result, + return_code=return_code, message=message, + ) + + def succeed_onto(self, runtime: LaunchRuntime) -> Never: + self.call_from_thread(self.exit, Succeeded(runtime)) + raise asyncio.CancelledError + async def _close_all(self) -> None: # View release can fail. The application still owns its observation diff --git a/src/toad/application_lifetime.py b/src/toad/application_lifetime.py index 9f93baad9..371c06336 100644 --- a/src/toad/application_lifetime.py +++ b/src/toad/application_lifetime.py @@ -1,6 +1,9 @@ """Application startup, graceful exit and optional reporting own their lifetime.""" from __future__ import annotations +import os +import sys +from abc import ABC, abstractmethod from datetime import datetime, timezone from functools import cached_property, partial from importlib import import_module @@ -15,9 +18,33 @@ if TYPE_CHECKING: from ssl import SSLContext + from agent_comms.launch_runtime import LaunchRuntime from toad.app import ToadApp +class TerminalOutcome(ABC): + @abstractmethod + def finish(self) -> None: ... + + +@dataclass(frozen=True) +class Ended(TerminalOutcome): + version: VersionMonitor + + def finish(self) -> None: + self.version.status.print_notice() + + +@dataclass(frozen=True) +class Succeeded(TerminalOutcome): + runtime: LaunchRuntime + + def finish(self) -> None: + self.runtime.require_available() + executable = str(self.runtime.interpreter.with_name("toad")) + os.execve(executable, [executable, *sys.argv[1:]], self.runtime.environment(os.environ)) + + class UsageReports: """All producers use one opt-in policy, identity and external HTTP boundary.""" diff --git a/src/toad/cli.py b/src/toad/cli.py index b643195b1..a450f1935 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -37,7 +37,7 @@ def run_terminal(app: ToadApp) -> None: persist_terminal_failure(app._exception) if app._exception is not None: raise SystemExit(app.return_code or 1) - app.application.version.status.print_notice() + app.return_value.finish() def check_directory(path: str) -> None: diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 97fb9cd50..75198f65a 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -22,13 +22,16 @@ from weakref import WeakKeyDictionary, WeakSet from agent_comms.child_process import join_retirement +from agent_comms.comms import Comms from agent_comms.coordination_errors import StaleRevision from agent_comms.display_references import PendingReferences, UnavailableReferences from agent_comms.errors import RouteChangedError +from agent_comms.launch_runtime import LaunchRuntime from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.presentation import MessageNotification, NoRevision, ReadState from agent_comms.revision_views import ExternalTranscriptsView, StatusView from agent_comms.route_selection import RouteSelection, run_selected_write +from agent_comms.root_runtime import RootRuntime from agent_comms.ui_model.observation import ( NotificationsObserved, NotificationsUnreadable, @@ -65,7 +68,6 @@ from toad.resource_release import AsyncResourceRelease, ResourceRelease if TYPE_CHECKING: - from agent_comms.comms import Comms from agent_comms.display_order import DisplayOrder from agent_comms.display_references import ReferenceContext from agent_comms.thread_identity import ThreadIncarnation @@ -368,21 +370,24 @@ def require(self, selected: RouteSelection) -> Observing: return self.observation.require(self, selected) def open_selected(self, selected: RouteSelection) -> Observing: - from agent_comms.comms import Comms - - previous = self.observation - with ExitStack() as acquisition: - acquisition.enter_context(selected.route.hold_client_route()) - service = Comms(selected.route) - if service.root.resolve() != selected.root or RouteSelection.capture() != selected: - raise RouteChangedError("Comms route changed while opening the service") - observed = Observing( - selected, service, SidebarModel(self.app.call_next), ObservationProcess(service.route), - self.app.running_loop, access=self) - observed.subscriptions.callback(observed.retire, self) - self.observation = observed - observed.loop.call_soon_threadsafe(observed.attach, self, previous) - return observed + def attach() -> Observing: + previous = self.observation + with ExitStack() as acquisition: + acquisition.enter_context(selected.route.hold_client_route()) + service = Comms(selected.route) + if service.root.resolve() != selected.root or RouteSelection.capture() != selected: + raise RouteChangedError("Comms route changed while opening the service") + observed = Observing( + selected, service, SidebarModel(self.app.call_next), ObservationProcess(service.route), + self.app.running_loop, access=self) + observed.subscriptions.callback(observed.retire, self) + self.observation = observed + observed.loop.call_soon_threadsafe(observed.attach, self, previous) + return observed + + return RootRuntime.attachment(selected.root, LaunchRuntime.running()).proceed( + attach, self.app.succeed_onto, + ) @handles(RevisionObserved) def revision_observed(self, result: RevisionObserved, observed: Observing) -> None: From a554dd0bde8271711e3908a337a8ad51615bf7aa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:47:22 -0400 Subject: [PATCH 5/5] Delete test hooks for retired lifetime route holds --- tests/model/test_sessions_share_one_stdio_process.py | 1 - tests/textual/test_observation_shutdown.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/tests/model/test_sessions_share_one_stdio_process.py b/tests/model/test_sessions_share_one_stdio_process.py index 2c84cfbb2..896e77217 100644 --- a/tests/model/test_sessions_share_one_stdio_process.py +++ b/tests/model/test_sessions_share_one_stdio_process.py @@ -97,7 +97,6 @@ async def test_closing_one_session_leaves_the_others_on_the_shared_process(attac await agent.stop() await asyncio.gather(*permissions, return_exceptions=True) assert shared is not None and shared.process is None and not shared.sessions - assert not shared.cleanup._exit_callbacks async def test_stopping_a_session_the_agent_refused_leaves_the_shared_process(attachment_scope): diff --git a/tests/textual/test_observation_shutdown.py b/tests/textual/test_observation_shutdown.py index 583c04c6c..4e4aa236c 100644 --- a/tests/textual/test_observation_shutdown.py +++ b/tests/textual/test_observation_shutdown.py @@ -2,7 +2,6 @@ import asyncio import sys -import threading import pytest from agent_comms.ui_model.observation import ObservationProcess, ObserverDied, ObserveNotifications, ReadNotifications @@ -10,14 +9,12 @@ async def test_an_observer_dying_during_shutdown_still_releases_its_resources(workspace): - cleaned = threading.Event() with pytest.raises(BaseExceptionGroup) as failures: async with workspace.app() as ui: access = ui.app.coordination_access await access.current_sidebar() observed = access.observation.observed() process = observed.process - access.cleanup.callback(cleaned.set) # The death has not been delivered to receive(); stop_updates must # encounter it at its actual StopObserving request during shutdown. asyncio.get_running_loop().remove_reader(process.fileno()) @@ -29,7 +26,6 @@ async def test_an_observer_dying_during_shutdown_still_releases_its_resources(wo assert process.connection.closed assert not process.process.is_alive() assert not access.retiring - assert cleaned.is_set()