From 896d5786775608ddd57eae3bf324cc6299ea4ccc Mon Sep 17 00:00:00 2001 From: imfondof Date: Fri, 3 Apr 2026 00:44:17 +0800 Subject: [PATCH] fix(restart): show restart completion with elapsed time across channels --- nanobot/channels/manager.py | 20 ++++++ nanobot/cli/commands.py | 85 +++++--------------------- nanobot/command/builtin.py | 5 +- nanobot/config/runtime_keys.py | 4 -- nanobot/utils/restart.py | 58 ++++++++++++++++++ tests/channels/test_channel_plugins.py | 28 +++++++++ tests/cli/test_restart_command.py | 81 ++---------------------- tests/utils/test_restart.py | 49 +++++++++++++++ 8 files changed, 179 insertions(+), 151 deletions(-) delete mode 100644 nanobot/config/runtime_keys.py create mode 100644 nanobot/utils/restart.py create mode 100644 tests/utils/test_restart.py diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 0d6232251..1f26f4d7a 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -11,6 +11,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config +from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message # Retry delays for message sending (exponential backoff: 1s, 2s, 4s) _SEND_RETRY_DELAYS = (1, 2, 4) @@ -91,9 +92,28 @@ class ChannelManager: logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) + self._notify_restart_done_if_needed() + # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) + def _notify_restart_done_if_needed(self) -> None: + """Send restart completion message when runtime env markers are present.""" + notice = consume_restart_notice_from_env() + if not notice: + return + target = self.channels.get(notice.channel) + if not target: + return + asyncio.create_task(self._send_with_retry( + target, + OutboundMessage( + channel=notice.channel, + chat_id=notice.chat_id, + content=format_restart_completed_message(notice.started_at_raw), + ), + )) + async def stop_all(self) -> None: """Stop all channels and the dispatcher.""" logger.info("Stopping all channels...") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b1e4f056a..4dcf3873f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -37,6 +37,11 @@ from nanobot.cli.stream import StreamRenderer, ThinkingSpinner from nanobot.config.paths import get_workspace_path, is_default_workspace from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates +from nanobot.utils.restart import ( + consume_restart_notice_from_env, + format_restart_completed_message, + should_show_cli_restart_notice, +) app = typer.Typer( name="nanobot", @@ -206,57 +211,6 @@ def _is_exit_command(command: str) -> bool: return command.lower() in EXIT_COMMANDS -def _parse_cli_session(session_id: str) -> tuple[str, str]: - """Split session id into (channel, chat_id).""" - if ":" in session_id: - return session_id.split(":", 1) - return "cli", session_id - - -def _should_show_cli_restart_notice( - restart_notify_channel: str, - restart_notify_chat_id: str, - session_id: str, -) -> bool: - """Return True when CLI should display restart-complete notice.""" - _, cli_chat_id = _parse_cli_session(session_id) - return restart_notify_channel == "cli" and ( - not restart_notify_chat_id or restart_notify_chat_id == cli_chat_id - ) - - -async def _notify_restart_done_when_channel_ready( - *, - bus, - channels, - channel: str, - chat_id: str, - timeout_s: float = 30.0, - poll_s: float = 0.25, -) -> bool: - """Wait for target channel readiness, then publish restart completion.""" - from nanobot.bus.events import OutboundMessage - - if not channel or not chat_id: - return False - if channel not in channels.enabled_channels: - return False - - waited = 0.0 - while waited <= timeout_s: - target = channels.get_channel(channel) - if target and target.is_running: - await bus.publish_outbound(OutboundMessage( - channel=channel, - chat_id=chat_id, - content="Restart completed.", - )) - return True - await asyncio.sleep(poll_s) - waited += poll_s - return False - - async def _read_interactive_input_async() -> str: """Read user input using prompt_toolkit (handles paste, history, display). @@ -649,7 +603,6 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -748,8 +701,6 @@ def gateway( # Create channel manager channels = ChannelManager(config, bus) - restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() - restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() def _pick_heartbeat_target() -> tuple[str, str]: """Pick a routable channel/chat target for heartbeat-triggered messages.""" @@ -826,13 +777,6 @@ def gateway( try: await cron.start() await heartbeat.start() - if restart_notify_channel and restart_notify_chat_id: - asyncio.create_task(_notify_restart_done_when_channel_ready( - bus=bus, - channels=channels, - channel=restart_notify_channel, - chat_id=restart_notify_chat_id, - )) await asyncio.gather( agent.run(), channels.start_all(), @@ -874,7 +818,6 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -915,13 +858,12 @@ def agent( channels_config=config.channels, timezone=config.agents.defaults.timezone, ) - restart_notify_channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() - restart_notify_chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() - - cli_channel, cli_chat_id = _parse_cli_session(session_id) - - if _should_show_cli_restart_notice(restart_notify_channel, restart_notify_chat_id, session_id): - _print_agent_response("Restart completed.", render_markdown=False) + restart_notice = consume_restart_notice_from_env() + if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): + _print_agent_response( + format_restart_completed_message(restart_notice.started_at_raw), + render_markdown=False, + ) # Shared reference for progress callbacks _thinking: ThinkingSpinner | None = None @@ -960,6 +902,11 @@ def agent( _init_prompt_session() console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + if ":" in session_id: + cli_channel, cli_chat_id = session_id.split(":", 1) + else: + cli_channel, cli_chat_id = "cli", session_id + def _handle_signal(signum, frame): sig_name = signal.Signals(signum).name _restore_terminal() diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index f63a1e357..fa8dd693b 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -9,8 +9,8 @@ import sys from nanobot import __version__ from nanobot.bus.events import OutboundMessage from nanobot.command.router import CommandContext, CommandRouter -from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.utils.helpers import build_status_content +from nanobot.utils.restart import set_restart_notice_to_env async def cmd_stop(ctx: CommandContext) -> OutboundMessage: @@ -36,8 +36,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage: """Restart the process in-place via os.execv.""" msg = ctx.msg - os.environ[RESTART_NOTIFY_CHANNEL_ENV] = msg.channel - os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = msg.chat_id + set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id) async def _do_restart(): await asyncio.sleep(1) diff --git a/nanobot/config/runtime_keys.py b/nanobot/config/runtime_keys.py deleted file mode 100644 index 2dc6c9234..000000000 --- a/nanobot/config/runtime_keys.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Runtime environment variable keys shared across components.""" - -RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" -RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" diff --git a/nanobot/utils/restart.py b/nanobot/utils/restart.py new file mode 100644 index 000000000..35b8cced5 --- /dev/null +++ b/nanobot/utils/restart.py @@ -0,0 +1,58 @@ +"""Helpers for restart notification messages.""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass + +RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" +RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" +RESTART_STARTED_AT_ENV = "NANOBOT_RESTART_STARTED_AT" + + +@dataclass(frozen=True) +class RestartNotice: + channel: str + chat_id: str + started_at_raw: str + + +def format_restart_completed_message(started_at_raw: str) -> str: + """Build restart completion text and include elapsed time when available.""" + elapsed_suffix = "" + if started_at_raw: + try: + elapsed_s = max(0.0, time.time() - float(started_at_raw)) + elapsed_suffix = f" in {elapsed_s:.1f}s" + except ValueError: + pass + return f"Restart completed{elapsed_suffix}." + + +def set_restart_notice_to_env(*, channel: str, chat_id: str) -> None: + """Write restart notice env values for the next process.""" + os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel + os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id + os.environ[RESTART_STARTED_AT_ENV] = str(time.time()) + + +def consume_restart_notice_from_env() -> RestartNotice | None: + """Read and clear restart notice env values once for this process.""" + channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() + chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() + started_at_raw = os.environ.pop(RESTART_STARTED_AT_ENV, "").strip() + if not (channel and chat_id): + return None + return RestartNotice(channel=channel, chat_id=chat_id, started_at_raw=started_at_raw) + + +def should_show_cli_restart_notice(notice: RestartNotice, session_id: str) -> bool: + """Return True when a restart notice should be shown in this CLI session.""" + if notice.channel != "cli": + return False + if ":" in session_id: + _, cli_chat_id = session_id.split(":", 1) + else: + cli_chat_id = session_id + return not notice.chat_id or notice.chat_id == cli_chat_id diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 4cf4fab21..8bb95b532 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -13,6 +13,7 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager from nanobot.config.schema import ChannelsConfig +from nanobot.utils.restart import RestartNotice # --------------------------------------------------------------------------- @@ -929,3 +930,30 @@ async def test_start_all_creates_dispatch_task(): # Dispatch task should have been created assert mgr._dispatch_task is not None + +@pytest.mark.asyncio +async def test_notify_restart_done_enqueues_outbound_message(): + """Restart notice should schedule send_with_retry for target channel.""" + fake_config = SimpleNamespace( + channels=ChannelsConfig(), + providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), + ) + + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + mgr.channels = {"feishu": _StartableChannel(fake_config, mgr.bus)} + mgr._dispatch_task = None + mgr._send_with_retry = AsyncMock() + + notice = RestartNotice(channel="feishu", chat_id="oc_123", started_at_raw="100.0") + with patch("nanobot.channels.manager.consume_restart_notice_from_env", return_value=notice): + mgr._notify_restart_done_if_needed() + + await asyncio.sleep(0) + mgr._send_with_retry.assert_awaited_once() + sent_channel, sent_msg = mgr._send_with_retry.await_args.args + assert sent_channel is mgr.channels["feishu"] + assert sent_msg.channel == "feishu" + assert sent_msg.chat_id == "oc_123" + assert sent_msg.content.startswith("Restart completed") diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 16b3aaa48..8ea30f684 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import os import time -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -37,8 +36,12 @@ class TestRestartCommand: @pytest.mark.asyncio async def test_restart_sends_message_and_calls_execv(self): from nanobot.command.builtin import cmd_restart - from nanobot.config.runtime_keys import RESTART_NOTIFY_CHANNEL_ENV, RESTART_NOTIFY_CHAT_ID_ENV from nanobot.command.router import CommandContext + from nanobot.utils.restart import ( + RESTART_NOTIFY_CHANNEL_ENV, + RESTART_NOTIFY_CHAT_ID_ENV, + RESTART_STARTED_AT_ENV, + ) loop, bus = _make_loop() msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") @@ -50,6 +53,7 @@ class TestRestartCommand: assert "Restarting" in out.content assert os.environ.get(RESTART_NOTIFY_CHANNEL_ENV) == "cli" assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct" + assert os.environ.get(RESTART_STARTED_AT_ENV) await asyncio.sleep(1.5) mock_execv.assert_called_once() @@ -196,76 +200,3 @@ class TestRestartCommand: assert response is not None assert response.metadata == {"render_as": "text"} - - -@pytest.mark.asyncio -async def test_notify_restart_done_waits_until_channel_running() -> None: - from nanobot.bus.queue import MessageBus - from nanobot.cli.commands import _notify_restart_done_when_channel_ready - - bus = MessageBus() - channel = SimpleNamespace(is_running=False) - - class DummyChannels: - enabled_channels = ["feishu"] - - @staticmethod - def get_channel(name: str): - return channel if name == "feishu" else None - - async def _mark_running() -> None: - await asyncio.sleep(0.02) - channel.is_running = True - - marker = asyncio.create_task(_mark_running()) - sent = await _notify_restart_done_when_channel_ready( - bus=bus, - channels=DummyChannels(), - channel="feishu", - chat_id="oc_123", - timeout_s=0.2, - poll_s=0.01, - ) - await marker - - assert sent is True - out = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1) - assert out.channel == "feishu" - assert out.chat_id == "oc_123" - assert out.content == "Restart completed." - - -@pytest.mark.asyncio -async def test_notify_restart_done_times_out_when_channel_not_running() -> None: - from nanobot.bus.queue import MessageBus - from nanobot.cli.commands import _notify_restart_done_when_channel_ready - - bus = MessageBus() - channel = SimpleNamespace(is_running=False) - - class DummyChannels: - enabled_channels = ["feishu"] - - @staticmethod - def get_channel(name: str): - return channel if name == "feishu" else None - - sent = await _notify_restart_done_when_channel_ready( - bus=bus, - channels=DummyChannels(), - channel="feishu", - chat_id="oc_123", - timeout_s=0.05, - poll_s=0.01, - ) - assert sent is False - assert bus.outbound_size == 0 - - -def test_should_show_cli_restart_notice() -> None: - from nanobot.cli.commands import _should_show_cli_restart_notice - - assert _should_show_cli_restart_notice("cli", "direct", "cli:direct") is True - assert _should_show_cli_restart_notice("cli", "", "cli:direct") is True - assert _should_show_cli_restart_notice("cli", "other", "cli:direct") is False - assert _should_show_cli_restart_notice("feishu", "oc_123", "cli:direct") is False diff --git a/tests/utils/test_restart.py b/tests/utils/test_restart.py new file mode 100644 index 000000000..48124d383 --- /dev/null +++ b/tests/utils/test_restart.py @@ -0,0 +1,49 @@ +"""Tests for restart notice helpers.""" + +from __future__ import annotations + +import os + +from nanobot.utils.restart import ( + RestartNotice, + consume_restart_notice_from_env, + format_restart_completed_message, + set_restart_notice_to_env, + should_show_cli_restart_notice, +) + + +def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch): + monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False) + monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False) + monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False) + + set_restart_notice_to_env(channel="feishu", chat_id="oc_123") + + notice = consume_restart_notice_from_env() + assert notice is not None + assert notice.channel == "feishu" + assert notice.chat_id == "oc_123" + assert notice.started_at_raw + + # Consumed values should be cleared from env. + assert consume_restart_notice_from_env() is None + assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ + assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ + assert "NANOBOT_RESTART_STARTED_AT" not in os.environ + + +def test_format_restart_completed_message_with_elapsed(monkeypatch): + monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0) + assert format_restart_completed_message("100.0") == "Restart completed in 2.0s." + + +def test_should_show_cli_restart_notice(): + notice = RestartNotice(channel="cli", chat_id="direct", started_at_raw="100") + assert should_show_cli_restart_notice(notice, "cli:direct") is True + assert should_show_cli_restart_notice(notice, "cli:other") is False + assert should_show_cli_restart_notice(notice, "direct") is True + + non_cli = RestartNotice(channel="feishu", chat_id="oc_1", started_at_raw="100") + assert should_show_cli_restart_notice(non_cli, "cli:direct") is False +