diff --git a/docs/configuration.md b/docs/configuration.md index bba16d76..5e030da7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -191,6 +191,8 @@ These variables are process-level switches. Set them in the same terminal, servi Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface. +For Windows service wrappers such as WinSW or nssm, set `gateway.restartMode` to `exit` so `/restart` exits and lets the service manager perform the restart. The default `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. + ## Langfuse Observability nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK wrapper. This is configured with environment variables, not `config.json`. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 56c996ed..d2b44699 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -218,6 +218,7 @@ class AgentLoop: preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None, runtime_events: RuntimeEventBus | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None, + restart_mode: str = "auto", ): from nanobot.config.schema import ToolsConfig @@ -227,6 +228,7 @@ class AgentLoop: self.runtime_events = runtime_events or RuntimeEventBus() self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events) self.channels_config = channels_config + self.restart_mode = restart_mode self.provider = provider self._provider_snapshot_loader = provider_snapshot_loader self._preset_snapshot_loader = preset_snapshot_loader @@ -396,6 +398,7 @@ class AgentLoop: tools_config=config.tools, model_presets=preset_helpers.configured_model_presets(config), model_preset=defaults.model_preset, + restart_mode=config.gateway.restart_mode, provider_snapshot_loader=provider_snapshot_loader, preset_snapshot_loader=preset_snapshot_loader, **extra, diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index bcfe16c8..42048549 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import os +import subprocess import sys import time from contextlib import suppress @@ -50,7 +51,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( BuiltinCommandSpec( "/restart", "Restart nanobot", - "Restart the bot process in place.", + "Restart the bot process.", "rotate-cw", ), BuiltinCommandSpec( @@ -147,7 +148,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage: - """Restart the process in-place via os.execv.""" + """Restart the process.""" msg = ctx.msg set_restart_notice_to_env( channel=msg.channel, @@ -157,7 +158,19 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage: async def _do_restart(): await asyncio.sleep(1) - os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:]) + argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:] + mode = getattr(ctx.loop, "restart_mode", "auto") or "auto" + if mode == "auto": + mode = "spawn" if sys.platform == "win32" else "exec" + if mode == "exec": + os.execv(sys.executable, argv) + return + if mode == "spawn": + kwargs = {} + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + subprocess.Popen(argv, **kwargs) + os._exit(0) asyncio.create_task(_do_restart()) return OutboundMessage( diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index d7da7d2c..9e8b0ce4 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -313,6 +313,7 @@ class GatewayConfig(Base): host: str = "127.0.0.1" # Safer default: local-only bind. port: int = 18790 + restart_mode: Literal["auto", "exec", "spawn", "exit"] = "auto" heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 20579315..f75a92f3 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import os +import sys import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -44,7 +45,8 @@ class TestRestartCommand: RESTART_STARTED_AT_ENV, ) - loop, bus = _make_loop() + loop, _bus = _make_loop() + loop.restart_mode = "exec" msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop) @@ -76,10 +78,75 @@ class TestRestartCommand: await scheduled[0] mock_execv.assert_called_once() + @pytest.mark.asyncio + async def test_restart_windows_auto_spawns_and_exits(self): + from nanobot.command.builtin import cmd_restart + from nanobot.command.router import CommandContext + + loop, _bus = _make_loop() + msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") + ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop) + + async def _fast_sleep(_delay: float) -> None: + return None + + scheduled: list[asyncio.Task] = [] + fake_asyncio = SimpleNamespace( + sleep=_fast_sleep, + create_task=lambda coro: scheduled.append(asyncio.create_task(coro)) or scheduled[-1], + ) + + with patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \ + patch("nanobot.command.builtin.sys.platform", "win32"), \ + patch("nanobot.command.builtin.subprocess.CREATE_NEW_PROCESS_GROUP", 512, create=True), \ + patch("nanobot.command.builtin.subprocess.Popen") as mock_popen, \ + patch("nanobot.command.builtin.os._exit") as mock_exit, \ + patch("nanobot.command.builtin.os.execv") as mock_execv: + await cmd_restart(ctx) + await scheduled[0] + + mock_popen.assert_called_once_with( + [sys.executable, "-m", "nanobot"] + sys.argv[1:], + creationflags=512, + ) + mock_exit.assert_called_once_with(0) + mock_execv.assert_not_called() + + @pytest.mark.asyncio + async def test_restart_exit_mode_does_not_spawn(self): + from nanobot.command.builtin import cmd_restart + from nanobot.command.router import CommandContext + + loop, _bus = _make_loop() + loop.restart_mode = "exit" + msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") + ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop) + + async def _fast_sleep(_delay: float) -> None: + return None + + scheduled: list[asyncio.Task] = [] + fake_asyncio = SimpleNamespace( + sleep=_fast_sleep, + create_task=lambda coro: scheduled.append(asyncio.create_task(coro)) or scheduled[-1], + ) + + with patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \ + patch("nanobot.command.builtin.subprocess.Popen") as mock_popen, \ + patch("nanobot.command.builtin.os._exit") as mock_exit, \ + patch("nanobot.command.builtin.os.execv") as mock_execv: + await cmd_restart(ctx) + await scheduled[0] + + mock_exit.assert_called_once_with(0) + mock_popen.assert_not_called() + mock_execv.assert_not_called() + @pytest.mark.asyncio async def test_restart_intercepted_in_run_loop(self): """Verify /restart is handled at the run-loop level, not inside _dispatch.""" loop, bus = _make_loop() + loop.restart_mode = "exec" msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart") async def _fast_sleep(_delay: float) -> None: diff --git a/tests/config/test_gateway_config.py b/tests/config/test_gateway_config.py new file mode 100644 index 00000000..c4860207 --- /dev/null +++ b/tests/config/test_gateway_config.py @@ -0,0 +1,15 @@ +import pytest + +from nanobot.config.schema import Config, GatewayConfig + + +def test_gateway_restart_mode_accepts_camel_alias(): + config = Config.model_validate({"gateway": {"restartMode": "exit"}}) + + assert config.gateway.restart_mode == "exit" + assert config.model_dump(by_alias=True)["gateway"]["restartMode"] == "exit" + + +def test_gateway_restart_mode_rejects_unknown_value(): + with pytest.raises(ValueError): + GatewayConfig(restart_mode="service")