fix(cli): share on-demand gateway lifecycle

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2d3045647a
commit 74c11e5d28
16 changed files with 594 additions and 232 deletions
+12 -1
View File
@@ -14,6 +14,7 @@ from rich.console import Console
from nanobot.config.schema import Config
from nanobot.gateway import (
GatewayClientLease,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
@@ -166,10 +167,16 @@ def create_gateway_app(
loaded_config=cfg,
)
)
if result.ok or result.message == "gateway_already_running":
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
if result.ok:
console.print("[green]Gateway started in the background.[/green]")
print_status(result.status)
return
if result.message == "gateway_already_running":
console.print("[yellow]Gateway is already running in the background.[/yellow]")
print_status(result.status)
return
console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]")
print_status(result.status)
raise typer.Exit(1)
@@ -222,7 +229,10 @@ def create_gateway_app(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
) -> None:
"""Stop the background gateway."""
result = runtime_for_instance(workspace=workspace, config=config).stop(timeout_s=timeout)
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.stop(timeout_s=timeout)
if result.ok or result.message == "gateway_not_running":
GatewayClientLease(runtime, kind="gateway-stop").clear()
if result.ok:
console.print("[green]Gateway stopped.[/green]")
else:
@@ -257,6 +267,7 @@ def create_gateway_app(
timeout_s=timeout,
)
if result.ok:
GatewayClientLease(runtime, kind="gateway-restart").mark_persistent()
console.print("[green]Gateway restarted in the background.[/green]")
print_status(result.status)
return
+95 -77
View File
@@ -14,7 +14,7 @@ import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from nanobot import __version__
from nanobot.cli.runtime_config import _model_display
@@ -24,9 +24,12 @@ from nanobot.cli.webui_support import (
_webui_endpoint_reachable,
webui_bootstrap_secret,
)
from nanobot.config.paths import get_data_dir, is_default_workspace
from nanobot.config.paths import get_data_dir
from nanobot.config.schema import Config
if TYPE_CHECKING:
from nanobot.gateway import GatewayClientLease
class TuiUnavailableError(RuntimeError):
"""Raised when the native TypeScript TUI cannot run on this installation."""
@@ -39,6 +42,7 @@ class TuiSessionError(ValueError):
@dataclass(frozen=True)
class _GatewayHandle:
base_url: str
lease: GatewayClientLease | None = None
def launch_tui(
@@ -58,35 +62,39 @@ def launch_tui(
config_path=config_path,
workspace_override=workspace_override,
)
bootstrap = _fetch_bootstrap(
gateway.base_url,
secret=webui_bootstrap_secret(config),
)
env = os.environ.copy()
env.update(
{
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
"NANOBOT_TUI_API_URL": gateway.base_url,
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
"NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
"NANOBOT_TUI_VERSION": __version__,
"NANOBOT_TUI_ACCESS": (
"workspace access" if config.tools.restrict_to_workspace else "full access"
),
"NANOBOT_TUI_THEME": theme,
}
)
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
if chat_id:
env["NANOBOT_TUI_CHAT_ID"] = chat_id
else:
env.pop("NANOBOT_TUI_CHAT_ID", None)
try:
bootstrap = _fetch_bootstrap(
gateway.base_url,
secret=webui_bootstrap_secret(config),
)
env = os.environ.copy()
env.update(
{
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
"NANOBOT_TUI_API_URL": gateway.base_url,
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
"NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
"NANOBOT_TUI_VERSION": __version__,
"NANOBOT_TUI_ACCESS": (
"workspace access" if config.tools.restrict_to_workspace else "full access"
),
"NANOBOT_TUI_THEME": theme,
}
)
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
if chat_id:
env["NANOBOT_TUI_CHAT_ID"] = chat_id
else:
env.pop("NANOBOT_TUI_CHAT_ID", None)
return subprocess.run(command, env=env, check=False).returncode
except OSError as exc:
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
finally:
lease = getattr(gateway, "lease", None)
if lease is not None:
lease.release()
def _resolve_tui_command() -> list[str]:
@@ -232,7 +240,12 @@ def _ensure_gateway(
config_path: Path,
workspace_override: str | None,
) -> _GatewayHandle:
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
from nanobot.gateway import (
GatewayClientLease,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
)
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
workspace_override_path = (
@@ -240,65 +253,70 @@ def _ensure_gateway(
if workspace_override
else None
)
effective_workspace = config.workspace_path.resolve(strict=False)
runtime_workspace = (
None if is_default_workspace(effective_workspace) else str(effective_workspace)
)
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=runtime_workspace,
config_path=str(config_path),
)
)
status = runtime.status()
endpoint_reachable = _webui_endpoint_reachable(base_url)
if status.running:
if status.port not in {None, config.gateway.port}:
raise TuiUnavailableError(
"the matching gateway instance is running on a different port; "
"restart it or use `nanobot agent --classic`"
)
if endpoint_reachable:
return _GatewayHandle(base_url=base_url)
elif endpoint_reachable:
raise TuiUnavailableError(
"the configured gateway port belongs to a different nanobot instance; "
"stop that instance or use `nanobot agent --classic`"
)
result = runtime.start_background(
GatewayStartOptions(
port=config.gateway.port,
workspace=workspace_override_path,
config_path=str(config_path),
)
)
started_here = result.ok
if not result.ok and result.message != "gateway_already_running":
raise TuiUnavailableError(
f"could not start the local gateway ({result.message}); logs: {result.status.log_path}"
lease = GatewayClientLease(runtime, kind="tui")
lease.acquire()
try:
status = runtime.status()
endpoint_reachable = _webui_endpoint_reachable(base_url)
if status.running:
if status.port not in {None, config.gateway.port}:
raise TuiUnavailableError(
"the matching gateway instance is running on a different port; "
"restart it or use `nanobot agent --classic`"
)
if endpoint_reachable:
return _GatewayHandle(base_url=base_url, lease=lease)
elif endpoint_reachable:
raise TuiUnavailableError(
"the configured gateway port belongs to a different nanobot instance; "
"stop that instance or use `nanobot agent --classic`"
)
result = runtime.start_background(
GatewayStartOptions(
port=config.gateway.port,
workspace=workspace_override_path,
config_path=str(config_path),
)
)
started_here = result.ok
if started_here:
lease.mark_ephemeral()
if not result.ok and result.message != "gateway_already_running":
raise TuiUnavailableError(
f"could not start the local gateway ({result.message}); "
f"logs: {result.status.log_path}"
)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if _webui_endpoint_reachable(base_url):
current = runtime.status()
if current.running and current.port in {None, config.gateway.port}:
return _GatewayHandle(base_url=base_url)
break
if not runtime.status().running and not _gateway_health_ready(
config.gateway.host,
config.gateway.port,
):
break
time.sleep(0.1)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if _webui_endpoint_reachable(base_url):
current = runtime.status()
if current.running and current.port in {None, config.gateway.port}:
return _GatewayHandle(base_url=base_url, lease=lease)
break
if not runtime.status().running and not _gateway_health_ready(
config.gateway.host,
config.gateway.port,
):
break
time.sleep(0.1)
if started_here:
runtime.stop(timeout_s=5)
raise TuiUnavailableError(
f"local gateway did not become ready; logs: {result.status.log_path}"
)
if started_here:
runtime.stop(timeout_s=5)
raise TuiUnavailableError(
f"local gateway did not become ready; logs: {result.status.log_path}"
)
except BaseException:
lease.release(timeout_s=5)
raise
def _fetch_bootstrap(base_url: str, *, secret: str) -> dict[str, Any]:
+107 -71
View File
@@ -7,7 +7,6 @@ from pydantic import ValidationError
from rich.console import Console
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.gateway_runtime import _run_gateway
from nanobot.cli.runtime_config import (
_load_runtime_config,
_print_config_error,
@@ -27,7 +26,6 @@ from nanobot.cli.webui_support import (
_open_webui_browser,
_prepare_webui_bundle_for_gateway,
_print_foreground_port_conflict,
_print_webui_foreground_lifecycle,
_resolve_webui_config_path,
_run_quick_start_for_webui,
_tcp_endpoint_reachable,
@@ -101,7 +99,12 @@ def webui(
) -> None:
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
from nanobot.config.loader import resolve_config_env_vars, save_config
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
from nanobot.gateway import (
GatewayClientLease,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
)
cli_terminal._ensure_interactive_tty_mode()
if dev and background:
@@ -223,9 +226,16 @@ def webui(
config_path=config_arg,
)
if background:
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
def ensure_shared_gateway(*, client_lease: GatewayClientLease | None = None) -> None:
"""Start or refresh the one managed gateway shared by local clients."""
_prepare_webui_bundle_for_gateway(
runtime_config,
mode="skip" if dev else webui_bundle_mode,
)
result = runtime.start_background(start_options)
started_fresh = result.ok
if started_fresh and client_lease is not None:
client_lease.mark_ephemeral()
restarted = False
restart_attempted = False
if not result.ok and result.message == "gateway_already_running" and changed_webui:
@@ -244,6 +254,8 @@ def webui(
console.print("[green]Gateway started in the background.[/green]")
else:
console.print("[yellow]Gateway is already running in the background.[/yellow]")
def print_shared_gateway_controls() -> None:
console.print(
"Manage this instance: "
f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
@@ -257,6 +269,11 @@ def webui(
"Stop nanobot: "
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
)
if background:
ensure_shared_gateway()
GatewayClientLease(runtime, kind="webui-background").mark_persistent()
print_shared_gateway_controls()
if not no_open:
_open_webui_browser(webui_url)
return
@@ -264,47 +281,67 @@ def webui(
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
webui_ready = _webui_endpoint_reachable(webui_url)
if gateway_ready and webui_ready:
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
if not dev:
console.print(
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(runtime)
else:
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
)
return
lease = GatewayClientLease(runtime, kind="webui")
lease.acquire()
try:
assert dev_browser_url is not None
with run_webui_dev_server(
target_url=webui_dev_proxy_target(webui_url),
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
if changed_webui and runtime.status().running:
ensure_shared_gateway(client_lease=lease)
gateway_ready = _gateway_health_ready(
runtime_config.gateway.host,
effective_gateway_port,
)
webui_ready = _webui_endpoint_reachable(webui_url)
if not gateway_ready or not webui_ready:
console.print("[red]Gateway did not become ready after the config update.[/red]")
raise typer.Exit(1)
console.print(
"[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]"
)
if not dev:
console.print(
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(dev_browser_url, wait=False)
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(
runtime,
poll_hook=dev_server.ensure_running,
)
_attach_to_background_gateway(runtime)
else:
_wait_with_existing_foreground_gateway(
runtime_config.gateway.host,
effective_gateway_port,
dev_server,
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
return
try:
assert dev_browser_url is not None
with run_webui_dev_server(
target_url=webui_dev_proxy_target(webui_url),
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
if not no_open:
_open_webui_browser(dev_browser_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(
runtime,
poll_hook=dev_server.ensure_running,
)
else:
_wait_with_existing_foreground_gateway(
runtime_config.gateway.host,
effective_gateway_port,
dev_server,
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
finally:
if lease.release():
console.print(
"[dim]Last local client exited; the on-demand gateway was stopped.[/dim]"
)
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
_host_for_local_browser(runtime_config.gateway.host),
@@ -319,34 +356,33 @@ def webui(
)
raise typer.Exit(1)
_print_webui_foreground_lifecycle(attached=False)
if dev_browser_url:
dev_proxy_target = webui_dev_proxy_target(webui_url)
try:
with run_webui_dev_server(
target_url=dev_proxy_target,
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else dev_browser_url,
open_browser_ready_url=f"{dev_proxy_target}/webui/bootstrap",
webui_static_dist=False,
webui_bundle_mode="skip",
unconfigured_provider_error=settings_setup_error,
webui_dev_server=dev_server,
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
lease = GatewayClientLease(runtime, kind="webui")
lease.acquire()
try:
ensure_shared_gateway(client_lease=lease)
print_shared_gateway_controls()
if dev_browser_url:
dev_proxy_target = webui_dev_proxy_target(webui_url)
try:
with run_webui_dev_server(
target_url=dev_proxy_target,
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
if not no_open:
_open_webui_browser(dev_browser_url)
_attach_to_background_gateway(
runtime,
poll_hook=dev_server.ensure_running,
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
webui_bundle_mode=webui_bundle_mode,
unconfigured_provider_error=settings_setup_error,
)
if not no_open:
_open_webui_browser(webui_url)
_attach_to_background_gateway(runtime)
finally:
if lease.release():
console.print("[dim]Last local client exited; the on-demand gateway was stopped.[/dim]")
+8 -11
View File
@@ -420,11 +420,13 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
"""Explain how the browser and gateway lifecycles differ."""
console.print()
if attached:
console.print("[green]nanobot is attached to the existing gateway.[/green]")
console.print("[green]WebUI is attached to the shared gateway.[/green]")
else:
console.print("[green]nanobot is running in this terminal.[/green]")
console.print("[green]WebUI is attached to the shared gateway.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
console.print(
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]"
)
def _attach_to_background_gateway(
@@ -432,7 +434,7 @@ def _attach_to_background_gateway(
*,
poll_hook: Callable[[], None] | None = None,
) -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
"""Keep a WebUI launcher attached without taking ownership of the gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
@@ -440,13 +442,8 @@ def _attach_to_background_gateway(
poll_hook()
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping nanobot...[/yellow]")
result = runtime.stop()
if result.ok or result.message == "gateway_not_running":
console.print("[green]Gateway stopped.[/green]")
return
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
raise typer.Exit(1)
console.print("\n[yellow]WebUI launcher detached.[/yellow]")
return
console.print("[yellow]Gateway stopped.[/yellow]")
+2
View File
@@ -1,6 +1,7 @@
"""Lightweight background runtime for the nanobot gateway."""
from nanobot.gateway.runtime import (
GatewayClientLease,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
@@ -10,6 +11,7 @@ from nanobot.gateway.runtime import (
)
__all__ = [
"GatewayClientLease",
"GatewayRuntime",
"GatewayRuntimePaths",
"GatewayStartOptions",
+153 -1
View File
@@ -3,12 +3,18 @@
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import tempfile
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, cast
from filelock import FileLock
from nanobot.config.paths import get_data_dir
from nanobot.process_runtime import (
@@ -97,8 +103,154 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
return build_gateway_command(self.python_executable, options)
class GatewayClientLease:
"""Reference-count an on-demand gateway shared by local interactive clients."""
def __init__(
self,
runtime: GatewayRuntime,
*,
kind: str,
pid: int | None = None,
token: str | None = None,
) -> None:
self.runtime = runtime
self.kind = kind
self.pid = pid or os.getpid()
self.token = token or uuid.uuid4().hex
state_path = runtime.paths.state_path
self.state_path = state_path.with_name(
f"{state_path.stem}.clients{state_path.suffix}"
)
self.lock = FileLock(f"{self.state_path}.lock")
self._acquired = False
def acquire(self) -> None:
"""Register this client before it starts or attaches to the gateway."""
with self.lock:
state = self._live_state()
clients = self._clients(state)
clients[self.token] = {
"pid": self.pid,
"kind": self.kind,
}
self._write_state(state)
self._acquired = True
def mark_ephemeral(self) -> None:
"""Mark a gateway started by a client for last-client shutdown."""
with self.lock:
state = self._live_state()
state["auto_stop"] = True
self._write_state(state)
def mark_persistent(self) -> None:
"""Keep an explicitly backgrounded gateway alive without client leases."""
with self.lock:
state = self._live_state()
state["auto_stop"] = False
self._write_or_clear(state)
def clear(self) -> None:
"""Forget leases after an explicit gateway stop."""
with self.lock:
self.state_path.unlink(missing_ok=True)
def release(self, *, timeout_s: int = 20) -> bool:
"""Release this client and stop an ephemeral gateway when it was the last."""
if not self._acquired:
return False
with self.lock:
state = self._live_state()
clients = self._clients(state)
clients.pop(self.token, None)
self._acquired = False
if clients or not bool(state.get("auto_stop")):
self._write_or_clear(state)
return False
result = self.runtime.stop(timeout_s=timeout_s)
stopped = result.ok or result.message == "gateway_not_running"
if stopped:
self.state_path.unlink(missing_ok=True)
else:
self._write_state(state)
return stopped
def _live_state(self) -> dict[str, object]:
state = self._read_state()
clients = self._clients(state)
stale: list[str] = []
for token, value in clients.items():
if not isinstance(value, dict):
stale.append(token)
continue
record = cast(dict[str, object], value)
if not _pid_is_running(record.get("pid")):
stale.append(token)
for token in stale:
clients.pop(token, None)
return state
@staticmethod
def _clients(state: dict[str, object]) -> dict[str, object]:
value = state.get("clients")
if isinstance(value, dict):
return cast(dict[str, object], value)
clients: dict[str, object] = {}
state["clients"] = clients
return clients
def _read_state(self) -> dict[str, object]:
try:
payload: object = json.loads(self.state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {"auto_stop": False, "clients": {}}
if isinstance(payload, dict):
return cast(dict[str, object], payload)
return {"auto_stop": False, "clients": {}}
def _write_or_clear(self, state: dict[str, object]) -> None:
clients = state.get("clients")
if not clients and not bool(state.get("auto_stop")):
self.state_path.unlink(missing_ok=True)
return
self._write_state(state)
def _write_state(self, state: dict[str, object]) -> None:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(
prefix=f"{self.state_path.name}.",
suffix=".tmp",
dir=self.state_path.parent,
)
temporary = Path(temporary_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.replace(self.state_path)
finally:
temporary.unlink(missing_ok=True)
def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None:
raw = "|".join(value for value in (workspace, config_path) if value)
if not raw:
return None
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def _pid_is_running(value: object) -> bool:
if not isinstance(value, int) or value <= 0:
return False
try:
os.kill(value, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True