fix(restart): add explicit restart mode
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user