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:
2026-02-23 13:31:15 +02:00
parent 422366df4c
commit 0e4aebf353
6 changed files with 98 additions and 101 deletions

View File

@@ -762,13 +762,6 @@ async def set_server_mood_endpoint(guild_id: int, data: MoodSetRequest):
logger.debug(f"Server mood set result: {success}") logger.debug(f"Server mood set result: {success}")
if 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 # Update the nickname for this server
from utils.moods import update_server_nickname from utils.moods import update_server_nickname
logger.debug(f"Updating nickname for server {guild_id}") 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}") logger.debug(f"Server mood reset result: {success}")
if 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 # Update the nickname for this server
from utils.moods import update_server_nickname from utils.moods import update_server_nickname
logger.debug(f"Updating nickname for server {guild_id}") 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}") logger.debug(f"TEST: Mood set result: {success}")
if 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 # Try to update nickname
from utils.moods import update_server_nickname from utils.moods import update_server_nickname
logger.debug(f"TEST: Attempting nickname update...") logger.debug(f"TEST: Attempting nickname update...")

View File

@@ -4,7 +4,7 @@ import json
import os import os
import asyncio import asyncio
from typing import Dict, List, Optional, Set 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 from datetime import datetime, timedelta
import discord import discord
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -39,9 +39,9 @@ class ServerConfig:
current_mood_description: str = "" current_mood_description: str = ""
previous_mood_name: str = "neutral" previous_mood_name: str = "neutral"
is_sleeping: bool = False is_sleeping: bool = False
sleepy_responses_left: int = None sleepy_responses_left: Optional[int] = None
angry_wakeup_timer = None angry_wakeup_timer: Optional[float] = None # Unused, kept for structural completeness
forced_angry_until = None forced_angry_until: Optional[str] = None # ISO format datetime string, or None
just_woken_up: bool = False just_woken_up: bool = False
def to_dict(self): def to_dict(self):
@@ -64,6 +64,9 @@ class ServerConfig:
logger.warning(f"Failed to parse enabled_features string '{data['enabled_features']}': {e}") logger.warning(f"Failed to parse enabled_features string '{data['enabled_features']}': {e}")
# Fallback to default features # Fallback to default features
data['enabled_features'] = {"autonomous", "bedtime", "monday_video"} 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) return cls(**data)
class ServerManager: class ServerManager:
@@ -255,7 +258,12 @@ class ServerManager:
return server.current_mood_name, server.current_mood_description return server.current_mood_name, server.current_mood_description
def set_server_mood(self, guild_id: int, mood_name: str, mood_description: str = None): 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: if guild_id not in self.servers:
return False return False
@@ -274,9 +282,24 @@ class ServerManager:
logger.error(f"Failed to load mood description for {mood_name}: {e}") logger.error(f"Failed to load mood description for {mood_name}: {e}")
server.current_mood_description = f"I'm feeling {mood_name} today." 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() self.save_config()
logger.info(f"Server {server.guild_name} mood changed to: {mood_name}") 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 ''}") 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 return True
def get_server_sleep_state(self, guild_id: int) -> bool: 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_sleep_state(guild_id, False)
self.set_server_mood(guild_id, "neutral") 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 # Update nickname
try: try:
from utils.moods import update_server_nickname from utils.moods import update_server_nickname

View File

@@ -18,6 +18,15 @@ logger = get_logger('autonomous')
_last_action_execution = {} # guild_id -> timestamp _last_action_execution = {} # guild_id -> timestamp
_MIN_ACTION_INTERVAL = 30 # Minimum 30 seconds between autonomous actions _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 # Pause state for voice sessions
_autonomous_paused = False _autonomous_paused = False
@@ -94,9 +103,6 @@ async def autonomous_tick_v2(guild_id: int):
# Record that action was taken # Record that action was taken
autonomous_engine.record_action(guild_id) autonomous_engine.record_action(guild_id)
# Record that action was taken
autonomous_engine.record_action(guild_id)
# Update rate limiter # Update rate limiter
_last_action_execution[guild_id] = time.time() _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 IMPORTANT: Pass triggered_by_message=True so the engine knows to respond
to the message instead of saying something random/general. to the message instead of saying something random/general.
Uses per-guild lock to prevent race conditions from near-simultaneous messages.
""" """
# Rate limiting check async with _get_action_lock(guild_id):
now = time.time() # Rate limiting check
if guild_id in _last_action_execution: now = time.time()
time_since_last = now - _last_action_execution[guild_id] if guild_id in _last_action_execution:
if time_since_last < _MIN_ACTION_INTERVAL: time_since_last = now - _last_action_execution[guild_id]
return 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}")
# Execute the action directly (don't call autonomous_tick_v2 which would check again) action_type = autonomous_engine.should_take_action(guild_id, triggered_by_message=True)
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:
if action_type == "general": logger.info(f"[V2] Message triggered autonomous action: {action_type}")
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 # Execute the action directly (don't call autonomous_tick_v2 which would check again)
autonomous_engine.record_action(guild_id) 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: try:
from utils.bipolar_mode import maybe_trigger_argument, is_bipolar_mode if action_type == "general":
if is_bipolar_mode(): await miku_say_something_general_for_server(guild_id)
server_config = server_manager.servers.get(guild_id) elif action_type == "engage_user":
if server_config and server_config.autonomous_channel_id: await miku_engage_random_user_for_server(guild_id)
channel = globals.client.get_channel(server_config.autonomous_channel_id) elif action_type == "share_tweet":
if channel: await share_miku_tweet_for_server(guild_id)
await maybe_trigger_argument(channel, globals.client, "Triggered after message-based action") elif action_type == "join_conversation":
except Exception as bipolar_err: await miku_detect_and_join_conversation_for_server(guild_id)
logger.warning(f"Bipolar check error: {bipolar_err}") elif action_type == "change_profile_picture":
# Get current mood for this server
except Exception as e: mood, _ = server_manager.get_server_mood(guild_id)
logger.error(f"Error executing message-triggered action: {e}") 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): def on_presence_update(member, before, after):

View File

@@ -81,6 +81,7 @@ def load_autonomous_context() -> tuple[Dict[int, dict], Dict[int, float]]:
context_data = {} context_data = {}
last_action = {} 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(): for guild_id_str, server_data in data.get("servers", {}).items():
guild_id = int(guild_id_str) guild_id = int(guild_id_str)

View File

@@ -314,7 +314,7 @@ VARIATION RULES (必須のバリエーションルール):
# Add angry wake-up context if in forced angry state # Add angry wake-up context if in forced angry state
if forced_angry_until: if forced_angry_until:
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow().isoformat()
if now < forced_angry_until: if now < forced_angry_until:
system_prompt += ( system_prompt += (
"\n\n[NOTE]: Miku is currently angry because she was rudely woken up from sleep by the user. " "\n\n[NOTE]: Miku is currently angry because she was rudely woken up from sleep by the user. "

View File

@@ -270,7 +270,7 @@ async def rotate_server_mood(guild_id: int):
# Check for forced angry mode and clear if expired # Check for forced angry mode and clear if expired
if server_config.forced_angry_until: if server_config.forced_angry_until:
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow().isoformat()
if now < server_config.forced_angry_until: return if now < server_config.forced_angry_until: return
else: server_config.forced_angry_until = None 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)) 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 transitioning to asleep, set up auto-wake via centralized registry
if new_mood_name == "asleep": if new_mood_name == "asleep":
server_manager.set_server_sleep_state(guild_id, True) server_manager.set_server_sleep_state(guild_id, True)