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
+2 -2
View File
@@ -161,7 +161,7 @@ If `nanobot` is not on `PATH`, invoke it through the method that installed it: r
nanobot webui
```
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts or joins the shared local gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
**Your first three steps**
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent
```
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another WebSocket session; use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts the shared local gateway when needed and leaves it available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another WebSocket session; use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `nanobot gateway --background` when the gateway must stay alive with no local clients. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
For one request and an immediate exit, use:
+3 -2
View File
@@ -121,7 +121,7 @@ nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bo
The command never deletes the external store and refuses to overwrite a different existing
workspace file. Back up both the config directory and workspace before changing versions.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts the shared background gateway. Exiting one TUI disconnects only that client, so other terminals and the WebUI stay connected; use `nanobot gateway stop` when you want to stop the shared process.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. `nanobot gateway --background`, `nanobot gateway restart`, and `nanobot webui --background` make it persistent until an explicit `nanobot gateway stop`.
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
@@ -149,7 +149,8 @@ First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
WebSocket channel port, and stops Vite together with the foreground gateway.
WebSocket channel port, and stops Vite when the launcher exits. The shared on-demand gateway stops
only when no other interactive client still holds it.
## Gateway
+1 -1
View File
@@ -52,7 +52,7 @@ nanobot webui -c ~/.nanobot-telegram/config.json
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
```
> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
> Interactive `nanobot agent` and `nanobot webui` commands with the same `--config` and explicit `--workspace` selectors share one gateway instance. Different selectors produce isolated runtime state and processes. The one-shot and `--classic` agent paths remain direct local executions.
| Component | Resolved From | Example |
|-----------|---------------|---------|
+2 -2
View File
@@ -79,7 +79,7 @@ Most other providers can say `not set`. This command validates local setup but d
## 4. Get the First Reply
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that terminal open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that launcher open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
Send:
@@ -89,7 +89,7 @@ Hello!
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
Interactive WebUI and TUI launchers share one on-demand gateway. Closing one launcher leaves it running for the others; closing the last launcher stops it. If you prefer a persistent background process, press `Ctrl+C`, then run:
```bash
nanobot gateway --background
+1 -1
View File
@@ -160,4 +160,4 @@ Run:
nanobot webui
```
Leave that terminal open while you use nanobot. To stop it, return to the terminal and press `Ctrl+C`. Use `nanobot webui --background` only after the normal foreground start and model setup work; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
Leave that launcher open while you use nanobot. Pressing `Ctrl+C` disconnects it; the shared gateway stops when it was the last local WebUI or TUI client. Use `nanobot webui --background` only after the normal foreground start and model setup work when you want the gateway to stay online with no clients; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
+5 -2
View File
@@ -19,7 +19,8 @@ nanobot webui
`nanobot webui` creates the config/workspace when needed, enables the local
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
one is missing, starts the gateway, and opens the browser. With a fresh config,
one is missing, starts or joins the same on-demand gateway used by the native
TUI, and opens the browser. With a fresh config,
it can open before a model is configured so you can finish setup in **Settings
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
it is not available from other devices on your LAN.
@@ -33,7 +34,9 @@ nanobot webui --background
Complete first-time model setup in a foreground `nanobot webui` session before using
`--background`.
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
Each foreground WebUI or TUI launcher releases only its own client. The last
interactive launcher stops an on-demand gateway. `--background` makes the
gateway persistent; manage it with `nanobot gateway status`, `nanobot gateway
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
Manual config still works. Same-machine localhost WebUI access can run without
+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
+99 -56
View File
@@ -207,7 +207,7 @@ def test_webui_restores_tty_before_loading_config(monkeypatch, tmp_path: Path) -
monkeypatch.setattr(cli_webui, "_gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr(cli_webui, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(cli_webui, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(cli_webui, "_run_gateway", lambda *_args, **_kwargs: None)
_patch_webui_managed_gateway(monkeypatch)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
@@ -1949,6 +1949,54 @@ def _patch_gateway_ports_free(monkeypatch) -> None:
)
def _patch_webui_managed_gateway(
monkeypatch,
seen: dict[str, object] | None = None,
) -> dict[str, object]:
"""Run WebUI CLI tests against a managed gateway without spawning a process."""
from nanobot.gateway import GatewayStatus, RuntimeResult
captured = seen if seen is not None else {}
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
captured["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
self.running = False
def start_background(self, options):
captured["start_options"] = options
self.running = True
status = GatewayStatus(
running=True,
pid=123,
state_path=Path("gateway.json"),
log_path=Path("gateway.log"),
port=options.port,
reason="running",
)
return RuntimeResult(True, "gateway_started_background", status)
def status(self):
return SimpleNamespace(running=self.running)
def stop(self, *, timeout_s: int):
captured["stop_timeout"] = timeout_s
self.running = False
return RuntimeResult(True, "gateway_stopped", self.status())
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"nanobot.cli.webui._attach_to_background_gateway",
lambda runtime, **_kwargs: captured.__setitem__("attached_runtime", runtime),
)
return captured
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -2125,11 +2173,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
lambda path: seen.__setitem__("templates", path),
)
def _fake_run_gateway(config: Config, **kwargs) -> None:
seen["gateway_config"] = config
seen["gateway_kwargs"] = kwargs
monkeypatch.setattr("nanobot.cli.webui._run_gateway", _fake_run_gateway)
_patch_webui_managed_gateway(monkeypatch, seen)
result = runner.invoke(
app,
@@ -2159,18 +2203,15 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
assert len(websocket["tokenIssueSecret"]) >= 32
assert data["agents"]["defaults"]["workspace"] == str(workspace)
assert seen["templates"] == workspace
assert seen["gateway_kwargs"] == {
"port": 18888,
"open_browser_url": None,
"webui_bundle_mode": "auto",
"unconfigured_provider_error": None,
}
options = seen["start_options"]
assert options.port == 18888
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
assert "bootstrap secret was generated" in compact_output
assert "channels.websocket.tokenIssueSecret" in compact_output
assert "rerun without --no-open" in compact_output
assert "nanobot is running in this terminal" in compact_output
assert "Press Ctrl+C here to stop nanobot" in compact_output
assert "Last local client exited; the on-demand gateway was stopped" in compact_output
def test_webui_dev_rejects_background_before_creating_config(tmp_path: Path) -> None:
@@ -2208,12 +2249,20 @@ def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path)
finally:
seen["dev_running"] = False
def fake_run_gateway(_config: Config, **kwargs) -> None:
assert seen["dev_running"] is True
seen["gateway_kwargs"] = kwargs
monkeypatch.setattr("nanobot.cli.webui.run_webui_dev_server", fake_dev_server)
monkeypatch.setattr("nanobot.cli.webui._run_gateway", fake_run_gateway)
_patch_webui_managed_gateway(monkeypatch, seen)
monkeypatch.setattr(
"nanobot.cli.webui._attach_to_background_gateway",
lambda runtime, **kwargs: seen.update(
attached_runtime=runtime,
attach_kwargs=kwargs,
attached_while_dev_running=seen["dev_running"],
),
)
monkeypatch.setattr(
"nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
@@ -2237,17 +2286,10 @@ def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path)
browser_url = dev_kwargs["browser_url"]
assert isinstance(browser_url, str)
assert browser_url.startswith("http://127.0.0.1:5173/#/?bootstrapSecret=")
gateway_kwargs = seen["gateway_kwargs"]
assert isinstance(gateway_kwargs, dict)
assert gateway_kwargs == {
"port": 18888,
"open_browser_url": browser_url,
"open_browser_ready_url": "http://127.0.0.1:8899/webui/bootstrap",
"webui_static_dist": False,
"webui_bundle_mode": "skip",
"unconfigured_provider_error": None,
"webui_dev_server": seen["dev_server"],
}
assert seen["start_options"].port == 18888
assert seen["attached_while_dev_running"] is True
assert seen["attach_kwargs"] == {"poll_hook": seen["dev_server"].ensure_running}
assert seen["opened_url"] == browser_url
assert seen["dev_running"] is False
assert "WebUI dev: http://127.0.0.1:5173/#/?bootstrapSecret=<redacted>" in re.sub(
r"\s+", " ", _strip_ansi(result.stdout)
@@ -2352,16 +2394,13 @@ def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path
)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda config, **kwargs: seen.update(config=config, **kwargs),
)
_patch_webui_managed_gateway(monkeypatch, seen)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
assert result.exit_code == 0
assert config_file.exists()
assert seen["unconfigured_provider_error"] == "No API key configured for provider 'custom'."
assert seen["start_options"].config_path == str(config_file.resolve(strict=False))
assert "Configure a provider and model in WebUI Settings → Models." in result.stdout
@@ -2390,11 +2429,6 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
}),
encoding="utf-8",
)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway must not start with unresolved config"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
assert result.exit_code == 1
@@ -2454,6 +2488,7 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
seen["start_options"] = options
@@ -2550,6 +2585,7 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
seen["start_options"] = options
@@ -2610,18 +2646,30 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
"nanobot.cli.webui._open_webui_browser",
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
self.paths = kwargs["paths"]
def status(self):
return SimpleNamespace(running=True)
def start_background(self, options):
return SimpleNamespace(
ok=False,
message="gateway_already_running",
status=SimpleNamespace(log_path=self.paths.log_path),
)
def restart(self, options, *, timeout_s: int):
seen["restart_options"] = options
seen["restart_timeout"] = timeout_s
return SimpleNamespace(
ok=True,
message="gateway_started_background",
status=SimpleNamespace(log_path=self.paths.log_path),
)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._attach_to_background_gateway",
@@ -2642,7 +2690,7 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
assert seen["open_kwargs"] == {"wait": False}
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
def test_attach_to_background_gateway_detaches_on_ctrl_c(monkeypatch, capsys) -> None:
stopped = False
class _FakeRuntime:
@@ -2661,11 +2709,11 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
cli_webui_support._attach_to_background_gateway(_FakeRuntime())
assert stopped is True
assert stopped is False
output = capsys.readouterr().out
assert "Closing the browser does not stop channels or automations" in output
assert "Press Ctrl+C here to stop nanobot" in output
assert "Gateway stopped" in output
assert "gateway stops only when the last local client exits" in output
assert "WebUI launcher detached" in output
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
@@ -2697,8 +2745,8 @@ def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path
)
class _FakeRuntime:
def __init__(self, **_kwargs) -> None:
pass
def __init__(self, **kwargs) -> None:
self.paths = kwargs["paths"]
def status(self):
return SimpleNamespace(running=False)
@@ -2719,11 +2767,6 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
"nanobot.cli.webui._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 1
+20 -1
View File
@@ -7,7 +7,7 @@ from typer.testing import CliRunner
from nanobot.cli.gateway import create_gateway_app
from nanobot.config.schema import Config
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
from nanobot.gateway import GatewayRuntimePaths, GatewayStartOptions, GatewayStatus, RuntimeResult
from nanobot.gateway.service import GatewayServiceOptions, GatewayServiceResult
runner = CliRunner()
@@ -15,6 +15,7 @@ runner = CliRunner()
class FakeRuntime:
def __init__(self, tmp_path: Path):
self.paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path)
self.status_value = GatewayStatus(
running=True,
pid=12345,
@@ -200,6 +201,20 @@ def test_gateway_background_starts_detached_runtime(tmp_path):
assert prepare_calls == [(config, "warn")]
def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def already_running(_options: GatewayStartOptions) -> RuntimeResult:
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value)
fake_runtime.start_background = already_running # type: ignore[method-assign]
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway is already running in the background" in result.stdout
def test_gateway_rejects_conflicting_modes(tmp_path):
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
@@ -230,6 +245,9 @@ def test_gateway_logs_can_read_without_following(tmp_path):
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
lease_state = fake_runtime.paths.state_path.with_name("gateway.clients.json")
lease_state.parent.mkdir(parents=True, exist_ok=True)
lease_state.write_text('{"auto_stop": true, "clients": {}}', encoding="utf-8")
def fake_stop(*, timeout_s: int) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
@@ -242,6 +260,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
assert result.exit_code == 0
assert "gateway_not_running" in result.stdout
assert fake_runtime.stop_timeout == 3
assert not lease_state.exists()
def test_gateway_restart_starts_background_runtime(tmp_path):
+17 -3
View File
@@ -79,12 +79,18 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
config.agents.defaults.model_preset = "Deep Research"
captured: dict[str, str] = {}
released: list[bool] = []
class FakeLease:
def release(self) -> None:
released.append(True)
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr(
"nanobot.cli.tui_launcher._ensure_gateway",
lambda *args, **kwargs: SimpleNamespace(
base_url="http://127.0.0.1:8765",
lease=FakeLease(),
),
)
monkeypatch.setattr(
@@ -116,6 +122,7 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
assert "NANOBOT_TUI_CHAT_ID" not in captured
assert released == [True]
def test_explicit_tui_binary_must_exist(
@@ -451,12 +458,13 @@ def test_gateway_reuses_the_matching_managed_instance(
assert gateway.base_url == "http://127.0.0.1:8765"
def test_gateway_started_for_tui_remains_shared_after_launch(
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
started = False
stopped = False
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
@@ -477,8 +485,11 @@ def test_gateway_started_for_tui_remains_shared_after_launch(
status=SimpleNamespace(log_path=tmp_path / "gateway.log"),
)
def stop(self, *, timeout_s: int) -> None:
raise AssertionError(f"shared gateway stopped with timeout {timeout_s}")
def stop(self, *, timeout_s: int) -> SimpleNamespace:
nonlocal stopped
assert timeout_s == 20
stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped")
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr(
@@ -494,3 +505,6 @@ def test_gateway_started_for_tui_remains_shared_after_launch(
assert gateway.base_url == "http://127.0.0.1:8765"
assert started is True
assert gateway.lease is not None
assert gateway.lease.release() is True
assert stopped is True
+67 -1
View File
@@ -1,13 +1,21 @@
import json
import os
import signal
import subprocess
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions, GatewayStatus
from nanobot.gateway import (
GatewayClientLease,
GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
GatewayStatus,
)
class FakeProcess:
@@ -179,6 +187,64 @@ def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypa
]
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
stopped: list[int] = []
def stop(*, timeout_s: int):
stopped.append(timeout_s)
return SimpleNamespace(ok=True, message="gateway_stopped")
monkeypatch.setattr(runtime, "stop", stop)
tui = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
webui = GatewayClientLease(runtime, kind="webui", pid=os.getpid(), token="webui")
tui.acquire()
tui.mark_ephemeral()
webui.acquire()
assert tui.release() is False
assert stopped == []
assert webui.release() is True
assert stopped == [20]
assert not webui.state_path.exists()
def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
stopped: list[int] = []
monkeypatch.setattr(
runtime,
"stop",
lambda *, timeout_s: stopped.append(timeout_s),
)
client = GatewayClientLease(runtime, kind="webui", pid=os.getpid())
client.acquire()
client.mark_ephemeral()
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
assert client.release() is False
assert stopped == []
assert not client.state_path.exists()
def test_failed_last_client_shutdown_remains_retryable(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(
runtime,
"stop",
lambda *, timeout_s: SimpleNamespace(ok=False, message="gateway_stop_timeout"),
)
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid())
client.acquire()
client.mark_ephemeral()
assert client.release() is False
state = json.loads(client.state_path.read_text(encoding="utf-8"))
assert state == {"auto_stop": True, "clients": {}}
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
calls: list[dict] = []