fix(cli): make gateway persistence explicit

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 74c11e5d28
commit b8333a2d7e
10 changed files with 147 additions and 189 deletions
+2 -2
View File
@@ -174,10 +174,10 @@ Any normal reply means the provider, model, workspace, and browser gateway are w
**Keep nanobot running after you close the terminal**
```bash
nanobot webui --background
nanobot gateway --background
```
This starts the same full gateway as `nanobot webui`, opens the browser, and leaves channels and automations running after the launcher exits. Complete first-time model setup with foreground `nanobot webui` before switching to background mode.
This is the only command that promotes the shared gateway to persistent background mode. It leaves channels and automations running after every local TUI and WebUI launcher exits. Complete first-time model setup with `nanobot webui` before switching to background mode; open the same localhost WebUI again afterward.
```bash
nanobot gateway status
+5 -3
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, 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`.
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. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` preserves the current lifecycle mode, and `nanobot gateway stop` ends either mode.
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.
@@ -138,7 +138,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| Command | Description |
|---|---|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
@@ -147,7 +147,9 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
`--dev` is a foreground source-checkout workflow. Persistent gateway lifecycle is deliberately
owned only by `nanobot gateway --background`; `nanobot webui --background` prints migration
guidance instead of silently changing process ownership.
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
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.
+1 -1
View File
@@ -160,4 +160,4 @@ Run:
nanobot webui
```
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`.
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. After the normal foreground start and model setup work, use `nanobot gateway --background` when you want the gateway to stay online with no clients; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
+5 -5
View File
@@ -25,17 +25,17 @@ 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.
Run it in the background when you do not want to keep a terminal open:
After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
```bash
nanobot webui --background
nanobot gateway --background
```
Complete first-time model setup in a foreground `nanobot webui` session before using
`--background`.
`nanobot webui --background` is retained only to print migration guidance. This keeps one
unambiguous owner for persistent process lifecycle.
Each foreground WebUI or TUI launcher releases only its own client. The last
interactive launcher stops an on-demand gateway. `--background` makes the
interactive launcher stops an on-demand gateway. `nanobot gateway --background` makes the
gateway persistent; manage it with `nanobot gateway status`, `nanobot gateway
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
+26 -3
View File
@@ -167,14 +167,31 @@ def create_gateway_app(
loaded_config=cfg,
)
)
promoted = False
if result.ok or result.message == "gateway_already_running":
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
promoted = 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]")
if promoted:
console.print(
"[green]Existing on-demand gateway promoted to persistent "
"background mode.[/green]"
)
console.print(
"[dim]It will keep running after all local clients exit; "
"use `nanobot gateway stop` to stop it.[/dim]"
)
else:
console.print(
"[yellow]Gateway is already running in persistent "
"background mode.[/yellow]"
)
print_status(result.status)
return
console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]")
@@ -267,10 +284,16 @@ 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
if result.message == "gateway_not_running":
console.print("[yellow]Gateway is not running; there is nothing to restart.[/yellow]")
console.print(
"[dim]Start a persistent gateway with `nanobot gateway --background`.[/dim]"
)
print_status(result.status)
raise typer.Exit(1)
console.print(f"[red]Gateway restart failed: {result.message}[/red]")
print_status(result.status)
raise typer.Exit(1)
+18 -18
View File
@@ -82,7 +82,7 @@ def webui(
background: bool = typer.Option(
False,
"--background",
help="Keep the gateway running after this command exits",
help="Deprecated; use `nanobot gateway --background`",
),
dev: bool = typer.Option(
False,
@@ -107,10 +107,24 @@ def webui(
)
cli_terminal._ensure_interactive_tty_mode()
if dev and background:
console.print("[red]Error: --dev cannot be combined with --background.[/red]")
raise typer.Exit(1)
config_path = _resolve_webui_config_path(config)
if background:
import shlex
command = ["nanobot", "gateway", "--background", "--config", str(config_path)]
if workspace:
command.extend(
["--workspace", str(Path(workspace).expanduser().resolve(strict=False))]
)
console.print(
"[red]`nanobot webui --background` no longer owns gateway lifecycle.[/red]"
)
console.print("Start the persistent gateway explicitly, then open the WebUI:")
console.print(" [cyan]" + " ".join(shlex.quote(part) for part in command) + "[/cyan]")
console.print(
" [cyan]nanobot webui --config " + shlex.quote(str(config_path)) + "[/cyan]"
)
raise typer.Exit(1)
created_config = not config_path.exists()
if created_config:
console.print(f"[yellow]No config found at {config_path}.[/yellow]")
@@ -134,12 +148,6 @@ def webui(
if settings_setup_error:
console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
console.print("Configure a provider and model in WebUI Settings → Models.")
if background:
console.print(
"[red]First-time WebUI setup must run in the foreground. "
"Run `nanobot webui` without --background.[/red]"
)
raise typer.Exit(1)
elif provider_error:
console.print(f"[dim]Provider check: {provider_error}[/dim]")
setup_config = _run_quick_start_for_webui(
@@ -270,14 +278,6 @@ def webui(
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
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:
+15 -2
View File
@@ -102,6 +102,17 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
return build_gateway_command(self.python_executable, options)
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult:
"""Restart an existing gateway without creating a new persistent instance."""
with self._lifecycle_lock():
status = self.status()
if not status.running:
return ProcessResult(False, "gateway_not_running", status)
stop_result = self._stop(timeout_s=timeout_s)
if not stop_result.ok:
return stop_result
return self._start_background(options)
class GatewayClientLease:
"""Reference-count an on-demand gateway shared by local interactive clients."""
@@ -144,12 +155,14 @@ class GatewayClientLease:
state["auto_stop"] = True
self._write_state(state)
def mark_persistent(self) -> None:
"""Keep an explicitly backgrounded gateway alive without client leases."""
def mark_persistent(self) -> bool:
"""Keep an explicitly backgrounded gateway alive; return whether it was promoted."""
with self.lock:
state = self._live_state()
promoted = bool(state.get("auto_stop"))
state["auto_stop"] = False
self._write_or_clear(state)
return promoted
def clear(self) -> None:
"""Forget leases after an explicit gateway stop."""
+16 -153
View File
@@ -2214,16 +2214,29 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
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:
def test_webui_background_points_to_the_single_persistent_gateway_command(
tmp_path: Path,
) -> None:
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
result = runner.invoke(
app,
["webui", "--dev", "--background", "--yes", "--config", str(config_file)],
[
"webui",
"--background",
"--config",
str(config_file),
"--workspace",
str(workspace),
],
)
assert result.exit_code == 1
assert "--dev cannot be combined with --background" in result.stdout
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "webui --background` no longer owns gateway lifecycle" in compact_output
assert "nanobot gateway --background --config" in compact_output
assert "--workspace" in compact_output
assert not config_file.exists()
@@ -2471,77 +2484,6 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup(
assert config_file.name in result.stdout
def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path: Path) -> None:
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
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
status = GatewayStatus(
running=True,
pid=123,
state_path=tmp_path / "gateway.json",
log_path=tmp_path / "gateway.log",
port=options.port,
reason="running",
)
return RuntimeResult(True, "gateway_started_background", status)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
[
"webui",
"--config",
str(config_file),
"--workspace",
str(workspace),
"--background",
"--gateway-port",
"18889",
"--yes",
],
)
assert result.exit_code == 0
assert "Gateway started in the background" in result.stdout
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "nanobot gateway status --config" in compact_output
assert "--workspace" in compact_output
options = seen["start_options"]
assert isinstance(options, GatewayStartOptions)
assert options.port == 18889
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
assert "bootstrapSecret=<redacted>" in compact_output
assert "bootstrapSecret=" in opened_url
assert "Closing the browser does not stop channels or automations" in compact_output
assert "nanobot gateway stop --config" in compact_output
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
opened: list[str] = []
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
@@ -2555,85 +2497,6 @@ def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> Non
assert "super-secret" not in output
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
monkeypatch,
tmp_path: Path,
) -> None:
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
def _status(options: GatewayStartOptions) -> GatewayStatus:
return GatewayStatus(
running=True,
pid=123,
state_path=tmp_path / "gateway.json",
log_path=tmp_path / "gateway.log",
port=options.port,
reason="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
return RuntimeResult(False, "gateway_already_running", _status(options))
def restart(self, options: GatewayStartOptions, *, timeout_s: int) -> RuntimeResult:
seen["restart_options"] = options
seen["restart_timeout"] = timeout_s
return RuntimeResult(True, "gateway_started_background", _status(options))
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
[
"webui",
"--config",
str(config_file),
"--workspace",
str(workspace),
"--background",
"--gateway-port",
"18889",
"--yes",
],
)
assert result.exit_code == 0
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "WebUI config changed; restarting the background gateway" in compact_output
assert "Gateway restarted in the background" in compact_output
assert "Gateway is already running" not in compact_output
options = seen["restart_options"]
assert isinstance(options, GatewayStartOptions)
assert options is seen["start_options"]
assert seen["restart_timeout"] == 20
assert options.port == 18889
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
+43 -1
View File
@@ -1,3 +1,4 @@
import json
from pathlib import Path
import pytest
@@ -203,6 +204,9 @@ def test_gateway_background_starts_detached_runtime(tmp_path):
def test_gateway_background_adopts_an_existing_on_demand_gateway(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 already_running(_options: GatewayStartOptions) -> RuntimeResult:
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value)
@@ -212,7 +216,23 @@ def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway is already running in the background" in result.stdout
assert "promoted to persistent background mode" in result.stdout
assert "will keep running after all local clients exit" in result.stdout
assert not lease_state.exists()
def test_gateway_background_reports_an_existing_persistent_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 "already running in persistent background mode" in result.stdout
def test_gateway_rejects_conflicting_modes(tmp_path):
@@ -267,6 +287,9 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
config = Config()
config.gateway.port = 18793
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
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")
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
@@ -275,6 +298,25 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
assert fake_runtime.stop_timeout == 9
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
assert prepare_calls == [(config, "warn")]
assert json.loads(lease_state.read_text(encoding="utf-8"))["auto_stop"] is True
def test_gateway_restart_does_not_create_a_persistent_gateway(tmp_path):
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def not_running(
_options: GatewayStartOptions, *, timeout_s: int
) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
return RuntimeResult(False, "gateway_not_running", fake_runtime.status_value)
fake_runtime.restart = not_running # type: ignore[method-assign]
result = runner.invoke(app, ["gateway", "restart"])
assert result.exit_code == 1
assert "there is nothing to restart" in result.stdout
assert "nanobot gateway --background" in result.stdout
def test_gateway_install_service_uses_service_installer(tmp_path):
+16 -1
View File
@@ -187,6 +187,21 @@ def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypa
]
def test_restart_does_not_start_a_gateway_that_is_not_running(tmp_path):
spawned: list[list[str]] = []
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=lambda command, **_kwargs: spawned.append(command),
)
result = runtime.restart(GatewayStartOptions(port=18790))
assert result.ok is False
assert result.message == "gateway_not_running"
assert spawned == []
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] = []
@@ -222,7 +237,7 @@ def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypa
client.acquire()
client.mark_ephemeral()
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
assert GatewayClientLease(runtime, kind="gateway-background").mark_persistent() is True
assert client.release() is False
assert stopped == []