fix(restart): preserve channel metadata across /restart so reply lands in thread

cmd_restart only persisted channel + chat_id across the os.execv boundary, so
when the new process announced "Restart completed" the OutboundMessage had
no Slack thread_ts and the reply fell back to the channel root.

Serialize msg.metadata into NANOBOT_RESTART_NOTIFY_METADATA, restore it on the
RestartNotice, and forward it to OutboundMessage so the completion message
follows the same routing as the original /restart invocation.

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-27 12:45:00 +08:00
committed by Xubin Ren
parent 1ef41052da
commit 1fe3f0eb22
4 changed files with 65 additions and 4 deletions
+1
View File
@@ -172,6 +172,7 @@ class ChannelManager:
channel=notice.channel, channel=notice.channel,
chat_id=notice.chat_id, chat_id=notice.chat_id,
content=format_restart_completed_message(notice.started_at_raw), content=format_restart_completed_message(notice.started_at_raw),
metadata=dict(notice.metadata or {}),
), ),
)) ))
+5 -1
View File
@@ -28,7 +28,11 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
async def cmd_restart(ctx: CommandContext) -> OutboundMessage: async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
"""Restart the process in-place via os.execv.""" """Restart the process in-place via os.execv."""
msg = ctx.msg msg = ctx.msg
set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id) set_restart_notice_to_env(
channel=msg.channel,
chat_id=msg.chat_id,
metadata=dict(msg.metadata or {}),
)
async def _do_restart(): async def _do_restart():
await asyncio.sleep(1) await asyncio.sleep(1)
+30 -3
View File
@@ -2,12 +2,15 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any
RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL" RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL"
RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID" RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID"
RESTART_NOTIFY_METADATA_ENV = "NANOBOT_RESTART_NOTIFY_METADATA"
RESTART_STARTED_AT_ENV = "NANOBOT_RESTART_STARTED_AT" RESTART_STARTED_AT_ENV = "NANOBOT_RESTART_STARTED_AT"
@@ -16,6 +19,7 @@ class RestartNotice:
channel: str channel: str
chat_id: str chat_id: str
started_at_raw: str started_at_raw: str
metadata: dict[str, Any] = field(default_factory=dict)
def format_restart_completed_message(started_at_raw: str) -> str: def format_restart_completed_message(started_at_raw: str) -> str:
@@ -30,11 +34,20 @@ def format_restart_completed_message(started_at_raw: str) -> str:
return f"Restart completed{elapsed_suffix}." return f"Restart completed{elapsed_suffix}."
def set_restart_notice_to_env(*, channel: str, chat_id: str) -> None: def set_restart_notice_to_env(
*, channel: str, chat_id: str, metadata: dict[str, Any] | None = None,
) -> None:
"""Write restart notice env values for the next process.""" """Write restart notice env values for the next process."""
os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel
os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id
os.environ[RESTART_STARTED_AT_ENV] = str(time.time()) os.environ[RESTART_STARTED_AT_ENV] = str(time.time())
if metadata:
try:
os.environ[RESTART_NOTIFY_METADATA_ENV] = json.dumps(metadata, default=str)
except (TypeError, ValueError):
os.environ.pop(RESTART_NOTIFY_METADATA_ENV, None)
else:
os.environ.pop(RESTART_NOTIFY_METADATA_ENV, None)
def consume_restart_notice_from_env() -> RestartNotice | None: def consume_restart_notice_from_env() -> RestartNotice | None:
@@ -42,9 +55,23 @@ def consume_restart_notice_from_env() -> RestartNotice | None:
channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip() channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip()
chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip() chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip()
started_at_raw = os.environ.pop(RESTART_STARTED_AT_ENV, "").strip() started_at_raw = os.environ.pop(RESTART_STARTED_AT_ENV, "").strip()
metadata_raw = os.environ.pop(RESTART_NOTIFY_METADATA_ENV, "").strip()
if not (channel and chat_id): if not (channel and chat_id):
return None return None
return RestartNotice(channel=channel, chat_id=chat_id, started_at_raw=started_at_raw) metadata: dict[str, Any] = {}
if metadata_raw:
try:
parsed = json.loads(metadata_raw)
except (TypeError, ValueError):
parsed = None
if isinstance(parsed, dict):
metadata = parsed
return RestartNotice(
channel=channel,
chat_id=chat_id,
started_at_raw=started_at_raw,
metadata=metadata,
)
def should_show_cli_restart_notice(notice: RestartNotice, session_id: str) -> bool: def should_show_cli_restart_notice(notice: RestartNotice, session_id: str) -> bool:
+29
View File
@@ -16,6 +16,7 @@ from nanobot.utils.restart import (
def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch): def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False) monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False) monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_METADATA", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False) monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
set_restart_notice_to_env(channel="feishu", chat_id="oc_123") set_restart_notice_to_env(channel="feishu", chat_id="oc_123")
@@ -25,14 +26,42 @@ def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
assert notice.channel == "feishu" assert notice.channel == "feishu"
assert notice.chat_id == "oc_123" assert notice.chat_id == "oc_123"
assert notice.started_at_raw assert notice.started_at_raw
assert notice.metadata == {}
# Consumed values should be cleared from env. # Consumed values should be cleared from env.
assert consume_restart_notice_from_env() is None assert consume_restart_notice_from_env() is None
assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ
assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
assert "NANOBOT_RESTART_STARTED_AT" not in os.environ assert "NANOBOT_RESTART_STARTED_AT" not in os.environ
def test_restart_notice_preserves_metadata_across_env(monkeypatch):
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_METADATA", raising=False)
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
set_restart_notice_to_env(
channel="slack",
chat_id="C123",
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
)
notice = consume_restart_notice_from_env()
assert notice is not None
assert notice.metadata == {
"slack": {"thread_ts": "1700.42", "channel_type": "channel"}
}
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
def test_restart_notice_clears_stale_metadata(monkeypatch):
monkeypatch.setenv("NANOBOT_RESTART_NOTIFY_METADATA", '{"stale": true}')
set_restart_notice_to_env(channel="cli", chat_id="direct")
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
def test_format_restart_completed_message_with_elapsed(monkeypatch): def test_format_restart_completed_message_with_elapsed(monkeypatch):
monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0) monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0)
assert format_restart_completed_message("100.0") == "Restart completed in 2.0s." assert format_restart_completed_message("100.0") == "Restart completed in 2.0s."