fix(restart): deliver completion after channel reconnects (#4931)
This commit is contained in:
@@ -710,10 +710,11 @@ class DingTalkChannel(BaseChannel):
|
||||
"""Send a message through DingTalk."""
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
return
|
||||
raise RuntimeError("DingTalk access token unavailable")
|
||||
|
||||
if msg.content and msg.content.strip():
|
||||
await self._send_markdown_text(token, msg.chat_id, msg.content.strip())
|
||||
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
|
||||
raise RuntimeError("DingTalk text message was not delivered")
|
||||
|
||||
for media_ref in msg.media or []:
|
||||
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
||||
@@ -722,11 +723,12 @@ class DingTalkChannel(BaseChannel):
|
||||
self.logger.error("media send failed for {}", media_ref)
|
||||
# Send visible fallback so failures are observable by the user.
|
||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||
await self._send_markdown_text(
|
||||
if not await self._send_markdown_text(
|
||||
token,
|
||||
msg.chat_id,
|
||||
f"[Attachment send failed: {filename}]",
|
||||
)
|
||||
):
|
||||
raise RuntimeError("DingTalk attachment fallback was not delivered")
|
||||
|
||||
async def _on_message(
|
||||
self,
|
||||
|
||||
@@ -264,7 +264,7 @@ if DISCORD_AVAILABLE:
|
||||
channel = await self.fetch_channel(channel_id)
|
||||
except Exception as e:
|
||||
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
||||
return
|
||||
raise
|
||||
|
||||
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
|
||||
sent_media = False
|
||||
@@ -466,8 +466,7 @@ class DiscordChannel(BaseChannel):
|
||||
"""Send a message through Discord using discord.py."""
|
||||
client = self._client
|
||||
if client is None or not client.is_ready():
|
||||
self.logger.warning("client not ready; dropping outbound message")
|
||||
return
|
||||
raise RuntimeError("Discord client is not ready")
|
||||
|
||||
is_progress = isinstance(msg.event, ProgressEvent)
|
||||
|
||||
|
||||
@@ -2407,7 +2407,14 @@ class FeishuChannel(BaseChannel):
|
||||
if ok:
|
||||
return
|
||||
# Fall back to regular send if reply fails
|
||||
self._send_message_sync(receive_id_type, msg.chat_id, m_type, content)
|
||||
message_id = self._send_message_sync(
|
||||
receive_id_type,
|
||||
msg.chat_id,
|
||||
m_type,
|
||||
content,
|
||||
)
|
||||
if not message_id:
|
||||
raise RuntimeError(f"Feishu {m_type} message was not delivered")
|
||||
|
||||
for file_path in msg.media:
|
||||
if not os.path.isfile(file_path):
|
||||
|
||||
+68
-14
@@ -28,7 +28,11 @@ from nanobot.channels._feishu_instances import ChannelInstanceSpec, feishu_insta
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
|
||||
from nanobot.utils.restart import (
|
||||
RestartNotice,
|
||||
consume_restart_notice_from_env,
|
||||
format_restart_completed_message,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -46,6 +50,8 @@ def _default_webui_dist() -> Path | None:
|
||||
|
||||
# Retry delays for message sending (exponential backoff: 1s, 2s, 4s)
|
||||
_SEND_RETRY_DELAYS = (1, 2, 4)
|
||||
_RESTART_NOTICE_START_TIMEOUT_S = 30.0
|
||||
_RESTART_NOTICE_START_POLL_S = 0.25
|
||||
|
||||
_BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||
"send_progress": "sendProgress",
|
||||
@@ -476,15 +482,40 @@ class ChannelManager:
|
||||
# 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."""
|
||||
def _notify_restart_done_if_needed(self) -> asyncio.Task[None] | None:
|
||||
"""Schedule restart completion after the target channel starts."""
|
||||
notice = consume_restart_notice_from_env()
|
||||
if not notice:
|
||||
return
|
||||
return None
|
||||
return asyncio.create_task(self._send_restart_notice_when_started(notice))
|
||||
|
||||
async def _send_restart_notice_when_started(
|
||||
self,
|
||||
notice: RestartNotice,
|
||||
*,
|
||||
timeout_s: float = _RESTART_NOTICE_START_TIMEOUT_S,
|
||||
poll_s: float = _RESTART_NOTICE_START_POLL_S,
|
||||
) -> None:
|
||||
"""Deliver a restart notice after the target channel starts."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_s
|
||||
target = self.channels.get(notice.channel)
|
||||
if not target:
|
||||
if target is None:
|
||||
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
|
||||
return
|
||||
asyncio.create_task(self._send_with_retry(
|
||||
|
||||
while not target.is_running:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
logger.warning(
|
||||
"Restart notice target did not start: {}:{}",
|
||||
notice.channel,
|
||||
notice.chat_id,
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(min(poll_s, remaining))
|
||||
|
||||
await self._send_with_retry(
|
||||
target,
|
||||
OutboundMessage(
|
||||
channel=notice.channel,
|
||||
@@ -492,7 +523,8 @@ class ChannelManager:
|
||||
content=format_restart_completed_message(notice.started_at_raw),
|
||||
metadata=dict(notice.metadata or {}),
|
||||
),
|
||||
))
|
||||
deadline=deadline,
|
||||
)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
"""Stop all channels and the dispatcher."""
|
||||
@@ -791,30 +823,52 @@ class ChannelManager:
|
||||
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
|
||||
return merged, non_matching
|
||||
|
||||
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||
async def _send_with_retry(
|
||||
self,
|
||||
channel: BaseChannel,
|
||||
msg: OutboundMessage,
|
||||
*,
|
||||
deadline: float | None = None,
|
||||
) -> None:
|
||||
"""Send a message with retry on failure using exponential backoff.
|
||||
|
||||
When deadline is provided, retry until that monotonic time instead of
|
||||
stopping at the configured attempt limit.
|
||||
|
||||
Note: CancelledError is re-raised to allow graceful shutdown.
|
||||
"""
|
||||
max_attempts = max(self.config.channels.send_max_retries, 1)
|
||||
attempt = 0
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
await self._send_once(channel, msg)
|
||||
return # Send succeeded
|
||||
except asyncio.CancelledError:
|
||||
raise # Propagate cancellation for graceful shutdown
|
||||
except Exception as e:
|
||||
if attempt == max_attempts - 1:
|
||||
loop = asyncio.get_running_loop()
|
||||
exhausted = (
|
||||
attempt >= max_attempts
|
||||
if deadline is None
|
||||
else loop.time() >= deadline
|
||||
)
|
||||
if exhausted:
|
||||
logger.exception(
|
||||
"Failed to send to {} after {} attempts",
|
||||
msg.channel, max_attempts
|
||||
msg.channel, attempt,
|
||||
)
|
||||
return
|
||||
delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)]
|
||||
delay = _SEND_RETRY_DELAYS[min(attempt - 1, len(_SEND_RETRY_DELAYS) - 1)]
|
||||
if deadline is not None:
|
||||
delay = min(delay, max(0.0, deadline - loop.time()))
|
||||
attempt_label = str(attempt)
|
||||
if deadline is None:
|
||||
attempt_label = f"{attempt}/{max_attempts}"
|
||||
logger.warning(
|
||||
"Send to {} failed (attempt {}/{}): {}, retrying in {}s",
|
||||
msg.channel, attempt + 1, max_attempts, type(e).__name__, delay
|
||||
"Send to {} failed (attempt {}): {}, retrying in {}s",
|
||||
msg.channel, attempt_label, type(e).__name__, delay,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
@@ -559,7 +559,7 @@ class MatrixChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send outbound content; clear typing for non-progress messages."""
|
||||
if not self.client:
|
||||
return
|
||||
raise RuntimeError("Matrix client not initialized")
|
||||
text = msg.content or ""
|
||||
candidates = self._collect_outbound_media_candidates(msg.media)
|
||||
relates_to = self._build_thread_relates_to(msg.metadata)
|
||||
@@ -582,7 +582,9 @@ class MatrixChannel(BaseChannel):
|
||||
content = _build_matrix_text_content(text)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
await self._send_room_content(msg.chat_id, content)
|
||||
response = await self._send_room_content(msg.chat_id, content)
|
||||
if isinstance(response, RoomSendError):
|
||||
raise RuntimeError(f"Matrix message was not delivered: {response}")
|
||||
finally:
|
||||
if not is_progress:
|
||||
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
||||
|
||||
@@ -430,8 +430,7 @@ class NapcatChannel(BaseChannel):
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
if self._ws is None:
|
||||
logger.warning("napcat: not connected, dropping outbound message")
|
||||
return
|
||||
raise RuntimeError("napcat: not connected")
|
||||
|
||||
kind, _, target = msg.chat_id.partition(":")
|
||||
if kind not in ("private", "group") or not target:
|
||||
|
||||
@@ -243,8 +243,7 @@ class QQChannel(BaseChannel):
|
||||
"""Send attachments first, then text."""
|
||||
try:
|
||||
if not self._client:
|
||||
self.logger.warning("client not initialized")
|
||||
return
|
||||
raise RuntimeError("QQ client not initialized")
|
||||
|
||||
msg_id = msg.metadata.get("message_id")
|
||||
chat_type = self._chat_type_cache.get(msg.chat_id, "c2c")
|
||||
@@ -284,6 +283,7 @@ class QQChannel(BaseChannel):
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
|
||||
raise
|
||||
|
||||
async def _send_text_only(
|
||||
self,
|
||||
|
||||
@@ -493,8 +493,7 @@ class WecomChannel(BaseChannel):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through WeCom."""
|
||||
if not self._client:
|
||||
self.logger.warning("client not initialized")
|
||||
return
|
||||
raise RuntimeError("WeCom client not initialized")
|
||||
|
||||
try:
|
||||
content = (msg.content or "").strip()
|
||||
@@ -553,3 +552,4 @@ class WecomChannel(BaseChannel):
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Error sending message to chat_id={}", msg.chat_id)
|
||||
raise
|
||||
|
||||
@@ -9,6 +9,8 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||
|
||||
RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL"
|
||||
RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID"
|
||||
RESTART_NOTIFY_METADATA_ENV = "NANOBOT_RESTART_NOTIFY_METADATA"
|
||||
@@ -40,9 +42,14 @@ def set_restart_notice_to_env(
|
||||
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())
|
||||
if metadata:
|
||||
persisted_metadata = dict(metadata or {})
|
||||
persisted_metadata.pop(WEBUI_TURN_METADATA_KEY, None)
|
||||
if persisted_metadata:
|
||||
try:
|
||||
os.environ[RESTART_NOTIFY_METADATA_ENV] = json.dumps(metadata, default=str)
|
||||
os.environ[RESTART_NOTIFY_METADATA_ENV] = json.dumps(
|
||||
persisted_metadata,
|
||||
default=str,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
os.environ.pop(RESTART_NOTIFY_METADATA_ENV, None)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user