fix(tasks): replace fire-and-forget asyncio.create_task with create_tracked_task

Add utils/task_tracker.py with create_tracked_task() that wraps background
tasks with error logging, cancellation handling, and reference tracking.

Replace all 17 fire-and-forget asyncio.create_task() calls across 7 files:
- bot/bot.py (5 interjection checks)
- bot/utils/autonomous.py (2 check-and-act/react tasks)
- bot/utils/bipolar_mode.py (3 argument tasks)
- bot/commands/uno.py (1 game loop task)
- bot/utils/voice_receiver.py (3 STT/interruption callbacks)
- bot/utils/persona_dialogue.py (4 dialogue turn/interjection tasks)

Previously-tracked tasks (voice_audio.py, voice_manager.py) were left as-is
since they already store task references for cancellation.

Closes #1
This commit is contained in:
2026-02-18 12:01:08 +02:00
parent cf55b15745
commit 7b7abcfc68
7 changed files with 104 additions and 25 deletions

View File

@@ -10,6 +10,7 @@ from utils.autonomous_engine import autonomous_engine
from server_manager import server_manager
import globals
from utils.logger import get_logger
from utils.task_tracker import create_tracked_task
logger = get_logger('autonomous')
@@ -166,10 +167,10 @@ def on_message_event(message):
# Check if we should act (async, non-blocking)
if not message.author.bot: # Only check for human messages
asyncio.create_task(_check_and_act(guild_id))
create_tracked_task(_check_and_act(guild_id), task_name="autonomous_check_act")
# Also check if we should react to this specific message
asyncio.create_task(_check_and_react(guild_id, message))
create_tracked_task(_check_and_react(guild_id, message), task_name="autonomous_check_react")
async def _check_and_react(guild_id: int, message):

View File

@@ -12,6 +12,7 @@ import asyncio
import discord
import globals
from utils.logger import get_logger
from utils.task_tracker import create_tracked_task
logger = get_logger('persona')
@@ -1113,7 +1114,7 @@ async def maybe_trigger_argument(channel: discord.TextChannel, client, context:
if should_trigger_argument():
# Run argument in background
asyncio.create_task(run_argument(channel, client, context))
create_tracked_task(run_argument(channel, client, context), task_name="bipolar_argument")
return True
return False
@@ -1136,7 +1137,7 @@ async def force_trigger_argument(channel: discord.TextChannel, client, context:
logger.warning("Argument already in progress in this channel")
return False
asyncio.create_task(run_argument(channel, client, context, starting_message))
create_tracked_task(run_argument(channel, client, context, starting_message), task_name="bipolar_argument_forced")
return True
@@ -1174,5 +1175,5 @@ async def force_trigger_argument_from_message_id(channel_id: int, message_id: in
return False, f"Failed to fetch message: {str(e)}"
# Trigger the argument with this message as starting point
asyncio.create_task(run_argument(channel, client, context, message))
create_tracked_task(run_argument(channel, client, context, message), task_name="bipolar_argument_from_msg")
return True, None

View File

@@ -20,6 +20,7 @@ import asyncio
import time
import globals
from utils.logger import get_logger
from utils.task_tracker import create_tracked_task
logger = get_logger('persona')
@@ -668,15 +669,16 @@ You can use emojis naturally! ✨💙"""
opposite = "evil" if responding_persona == "miku" else "miku"
if should_continue and confidence in ["HIGH", "MEDIUM"]:
asyncio.create_task(self._next_turn(channel, opposite))
create_tracked_task(self._next_turn(channel, opposite), task_name="persona_next_turn")
elif should_continue and confidence == "LOW":
asyncio.create_task(self._next_turn(channel, opposite))
create_tracked_task(self._next_turn(channel, opposite), task_name="persona_next_turn")
elif not should_continue and confidence == "LOW":
# Offer opposite persona the last word
asyncio.create_task(
self._offer_last_word(channel, opposite, context + f"\n{responding_persona}: {response_text}")
create_tracked_task(
self._offer_last_word(channel, opposite, context + f"\n{responding_persona}: {response_text}"),
task_name="persona_last_word"
)
else:
# Clear signal to end
@@ -788,7 +790,7 @@ Don't force a response if you have nothing meaningful to contribute."""
logger.info(f"Dialogue ended after last word, {state['turn_count']} turns total")
self.end_dialogue(channel.id)
else:
asyncio.create_task(self._next_turn(channel, opposite))
create_tracked_task(self._next_turn(channel, opposite), task_name="persona_next_turn")
# ========================================================================
# ARGUMENT ESCALATION
@@ -953,8 +955,9 @@ async def check_for_interjection(message: discord.Message, current_persona: str)
# Start dialogue with the opposite persona responding first
dialogue_manager.start_dialogue(message.channel.id)
asyncio.create_task(
dialogue_manager.handle_dialogue_turn(message.channel, opposite_persona, trigger_reason=reason)
create_tracked_task(
dialogue_manager.handle_dialogue_turn(message.channel, opposite_persona, trigger_reason=reason),
task_name="persona_dialogue_turn"
)
return True

54
bot/utils/task_tracker.py Normal file
View File

@@ -0,0 +1,54 @@
# utils/task_tracker.py
"""
Tracked asyncio task creation utility.
Replaces fire-and-forget asyncio.create_task() calls with error-logging wrappers
so that exceptions in background tasks are never silently swallowed.
"""
import asyncio
from typing import Optional, Coroutine, Set
from utils.logger import get_logger
logger = get_logger("task_tracker")
# Keep references to running tasks so they aren't garbage-collected
_active_tasks: Set[asyncio.Task] = set()
def create_tracked_task(
coro: Coroutine,
task_name: Optional[str] = None,
) -> asyncio.Task:
"""
Create an asyncio task with automatic error logging.
Unlike bare asyncio.create_task(), this wrapper:
- Names the task for easier debugging
- Logs any unhandled exception (with full traceback) instead of swallowing it
- Keeps a strong reference so the task isn't garbage-collected mid-flight
- Auto-cleans the reference set when the task finishes
Args:
coro: The coroutine to schedule.
task_name: Human-readable name for log messages.
Defaults to the coroutine's __qualname__.
Returns:
The created asyncio.Task (tracked internally).
"""
name = task_name or getattr(coro, "__qualname__", str(coro))
async def _wrapped():
try:
await coro
except asyncio.CancelledError:
logger.debug(f"Task '{name}' was cancelled")
raise # re-raise so Task.cancelled() works correctly
except Exception:
logger.error(f"Background task '{name}' failed", exc_info=True)
task = asyncio.create_task(_wrapped(), name=name)
_active_tasks.add(task)
task.add_done_callback(_active_tasks.discard)
return task

View File

@@ -17,6 +17,7 @@ import discord
from discord.ext import voice_recv
from utils.stt_client import STTClient
from utils.task_tracker import create_tracked_task
logger = logging.getLogger('voice_receiver')
@@ -256,11 +257,11 @@ class VoiceReceiverSink(voice_recv.AudioSink):
stt_client = STTClient(
user_id=user_id,
stt_url=self.stt_url,
on_partial_transcript=lambda text, timestamp: asyncio.create_task(
self._on_partial_transcript(user_id, text)
on_partial_transcript=lambda text, timestamp: create_tracked_task(
self._on_partial_transcript(user_id, text), task_name="stt_partial_transcript"
),
on_final_transcript=lambda text, timestamp: asyncio.create_task(
self._on_final_transcript(user_id, text, user)
on_final_transcript=lambda text, timestamp: create_tracked_task(
self._on_final_transcript(user_id, text, user), task_name="stt_final_transcript"
),
)
@@ -421,8 +422,9 @@ class VoiceReceiverSink(voice_recv.AudioSink):
self.interruption_audio_count.pop(user_id, None)
# Call interruption handler (this sets miku_speaking=False)
asyncio.create_task(
self.voice_manager.on_user_interruption(user_id)
create_tracked_task(
self.voice_manager.on_user_interruption(user_id),
task_name="voice_user_interruption"
)
else:
# Audio below RMS threshold (silence) - reset interruption tracking