From 0e4aebf3539dc5307ed9cd711eb0475b4df57647 Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Mon, 23 Feb 2026 13:31:15 +0200 Subject: [PATCH] fix(P1): 6 priority-1 bug fixes for autonomous engine and mood system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4 Sleep/mood desync — set_server_mood() now clears is_sleeping when mood changes away from 'asleep', preventing ghost-sleep state. #5 Race condition in _check_and_act — added per-guild asyncio.Lock so overlapping ticks + message-triggered calls cannot fire concurrently. #6 Class-level attrs on ServerConfig — sleepy_responses_left, angry_wakeup_timer, and forced_angry_until are now proper dataclass fields with defaults, so asdict()/from_dict() round-trip correctly. Also strips unknown keys in from_dict() to survive schema changes. #7 Persistence decay_factor crash — initialise decay_factor = 1.0 before the loop so empty-server or zero-downtime paths don't raise NameError. #8 Double record_action — removed the redundant call in autonomous_tick_v2(); only _check_and_act records the action now. #9 Engine mood desync — on_mood_change() is now called inside set_server_mood() (single source of truth) and removed from 4 call-sites in api.py, moods.py, and server_manager wakeup task. --- bot/api.py | 22 ----- bot/server_manager.py | 40 ++++++--- bot/utils/autonomous.py | 125 +++++++++++++++------------- bot/utils/autonomous_persistence.py | 1 + bot/utils/llm.py | 2 +- bot/utils/moods.py | 9 +- 6 files changed, 98 insertions(+), 101 deletions(-) diff --git a/bot/api.py b/bot/api.py index a8f2dbd..58eae21 100644 --- a/bot/api.py +++ b/bot/api.py @@ -762,13 +762,6 @@ async def set_server_mood_endpoint(guild_id: int, data: MoodSetRequest): logger.debug(f"Server mood set result: {success}") if success: - # V2: Notify autonomous engine of mood change - try: - from utils.autonomous import on_mood_change - on_mood_change(guild_id, data.mood) - except Exception as e: - logger.error(f"Failed to notify autonomous engine of mood change: {e}") - # Update the nickname for this server from utils.moods import update_server_nickname logger.debug(f"Updating nickname for server {guild_id}") @@ -793,13 +786,6 @@ async def reset_server_mood_endpoint(guild_id: int): logger.debug(f"Server mood reset result: {success}") if success: - # V2: Notify autonomous engine of mood change - try: - from utils.autonomous import on_mood_change - on_mood_change(guild_id, "neutral") - except Exception as e: - logger.error(f"Failed to notify autonomous engine of mood reset: {e}") - # Update the nickname for this server from utils.moods import update_server_nickname logger.debug(f"Updating nickname for server {guild_id}") @@ -1860,14 +1846,6 @@ async def test_mood_change(guild_id: int, data: MoodSetRequest): logger.debug(f"TEST: Mood set result: {success}") if success: - # V2: Notify autonomous engine of mood change - try: - from utils.autonomous import on_mood_change - on_mood_change(guild_id, data.mood) - logger.debug(f"TEST: Notified autonomous engine of mood change") - except Exception as e: - logger.error(f"TEST: Failed to notify autonomous engine: {e}") - # Try to update nickname from utils.moods import update_server_nickname logger.debug(f"TEST: Attempting nickname update...") diff --git a/bot/server_manager.py b/bot/server_manager.py index a8d9c06..554cd06 100644 --- a/bot/server_manager.py +++ b/bot/server_manager.py @@ -4,7 +4,7 @@ import json import os import asyncio from typing import Dict, List, Optional, Set -from dataclasses import dataclass, asdict +from dataclasses import dataclass, asdict, fields as dataclass_fields from datetime import datetime, timedelta import discord from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -39,9 +39,9 @@ class ServerConfig: current_mood_description: str = "" previous_mood_name: str = "neutral" is_sleeping: bool = False - sleepy_responses_left: int = None - angry_wakeup_timer = None - forced_angry_until = None + sleepy_responses_left: Optional[int] = None + angry_wakeup_timer: Optional[float] = None # Unused, kept for structural completeness + forced_angry_until: Optional[str] = None # ISO format datetime string, or None just_woken_up: bool = False def to_dict(self): @@ -64,6 +64,9 @@ class ServerConfig: logger.warning(f"Failed to parse enabled_features string '{data['enabled_features']}': {e}") # Fallback to default features data['enabled_features'] = {"autonomous", "bedtime", "monday_video"} + # Strip any keys that aren't valid dataclass fields (forward-compat safety) + valid_fields = {f.name for f in dataclass_fields(cls)} + data = {k: v for k, v in data.items() if k in valid_fields} return cls(**data) class ServerManager: @@ -255,7 +258,12 @@ class ServerManager: return server.current_mood_name, server.current_mood_description def set_server_mood(self, guild_id: int, mood_name: str, mood_description: str = None): - """Set mood for a specific server""" + """Set mood for a specific server. + + Also handles: + - Syncing is_sleeping state (fix #4: sleep/mood desync) + - Notifying the autonomous engine (fix #9: engine mood desync) + """ if guild_id not in self.servers: return False @@ -274,9 +282,24 @@ class ServerManager: logger.error(f"Failed to load mood description for {mood_name}: {e}") server.current_mood_description = f"I'm feeling {mood_name} today." + # Fix #4: Keep is_sleeping in sync with mood + # If mood changes away from 'asleep', clear sleeping state + if mood_name != "asleep" and server.is_sleeping: + server.is_sleeping = False + self.cancel_wakeup_task(guild_id) + logger.info(f"Cleared sleep state for server {server.guild_name} (mood changed to {mood_name})") + self.save_config() logger.info(f"Server {server.guild_name} mood changed to: {mood_name}") logger.debug(f"Mood description: {server.current_mood_description[:100]}{'...' if len(server.current_mood_description) > 100 else ''}") + + # Fix #9: Always notify autonomous engine of mood change + try: + from utils.autonomous import on_mood_change + on_mood_change(guild_id, mood_name) + except Exception as e: + logger.error(f"Failed to notify autonomous engine of mood change to {mood_name}: {e}") + return True def get_server_sleep_state(self, guild_id: int) -> bool: @@ -321,13 +344,6 @@ class ServerManager: self.set_server_sleep_state(guild_id, False) self.set_server_mood(guild_id, "neutral") - # Notify autonomous engine - try: - from utils.autonomous import on_mood_change - on_mood_change(guild_id, "neutral") - except Exception as e: - logger.error(f"Failed to notify autonomous engine of wake-up: {e}") - # Update nickname try: from utils.moods import update_server_nickname diff --git a/bot/utils/autonomous.py b/bot/utils/autonomous.py index f51a8e4..8ec5507 100644 --- a/bot/utils/autonomous.py +++ b/bot/utils/autonomous.py @@ -18,6 +18,15 @@ logger = get_logger('autonomous') _last_action_execution = {} # guild_id -> timestamp _MIN_ACTION_INTERVAL = 30 # Minimum 30 seconds between autonomous actions +# Per-guild locks to prevent race conditions from near-simultaneous messages +_action_locks: dict = {} # guild_id -> asyncio.Lock + +def _get_action_lock(guild_id: int) -> asyncio.Lock: + """Get or create an asyncio.Lock for a guild.""" + if guild_id not in _action_locks: + _action_locks[guild_id] = asyncio.Lock() + return _action_locks[guild_id] + # Pause state for voice sessions _autonomous_paused = False @@ -94,9 +103,6 @@ async def autonomous_tick_v2(guild_id: int): # Record that action was taken autonomous_engine.record_action(guild_id) - # Record that action was taken - autonomous_engine.record_action(guild_id) - # Update rate limiter _last_action_execution[guild_id] = time.time() @@ -201,67 +207,70 @@ async def _check_and_act(guild_id: int): IMPORTANT: Pass triggered_by_message=True so the engine knows to respond to the message instead of saying something random/general. + + Uses per-guild lock to prevent race conditions from near-simultaneous messages. """ - # Rate limiting check - now = time.time() - if guild_id in _last_action_execution: - time_since_last = now - _last_action_execution[guild_id] - if time_since_last < _MIN_ACTION_INTERVAL: - return - - action_type = autonomous_engine.should_take_action(guild_id, triggered_by_message=True) - - if action_type: - logger.info(f"[V2] Message triggered autonomous action: {action_type}") + async with _get_action_lock(guild_id): + # Rate limiting check + now = time.time() + if guild_id in _last_action_execution: + time_since_last = now - _last_action_execution[guild_id] + if time_since_last < _MIN_ACTION_INTERVAL: + return - # Execute the action directly (don't call autonomous_tick_v2 which would check again) - from utils.autonomous_v1_legacy import ( - miku_say_something_general_for_server, - miku_engage_random_user_for_server, - share_miku_tweet_for_server, - miku_detect_and_join_conversation_for_server - ) - from utils.profile_picture_manager import profile_picture_manager + action_type = autonomous_engine.should_take_action(guild_id, triggered_by_message=True) - try: - if action_type == "general": - await miku_say_something_general_for_server(guild_id) - elif action_type == "engage_user": - await miku_engage_random_user_for_server(guild_id) - elif action_type == "share_tweet": - await share_miku_tweet_for_server(guild_id) - elif action_type == "join_conversation": - await miku_detect_and_join_conversation_for_server(guild_id) - elif action_type == "change_profile_picture": - # Get current mood for this server - mood, _ = server_manager.get_server_mood(guild_id) - logger.info(f"[V2] Changing profile picture (mood: {mood})") - result = await profile_picture_manager.change_profile_picture(mood=mood, debug=True) - if result["success"]: - logger.info(f"Profile picture changed successfully!") - else: - logger.warning(f"Profile picture change failed: {result.get('error')}") + if action_type: + logger.info(f"[V2] Message triggered autonomous action: {action_type}") - # Record that action was taken - autonomous_engine.record_action(guild_id) + # Execute the action directly (don't call autonomous_tick_v2 which would check again) + from utils.autonomous_v1_legacy import ( + miku_say_something_general_for_server, + miku_engage_random_user_for_server, + share_miku_tweet_for_server, + miku_detect_and_join_conversation_for_server + ) + from utils.profile_picture_manager import profile_picture_manager - # Update rate limiter - _last_action_execution[guild_id] = time.time() - - # Check for bipolar argument trigger (only if bipolar mode is active) try: - from utils.bipolar_mode import maybe_trigger_argument, is_bipolar_mode - if is_bipolar_mode(): - server_config = server_manager.servers.get(guild_id) - if server_config and server_config.autonomous_channel_id: - channel = globals.client.get_channel(server_config.autonomous_channel_id) - if channel: - await maybe_trigger_argument(channel, globals.client, "Triggered after message-based action") - except Exception as bipolar_err: - logger.warning(f"Bipolar check error: {bipolar_err}") - - except Exception as e: - logger.error(f"Error executing message-triggered action: {e}") + if action_type == "general": + await miku_say_something_general_for_server(guild_id) + elif action_type == "engage_user": + await miku_engage_random_user_for_server(guild_id) + elif action_type == "share_tweet": + await share_miku_tweet_for_server(guild_id) + elif action_type == "join_conversation": + await miku_detect_and_join_conversation_for_server(guild_id) + elif action_type == "change_profile_picture": + # Get current mood for this server + mood, _ = server_manager.get_server_mood(guild_id) + logger.info(f"[V2] Changing profile picture (mood: {mood})") + result = await profile_picture_manager.change_profile_picture(mood=mood, debug=True) + if result["success"]: + logger.info(f"Profile picture changed successfully!") + else: + logger.warning(f"Profile picture change failed: {result.get('error')}") + + # Record that action was taken + autonomous_engine.record_action(guild_id) + + # Update rate limiter + _last_action_execution[guild_id] = time.time() + + # Check for bipolar argument trigger (only if bipolar mode is active) + try: + from utils.bipolar_mode import maybe_trigger_argument, is_bipolar_mode + if is_bipolar_mode(): + server_config = server_manager.servers.get(guild_id) + if server_config and server_config.autonomous_channel_id: + channel = globals.client.get_channel(server_config.autonomous_channel_id) + if channel: + await maybe_trigger_argument(channel, globals.client, "Triggered after message-based action") + except Exception as bipolar_err: + logger.warning(f"Bipolar check error: {bipolar_err}") + + except Exception as e: + logger.error(f"Error executing message-triggered action: {e}") def on_presence_update(member, before, after): diff --git a/bot/utils/autonomous_persistence.py b/bot/utils/autonomous_persistence.py index 71ed736..160048d 100644 --- a/bot/utils/autonomous_persistence.py +++ b/bot/utils/autonomous_persistence.py @@ -81,6 +81,7 @@ def load_autonomous_context() -> tuple[Dict[int, dict], Dict[int, float]]: context_data = {} last_action = {} + decay_factor = 1.0 # Default: no decay (in case loop doesn't execute) for guild_id_str, server_data in data.get("servers", {}).items(): guild_id = int(guild_id_str) diff --git a/bot/utils/llm.py b/bot/utils/llm.py index f39cb00..3068b1b 100644 --- a/bot/utils/llm.py +++ b/bot/utils/llm.py @@ -314,7 +314,7 @@ VARIATION RULES (必須のバリエーションルール): # Add angry wake-up context if in forced angry state if forced_angry_until: - now = datetime.datetime.utcnow() + now = datetime.datetime.utcnow().isoformat() if now < forced_angry_until: system_prompt += ( "\n\n[NOTE]: Miku is currently angry because she was rudely woken up from sleep by the user. " diff --git a/bot/utils/moods.py b/bot/utils/moods.py index 4104f1b..4725a2a 100644 --- a/bot/utils/moods.py +++ b/bot/utils/moods.py @@ -270,7 +270,7 @@ async def rotate_server_mood(guild_id: int): # Check for forced angry mode and clear if expired if server_config.forced_angry_until: - now = datetime.datetime.utcnow() + now = datetime.datetime.utcnow().isoformat() if now < server_config.forced_angry_until: return else: server_config.forced_angry_until = None @@ -292,13 +292,6 @@ async def rotate_server_mood(guild_id: int): server_manager.set_server_mood(guild_id, new_mood_name, load_mood_description(new_mood_name)) - # V2: Notify autonomous engine of mood change - try: - from utils.autonomous import on_mood_change - on_mood_change(guild_id, new_mood_name) - except Exception as mood_notify_error: - logger.error(f"Failed to notify autonomous engine of mood change: {mood_notify_error}") - # If transitioning to asleep, set up auto-wake via centralized registry if new_mood_name == "asleep": server_manager.set_server_sleep_state(guild_id, True)