From 014eab5f6af304ade11dee2d05cb15852b5ef9c9 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:08:44 +0800 Subject: [PATCH] fix(gateway): register foreground instances --- nanobot/cli/gateway_runtime.py | 26 +++++++++++++++------ nanobot/gateway/runtime.py | 28 +++++++++++++++++++++++ tests/cli/test_commands.py | 4 ++++ tests/gateway/test_runtime.py | 42 ++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index 5cc577867..1fe8e0da4 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -387,19 +387,27 @@ def _run_gateway( raise typer.Exit(1) from exc session_manager = SessionManager(config.workspace_path) - # Self-heal the gateway state file with the current PID after any restart. + # Use the same runtime identity for foreground and managed gateway processes. from nanobot.config.loader import get_config_path - from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths + from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions config_path = str(get_config_path().resolve(strict=False)) - GatewayRuntime.refresh_state_pid( + gateway_workspace = ( + str(config.workspace_path) + if not is_default_workspace(config.workspace_path) + else None + ) + gateway_runtime = GatewayRuntime( paths=GatewayRuntimePaths.for_instance( - workspace=str(config.workspace_path) - if not is_default_workspace(config.workspace_path) - else None, + workspace=gateway_workspace, config_path=config_path, ) ) + gateway_start_options = GatewayStartOptions( + port=port, + workspace=gateway_workspace, + config_path=config_path, + ) # Preserve existing single-workspace installs, but keep custom workspaces clean. if is_default_workspace(config.workspace_path): @@ -946,4 +954,8 @@ def _run_gateway( finally: restore_shutdown_handlers() - asyncio.run(run()) + gateway_runtime.claim_current_process(gateway_start_options) + try: + asyncio.run(run()) + finally: + gateway_runtime.release_current_process() diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index 882819bed..1cfe9ce01 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -11,6 +11,7 @@ import time import uuid from collections.abc import Callable from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from typing import Any, cast @@ -102,6 +103,33 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]): def _build_child_command(self, options: ProcessStartOptions) -> list[str]: return build_gateway_command(self.python_executable, options) + def claim_current_process(self, options: ProcessStartOptions) -> None: + """Publish the running foreground gateway for local client discovery.""" + with self._lifecycle_lock(): + state = self._read_state() or {} + pid = os.getpid() + state.update( + { + "pid": pid, + "identity": self._process_identity(pid), + "started_at": datetime.now(UTC).isoformat(), + "platform": self.platform_name, + "port": options.port, + "workspace": options.workspace, + "config_path": options.config_path, + "command": self._build_child_command(options), + "log_path": str(self.paths.log_path), + } + ) + self._write_state(state) + + def release_current_process(self) -> None: + """Remove this foreground gateway's state without touching a replacement.""" + with self._lifecycle_lock(): + state = self._read_state() + if state and self._record_matches_process(state, os.getpid()): + self._clear_state() + def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult: """Restart an existing gateway without creating a new persistent instance.""" with self._lifecycle_lock(): diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 514424a3d..6525f3b9c 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -2658,6 +2658,10 @@ def test_webui_foreground_reports_an_existing_gateway_without_leaking_secret( _patch_webui_provider_ready(monkeypatch) monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True) + monkeypatch.setattr( + "nanobot.cli.webui_support._gateway_health_ready", + lambda *_args, **_kwargs: True, + ) monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: False) result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"]) diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index 954f89ecc..45d0748dc 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -104,6 +104,48 @@ def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch): assert state["port"] == 18790 +def test_foreground_gateway_claim_is_discoverable_and_released(tmp_path, monkeypatch): + runtime = GatewayRuntime( + paths=_paths(tmp_path), + platform_name="Darwin", + python_executable="/python", + ) + monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 54321) + monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True) + options = GatewayStartOptions( + port=18790, + workspace="/tmp/workspace", + config_path="/tmp/config.json", + ) + + runtime.claim_current_process(options) + + status = runtime.status() + assert status.running is True + assert status.pid == os.getpid() + assert status.port == 18790 + assert status.command == tuple(runtime._build_child_command(options)) + + runtime.release_current_process() + + assert runtime.status().running is False + assert not runtime.paths.state_path.exists() + + +def test_foreground_gateway_release_preserves_a_replacement_state(tmp_path, monkeypatch): + runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Darwin") + monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid) + runtime.claim_current_process(GatewayStartOptions(port=18790)) + replacement = json.loads(runtime.paths.state_path.read_text(encoding="utf-8")) + replacement["pid"] = os.getpid() + 1 + replacement["identity"] = replacement["pid"] + runtime.paths.state_path.write_text(json.dumps(replacement), encoding="utf-8") + + runtime.release_current_process() + + assert runtime.paths.state_path.exists() + + def test_stop_reaps_an_owned_child_without_consuming_the_shutdown_timeout( tmp_path, monkeypatch,