major-domo-v2/tasks/transaction_freeze.py
Claude 57b4dc0ce9
CLAUDE: Fix critical bug preventing Monday freeze/thaw from executing
## Problem
The weekly freeze/thaw system had a state flag initialization bug that prevented
both Monday freeze operations and Saturday thaw operations from executing.

## Root Cause (Introduced in commit 07f69eb on Oct 25, 2025)
The `weekly_warning_sent` boolean flag was being used for TWO different purposes:
1. Preventing duplicate freeze/thaw operations (state machine)
2. Preventing duplicate error notifications

The flag was initialized to `False`, but the Monday freeze condition required
it to be `True`:
- Line 205: `if ... and self.weekly_warning_sent:` (required True)
- Line 160: `self.weekly_warning_sent = False` (initialized to False)

This created a deadlock:
- Monday freeze: Never ran (flag was False, needed True)
- Saturday thaw: Never ran (freeze flag in DB never set to True)

## Impact
- First failure: Monday October 27, 2025
- Affected weeks: 2+ weeks of missed freeze/thaw operations
- Result: Week did NOT increment, transactions did NOT execute

## Solution
Separated the two concerns and replaced boolean flag with week tracking:

1. **Deduplication**: Track `last_freeze_week` and `last_thaw_week` (int | None)
   - Monday: Execute if `last_freeze_week != current.week`
   - Saturday: Execute if `last_thaw_week != current.week`
   - Prevents duplicate operations during same hour (loop runs every minute)

2. **Error notifications**: Separate `error_notification_sent` boolean flag
   - Only used for preventing duplicate error notifications
   - Clear separation of concerns

## Validation
Created and ran validation script simulating 7 scenarios across multiple weeks:
-  Monday freeze executes on first check
-  Monday freeze skips on subsequent checks same hour
-  Saturday thaw executes on first check
-  Saturday thaw skips on subsequent checks same hour
-  Next Monday freeze executes for new week
-  Week numbers increment correctly (19→20→21→22)
-  All state transitions work as expected

## Files Changed
- tasks/transaction_freeze.py:157-167 - Separated state tracking from error flags
- tasks/transaction_freeze.py:211-231 - Fixed freeze/thaw conditions with week tracking
- tasks/transaction_freeze.py:248-250 - Updated error notification flag name

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 17:56:44 +00:00

801 lines
30 KiB
Python

"""
Transaction Freeze/Thaw Task for Discord Bot v2.0
Automated weekly system for freezing and processing transactions.
Runs on a schedule to increment weeks and process contested transactions.
"""
import asyncio
import random
from datetime import datetime, UTC
from typing import Dict, List, Tuple, Set
from dataclasses import dataclass
import discord
from discord.ext import commands, tasks
from services.league_service import league_service
from services.transaction_service import transaction_service
from services.standings_service import standings_service
from models.current import Current
from models.transaction import Transaction
from utils.logging import get_contextual_logger
from views.embeds import EmbedTemplate, EmbedColors
from config import get_config
@dataclass
class TransactionPriority:
"""
Data class for transaction priority calculation.
Used to resolve contested transactions (multiple teams wanting same player).
"""
transaction: Transaction
team_win_percentage: float
tiebreaker: float # win% + small random number for randomized tiebreak
def __lt__(self, other):
"""Allow sorting by tiebreaker value."""
return self.tiebreaker < other.tiebreaker
async def resolve_contested_transactions(
transactions: List[Transaction],
season: int
) -> Tuple[List[str], List[str]]:
"""
Resolve contested transactions where multiple teams want the same player.
This is extracted as a pure function for testability.
Args:
transactions: List of all frozen transactions for the week
season: Current season number
Returns:
Tuple of (winning_move_ids, losing_move_ids)
"""
logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions')
# Group transactions by player name
player_transactions: Dict[str, List[Transaction]] = {}
for transaction in transactions:
player_name = transaction.player.name.lower()
# Only consider transactions where a team is acquiring a player (not FA drops)
if transaction.newteam.abbrev.upper() != 'FA':
if player_name not in player_transactions:
player_transactions[player_name] = []
player_transactions[player_name].append(transaction)
# Identify contested players (multiple teams want same player)
contested_players: Dict[str, List[Transaction]] = {}
non_contested_moves: Set[str] = set()
for player_name, player_transactions_list in player_transactions.items():
if len(player_transactions_list) > 1:
contested_players[player_name] = player_transactions_list
logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)")
else:
# Non-contested, automatically wins
non_contested_moves.add(player_transactions_list[0].moveid)
# Resolve contests using team priority (win% + random tiebreaker)
winning_move_ids: Set[str] = set()
losing_move_ids: Set[str] = set()
for player_name, contested_transactions in contested_players.items():
priorities: List[TransactionPriority] = []
for transaction in contested_transactions:
# Get team for priority calculation
# If adding to MiL team, use the parent ML team for standings
if transaction.newteam.abbrev.endswith('MiL'):
team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix
else:
team_abbrev = transaction.newteam.abbrev
try:
# Get team standings to calculate win percentage
standings = await standings_service.get_team_standings(team_abbrev, season)
if standings and standings.wins is not None and standings.losses is not None:
total_games = standings.wins + standings.losses
win_pct = standings.wins / total_games if total_games > 0 else 0.0
else:
win_pct = 0.0
logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%")
# Add small random component for tiebreaking (5 decimal precision)
random_component = random.randint(10000, 99999) * 0.00000001
tiebreaker = win_pct + random_component
priorities.append(TransactionPriority(
transaction=transaction,
team_win_percentage=win_pct,
tiebreaker=tiebreaker
))
except Exception as e:
logger.error(f"Error calculating priority for {team_abbrev}: {e}")
# Give them 0.0 priority on error
priorities.append(TransactionPriority(
transaction=transaction,
team_win_percentage=0.0,
tiebreaker=random.randint(10000, 99999) * 0.00000001
))
# Sort by tiebreaker (lowest win% wins - worst teams get priority)
priorities.sort()
# First team wins, rest lose
if priorities:
winner = priorities[0]
winning_move_ids.add(winner.transaction.moveid)
logger.info(
f"Contest resolved for {player_name}: {winner.transaction.newteam.abbrev} wins "
f"(win%: {winner.team_win_percentage:.3f}, tiebreaker: {winner.tiebreaker:.8f})"
)
for loser in priorities[1:]:
losing_move_ids.add(loser.transaction.moveid)
logger.info(
f"Contest lost for {player_name}: {loser.transaction.newteam.abbrev} "
f"(win%: {loser.team_win_percentage:.3f}, tiebreaker: {loser.tiebreaker:.8f})"
)
# Add non-contested moves to winners
winning_move_ids.update(non_contested_moves)
return list(winning_move_ids), list(losing_move_ids)
class TransactionFreezeTask:
"""Automated weekly freeze/thaw system for transactions."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask')
# Track last execution to prevent duplicate operations
self.last_freeze_week: int | None = None
self.last_thaw_week: int | None = None
# Track error notifications separately
self.error_notification_sent = False
self.logger.info("Transaction freeze/thaw task initialized")
# Start the weekly loop
self.weekly_loop.start()
def cog_unload(self):
"""Stop the task when cog is unloaded."""
self.weekly_loop.cancel()
@tasks.loop(minutes=1)
async def weekly_loop(self):
"""
Main loop that checks time and triggers freeze/thaw operations.
Runs every minute and checks:
- Monday 00:00 -> Begin freeze (increment week, set freeze flag)
- Saturday 00:00 -> End freeze (process frozen transactions)
"""
try:
self.logger.info("Weekly loop check starting")
config = get_config()
# Skip if offseason mode is enabled
if config.offseason_flag:
self.logger.info("Skipping freeze/thaw operations - offseason mode enabled")
return
# Get current league state
current = await league_service.get_current_state()
if not current:
self.logger.warning("Could not get current league state")
return
now = datetime.now()
self.logger.info(
f"Weekly loop check",
datetime=now.isoformat(),
weekday=now.weekday(),
hour=now.hour,
current_week=current.week,
freeze_status=current.freeze
)
# BEGIN FREEZE: Monday at 00:00, not already frozen
if now.weekday() == 0 and now.hour == 0 and not current.freeze:
# Only run if we haven't already frozen this week
# Track the week we're freezing FROM (before increment)
if self.last_freeze_week != current.week:
self.logger.info("Triggering freeze begin", current_week=current.week)
await self._begin_freeze(current)
self.last_freeze_week = current.week # Track the week we froze (before increment)
self.error_notification_sent = False # Reset error flag for new cycle
else:
self.logger.debug("Freeze already executed for week", week=current.week)
# END FREEZE: Saturday at 00:00, currently frozen
elif now.weekday() == 5 and now.hour == 0 and current.freeze:
# Only run if we haven't already thawed this week
if self.last_thaw_week != current.week:
self.logger.info("Triggering freeze end", current_week=current.week)
await self._end_freeze(current)
self.last_thaw_week = current.week
self.error_notification_sent = False # Reset error flag for new cycle
else:
self.logger.debug("Thaw already executed for week", week=current.week)
else:
self.logger.debug("No freeze/thaw action needed at this time")
except Exception as e:
self.logger.error(f"Unhandled exception in weekly_loop: {e}", exc_info=True)
error_message = (
f"⚠️ **Weekly Freeze Task Failed**\n"
f"```\n"
f"Error: {str(e)}\n"
f"Time: {datetime.now(UTC).isoformat()}\n"
f"Task: weekly_loop in transaction_freeze.py\n"
f"```"
)
try:
if not self.error_notification_sent:
await self._send_owner_notification(error_message)
self.error_notification_sent = True
except Exception as notify_error:
self.logger.error(f"Failed to send error notification: {notify_error}")
@weekly_loop.before_loop
async def before_weekly_loop(self):
"""Wait for bot to be ready before starting."""
await self.bot.wait_until_ready()
self.logger.info("Bot is ready, transaction freeze/thaw task starting")
async def _begin_freeze(self, current: Current):
"""
Begin weekly freeze period.
Actions:
1. Increment current week
2. Set freeze flag to True
3. Run regular transactions for current week
4. Send freeze announcement
5. Post weekly info (weeks 1-18 only)
"""
try:
self.logger.info(f"Beginning freeze for week {current.week}")
# Increment week and set freeze via service
new_week = current.week + 1
updated_current = await league_service.update_current_state(
week=new_week,
freeze=True
)
if not updated_current:
raise Exception("Failed to update current state during freeze begin")
self.logger.info(f"Week incremented to {new_week}, freeze set to True")
# Update local current object with returned data
current.week = updated_current.week
current.freeze = updated_current.freeze
# Run regular transactions for the new week
await self._run_transactions(current)
# Send freeze announcement
await self._send_freeze_announcement(current.week, is_beginning=True)
# Post weekly info for weeks 1-18
if 1 <= current.week <= 18:
await self._post_weekly_info(current)
self.logger.info(f"Freeze begin completed for week {current.week}")
except Exception as e:
self.logger.error(f"Error in _begin_freeze: {e}", exc_info=True)
raise
async def _end_freeze(self, current: Current):
"""
End weekly freeze period.
Actions:
1. Process frozen transactions with priority resolution
2. Set freeze flag to False
3. Send thaw announcement
"""
try:
self.logger.info(f"Ending freeze for week {current.week}")
# Process frozen transactions BEFORE unfreezing
await self._process_frozen_transactions(current)
# Set freeze to False via service
updated_current = await league_service.update_current_state(freeze=False)
if not updated_current:
raise Exception("Failed to update current state during freeze end")
self.logger.info(f"Freeze set to False for week {current.week}")
# Update local current object
current.freeze = updated_current.freeze
# Send thaw announcement
await self._send_freeze_announcement(current.week, is_beginning=False)
self.logger.info(f"Freeze end completed for week {current.week}")
except Exception as e:
self.logger.error(f"Error in _end_freeze: {e}", exc_info=True)
raise
async def _run_transactions(self, current: Current):
"""
Process regular (non-frozen) transactions for the current week.
These are transactions that take effect immediately.
"""
try:
# Get all non-frozen transactions for current week
client = await transaction_service.get_client()
params = [
('season', str(current.season)),
('week_start', str(current.week)),
('week_end', str(current.week))
]
response = await client.get('transactions', params=params)
if not response or response.get('count', 0) == 0:
self.logger.info(f"No regular transactions to process for week {current.week}")
return
transactions = response.get('transactions', [])
self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}")
# Execute player roster updates for all transactions
success_count = 0
failure_count = 0
for transaction in transactions:
try:
# Update player's team via PATCH /players/{player_id}?team_id={new_team_id}
await self._execute_player_update(
player_id=transaction['player']['id'],
new_team_id=transaction['newteam']['id'],
player_name=transaction['player']['name']
)
success_count += 1
# Rate limiting: 100ms delay between requests to avoid API overload
await asyncio.sleep(0.1)
except Exception as e:
self.logger.error(
f"Failed to execute transaction for {transaction['player']['name']}",
player_id=transaction['player']['id'],
new_team_id=transaction['newteam']['id'],
error=str(e)
)
failure_count += 1
self.logger.info(
f"Transaction execution complete for week {current.week}",
success=success_count,
failures=failure_count,
total=len(transactions)
)
except Exception as e:
self.logger.error(f"Error running transactions: {e}", exc_info=True)
async def _process_frozen_transactions(self, current: Current):
"""
Process frozen transactions with priority resolution.
Uses the NEW transaction logic (no backup implementation).
Steps:
1. Get all frozen transactions for current week
2. Resolve contested transactions (multiple teams want same player)
3. Cancel losing transactions
4. Unfreeze and post winning transactions
"""
try:
# Get all frozen transactions for current week via service
transactions = await transaction_service.get_frozen_transactions_by_week(
season=current.season,
week_start=current.week,
week_end=current.week + 1
)
if not transactions:
self.logger.warning(f"No frozen transactions to process for week {current.week}")
return
self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}")
# Resolve contested transactions
winning_move_ids, losing_move_ids = await resolve_contested_transactions(
transactions,
current.season
)
# Cancel losing transactions via service
for losing_move_id in losing_move_ids:
try:
# Get all moves with this moveid (could be multiple players in one transaction)
losing_moves = [t for t in transactions if t.moveid == losing_move_id]
if losing_moves:
# Cancel the entire transaction (all moves with same moveid)
for move in losing_moves:
success = await transaction_service.cancel_transaction(move.moveid)
if not success:
self.logger.warning(f"Failed to cancel transaction {move.moveid}")
# Notify the GM(s) about cancellation
first_move = losing_moves[0]
# Determine which team to notify (the team that was trying to acquire)
team_for_notification = (first_move.newteam
if first_move.newteam.abbrev.upper() != 'FA'
else first_move.oldteam)
await self._notify_gm_of_cancellation(first_move, team_for_notification)
contested_players = [move.player.name for move in losing_moves]
self.logger.info(
f"Cancelled transaction {losing_move_id} due to contested players: "
f"{contested_players}"
)
except Exception as e:
self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}")
# Unfreeze winning transactions and post to log via service
for winning_move_id in winning_move_ids:
try:
# Get all moves with this moveid
winning_moves = [t for t in transactions if t.moveid == winning_move_id]
for move in winning_moves:
# Unfreeze the transaction via service
success = await transaction_service.unfreeze_transaction(move.moveid)
if not success:
self.logger.warning(f"Failed to unfreeze transaction {move.moveid}")
# Post to transaction log
await self._post_transaction_to_log(winning_move_id, transactions)
self.logger.info(f"Processed successful transaction {winning_move_id}")
except Exception as e:
self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}")
self.logger.info(
f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
f"{len(losing_move_ids)} cancelled transactions"
)
except Exception as e:
self.logger.error(f"Error during freeze processing: {e}", exc_info=True)
raise
async def _execute_player_update(
self,
player_id: int,
new_team_id: int,
player_name: str
) -> bool:
"""
Execute a player roster update via API PATCH.
Args:
player_id: Player database ID
new_team_id: New team ID to assign
player_name: Player name for logging
Returns:
True if update successful, False otherwise
Raises:
Exception: If API call fails
"""
try:
self.logger.info(
f"Updating player roster",
player_id=player_id,
player_name=player_name,
new_team_id=new_team_id
)
# Get API client from transaction service
client = await transaction_service.get_client()
# Execute PATCH request to update player's team
response = await client.patch(
f'players/{player_id}',
params=[('team_id', str(new_team_id))]
)
# Verify response (200 or 204 indicates success)
if response is not None:
self.logger.info(
f"Successfully updated player roster",
player_id=player_id,
player_name=player_name,
new_team_id=new_team_id
)
return True
else:
self.logger.warning(
f"Player update returned no response",
player_id=player_id,
player_name=player_name,
new_team_id=new_team_id
)
return False
except Exception as e:
self.logger.error(
f"Failed to update player roster",
player_id=player_id,
player_name=player_name,
new_team_id=new_team_id,
error=str(e),
exc_info=True
)
raise
async def _send_freeze_announcement(self, week: int, is_beginning: bool):
"""
Send freeze/thaw announcement to transaction log channel.
Args:
week: Current week number
is_beginning: True for freeze begin, False for freeze end
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
self.logger.warning("Could not find guild for freeze announcement")
return
channel = discord.utils.get(guild.text_channels, name='transaction-log')
if not channel:
self.logger.warning("Could not find transaction-log channel")
return
# Create announcement message (formatted like legacy bot)
week_num = f'Week {week}'
stars = '*' * 32
if is_beginning:
message = (
f'```\n'
f'{stars}\n'
f'{week_num:>9} Freeze Period Begins\n'
f'{stars}\n'
f'```'
)
else:
message = (
f'```\n'
f'{"*" * 30}\n'
f'{week_num:>9} Freeze Period Ends\n'
f'{"*" * 30}\n'
f'```'
)
await channel.send(message)
self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})")
except Exception as e:
self.logger.error(f"Error sending freeze announcement: {e}")
async def _post_weekly_info(self, current: Current):
"""
Post weekly schedule information to #weekly-info channel.
Args:
current: Current league state
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
info_channel = discord.utils.get(guild.text_channels, name='weekly-info')
if not info_channel:
self.logger.warning("Could not find weekly-info channel")
return
# Clear recent messages (last 25)
async for message in info_channel.history(limit=25):
try:
await message.delete()
except:
pass # Ignore deletion errors
# Determine season emoji
if current.week <= 5:
season_str = "🌼 **Spring**"
elif current.week > 14:
season_str = "🍂 **Fall**"
else:
season_str = "🏖️ **Summer**"
# Determine day/night schedule
night_str = "🌙 Night"
day_str = "🌞 Day"
is_div_week = current.week in [1, 3, 6, 14, 16, 18]
weekly_str = (
f'**Season**: {season_str}\n'
f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / '
f'{night_str} / {day_str}'
)
# Send info messages
await info_channel.send(
content=(
f'Each team has manage permissions in their home ballpark. '
f'They may pin messages and rename the channel.\n\n'
f'**Make sure your ballpark starts with your team abbreviation.**'
)
)
await info_channel.send(weekly_str)
self.logger.info(f"Weekly info posted for week {current.week}")
except Exception as e:
self.logger.error(f"Error posting weekly info: {e}")
async def _post_transaction_to_log(
self,
move_id: str,
all_transactions: List[Transaction]
):
"""
Post a transaction to the transaction log channel.
Args:
move_id: Transaction move ID
all_transactions: List of all transactions to find moves with this ID
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
channel = discord.utils.get(guild.text_channels, name='transaction-log')
if not channel:
return
# Get all moves with this moveid
moves = [t for t in all_transactions if t.moveid == move_id]
if not moves:
return
# Determine the team for the embed (team making the moves)
first_move = moves[0]
if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev:
this_team = first_move.newteam
elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev:
this_team = first_move.oldteam
else:
# Default to newteam if both are FA/IL
this_team = first_move.newteam
# Build move string
move_string = ""
week_num = first_move.week
for move in moves:
move_string += (
f'**{move.player.name}** ({move.player.wara:.1f}) '
f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n'
)
# Create embed
embed = EmbedTemplate.create_base_embed(
title=f'Week {week_num} Transaction',
description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname,
color=EmbedColors.INFO
)
# Set team color if available
if hasattr(this_team, 'color') and this_team.color:
try:
embed.color = discord.Color(int(this_team.color.replace('#', ''), 16))
except:
pass # Use default color on error
embed.add_field(name='Player Moves', value=move_string, inline=False)
await channel.send(embed=embed)
self.logger.info(f"Transaction posted to log: {move_id}")
except Exception as e:
self.logger.error(f"Error posting transaction to log: {e}")
async def _notify_gm_of_cancellation(
self,
transaction: Transaction,
team
):
"""
Send DM to GM(s) about cancelled transaction.
Args:
transaction: The cancelled transaction
team: Team whose GMs should be notified
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
cancel_text = (
f'Your transaction for **{transaction.player.name}** has been cancelled '
f'because another team successfully claimed them during the freeze period.'
)
# Notify GM1
if hasattr(team, 'gmid') and team.gmid:
try:
gm_one = guild.get_member(team.gmid)
if gm_one:
await gm_one.send(cancel_text)
self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}")
except Exception as e:
self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}")
# Notify GM2 if exists
if hasattr(team, 'gmid2') and team.gmid2:
try:
gm_two = guild.get_member(team.gmid2)
if gm_two:
await gm_two.send(cancel_text)
self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}")
except Exception as e:
self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}")
except Exception as e:
self.logger.error(f"Error notifying GM of cancellation: {e}")
async def _send_owner_notification(self, message: str):
"""
Send error notification to bot owner.
Args:
message: Error message to send
"""
try:
app_info = await self.bot.application_info()
if app_info.owner:
await app_info.owner.send(message)
self.logger.info("Owner notification sent")
except Exception as e:
self.logger.error(f"Could not send owner notification: {e}")
def setup_freeze_task(bot: commands.Bot) -> TransactionFreezeTask:
"""Set up the transaction freeze/thaw task."""
return TransactionFreezeTask(bot)