fix(restart): add explicit restart mode

This commit is contained in:
chengyongru
2026-06-30 15:21:14 +08:00
committed by Xubin Ren
parent d979597361
commit 4726ca0478
6 changed files with 105 additions and 4 deletions
+2
View File
@@ -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`.
+3
View File
@@ -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,
+16 -3
View File
@@ -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(
+1
View File
@@ -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)
+68 -1
View File
@@ -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:
+15
View File
@@ -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")