fix(P1): 6 priority-1 bug fixes for autonomous engine and mood system
#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.
This commit is contained in:
22
bot/api.py
22
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...")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
action_type = autonomous_engine.should_take_action(guild_id, triggered_by_message=True)
|
||||
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}")
|
||||
if action_type:
|
||||
logger.info(f"[V2] Message triggered autonomous action: {action_type}")
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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')}")
|
||||
|
||||
# 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}")
|
||||
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')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing message-triggered action: {e}")
|
||||
# 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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user