Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
488 lines
16 KiB
Python
488 lines
16 KiB
Python
"""Timer and scheduling tools for delayed task execution."""
|
|
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
from typing import Optional, Dict, Any, Callable
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Active timers storage
|
|
_active_timers: Dict[str, Dict[str, Any]] = {}
|
|
_timer_tasks: Dict[str, asyncio.Task] = {}
|
|
|
|
|
|
async def set_timer(
|
|
duration_seconds: int,
|
|
timer_name: Optional[str] = None,
|
|
callback_message: Optional[str] = None,
|
|
callback_data: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Set a timer that will notify when completed.
|
|
|
|
Args:
|
|
duration_seconds: How long to wait before timer expires
|
|
timer_name: Optional name for the timer
|
|
callback_message: Message to return when timer expires
|
|
callback_data: Optional data to include in callback
|
|
|
|
Returns:
|
|
Dictionary with timer ID and metadata
|
|
"""
|
|
try:
|
|
# Generate timer ID
|
|
timer_id = str(uuid.uuid4())
|
|
|
|
# Calculate expiry time
|
|
start_time = datetime.now()
|
|
expiry_time = start_time + timedelta(seconds=duration_seconds)
|
|
|
|
# Create timer record
|
|
timer_data = {
|
|
"timer_id": timer_id,
|
|
"name": timer_name or f"Timer-{timer_id[:8]}",
|
|
"duration_seconds": duration_seconds,
|
|
"start_time": start_time.isoformat(),
|
|
"expiry_time": expiry_time.isoformat(),
|
|
"callback_message": callback_message,
|
|
"callback_data": callback_data or {},
|
|
"status": "active",
|
|
"created_at": start_time.isoformat()
|
|
}
|
|
|
|
_active_timers[timer_id] = timer_data
|
|
|
|
# Start the timer task
|
|
task = asyncio.create_task(_run_timer(timer_id, duration_seconds))
|
|
_timer_tasks[timer_id] = task
|
|
|
|
# Save timers to storage
|
|
await _save_timers()
|
|
|
|
logger.info(f"Timer {timer_id} set for {duration_seconds} seconds")
|
|
|
|
return {
|
|
"success": True,
|
|
"timer_id": timer_id,
|
|
"name": timer_data["name"],
|
|
"duration_seconds": duration_seconds,
|
|
"expiry_time": expiry_time.isoformat(),
|
|
"message": f"Timer set for {duration_seconds} seconds"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to set timer: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to set timer"
|
|
}
|
|
|
|
|
|
async def _run_timer(timer_id: str, duration_seconds: int):
|
|
"""Internal function to run a timer."""
|
|
try:
|
|
await asyncio.sleep(duration_seconds)
|
|
|
|
# Timer expired
|
|
if timer_id in _active_timers:
|
|
timer_data = _active_timers[timer_id]
|
|
timer_data["status"] = "expired"
|
|
timer_data["completed_at"] = datetime.now().isoformat()
|
|
|
|
logger.info(f"Timer {timer_id} expired: {timer_data.get('name')}")
|
|
|
|
# Trigger callback notification if configured
|
|
await _trigger_timer_callback(timer_data)
|
|
|
|
await _save_timers()
|
|
|
|
except asyncio.CancelledError:
|
|
# Timer was cancelled
|
|
if timer_id in _active_timers:
|
|
_active_timers[timer_id]["status"] = "cancelled"
|
|
await _save_timers()
|
|
logger.info(f"Timer {timer_id} was cancelled")
|
|
except Exception as e:
|
|
logger.error(f"Error in timer {timer_id}: {e}")
|
|
if timer_id in _active_timers:
|
|
_active_timers[timer_id]["status"] = "error"
|
|
_active_timers[timer_id]["error"] = str(e)
|
|
await _save_timers()
|
|
|
|
|
|
async def _trigger_timer_callback(timer_data: Dict[str, Any]):
|
|
"""Trigger callback actions when timer expires."""
|
|
try:
|
|
# You could integrate with notification systems here
|
|
callback_message = timer_data.get("callback_message")
|
|
|
|
if callback_message:
|
|
# Log the callback (in a real system, you might want to
|
|
# send notifications or trigger other actions)
|
|
logger.info(f"Timer callback: {callback_message}")
|
|
|
|
# Optionally send notification
|
|
try:
|
|
from notification_tools import send_slack_message, send_telegram_message
|
|
|
|
message = f"⏰ Timer Expired: {timer_data['name']}\n\n{callback_message}"
|
|
|
|
# Try Slack first
|
|
result = await send_slack_message(message)
|
|
if not result["success"]:
|
|
# Try Telegram
|
|
await send_telegram_message(message)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send timer notification: {e}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in timer callback: {e}")
|
|
|
|
|
|
async def cancel_timer(timer_id: str) -> Dict[str, Any]:
|
|
"""Cancel an active timer.
|
|
|
|
Args:
|
|
timer_id: ID of the timer to cancel
|
|
|
|
Returns:
|
|
Dictionary with cancellation status
|
|
"""
|
|
try:
|
|
if timer_id not in _active_timers:
|
|
return {
|
|
"success": False,
|
|
"error": "Timer not found",
|
|
"message": f"No timer found with ID {timer_id}"
|
|
}
|
|
|
|
timer = _active_timers[timer_id]
|
|
|
|
if timer["status"] != "active":
|
|
return {
|
|
"success": False,
|
|
"error": "Timer not active",
|
|
"status": timer["status"],
|
|
"message": f"Timer is already {timer['status']}"
|
|
}
|
|
|
|
# Cancel the task
|
|
if timer_id in _timer_tasks:
|
|
_timer_tasks[timer_id].cancel()
|
|
del _timer_tasks[timer_id]
|
|
|
|
timer["status"] = "cancelled"
|
|
timer["cancelled_at"] = datetime.now().isoformat()
|
|
|
|
await _save_timers()
|
|
|
|
logger.info(f"Timer {timer_id} cancelled")
|
|
|
|
return {
|
|
"success": True,
|
|
"timer_id": timer_id,
|
|
"name": timer["name"],
|
|
"message": "Timer cancelled successfully"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to cancel timer: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to cancel timer"
|
|
}
|
|
|
|
|
|
async def list_timers(status: Optional[str] = None) -> Dict[str, Any]:
|
|
"""List all timers, optionally filtered by status.
|
|
|
|
Args:
|
|
status: Optional status filter (active, expired, cancelled)
|
|
|
|
Returns:
|
|
Dictionary with list of timers
|
|
"""
|
|
try:
|
|
timers = list(_active_timers.values())
|
|
|
|
if status:
|
|
timers = [t for t in timers if t["status"] == status]
|
|
|
|
# Sort by creation time
|
|
timers.sort(key=lambda t: t["created_at"], reverse=True)
|
|
|
|
return {
|
|
"success": True,
|
|
"count": len(timers),
|
|
"timers": timers,
|
|
"message": f"Found {len(timers)} timers"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to list timers: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to list timers"
|
|
}
|
|
|
|
|
|
async def get_timer_status(timer_id: str) -> Dict[str, Any]:
|
|
"""Get status of a specific timer.
|
|
|
|
Args:
|
|
timer_id: ID of the timer to check
|
|
|
|
Returns:
|
|
Dictionary with timer status
|
|
"""
|
|
try:
|
|
if timer_id not in _active_timers:
|
|
return {
|
|
"success": False,
|
|
"error": "Timer not found",
|
|
"message": f"No timer found with ID {timer_id}"
|
|
}
|
|
|
|
timer = _active_timers[timer_id]
|
|
|
|
# Recurring timers have an interval rather than a fixed expiry time.
|
|
if timer["status"] == "active" and timer.get("type") != "recurring":
|
|
expiry_str = timer["expiry_time"].replace("Z", "+00:00")
|
|
expiry = datetime.fromisoformat(expiry_str)
|
|
now = datetime.now(expiry.tzinfo) if expiry.tzinfo is not None else datetime.now()
|
|
remaining = (expiry - now).total_seconds()
|
|
timer["remaining_seconds"] = max(0, int(remaining))
|
|
|
|
return {
|
|
"success": True,
|
|
"timer": timer,
|
|
"message": "Timer found"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get timer status: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to get timer status"
|
|
}
|
|
|
|
|
|
async def set_recurring_timer(
|
|
interval_seconds: int,
|
|
max_occurrences: Optional[int] = None,
|
|
timer_name: Optional[str] = None,
|
|
callback_message: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Set a recurring timer that repeats at intervals.
|
|
|
|
Args:
|
|
interval_seconds: Time between occurrences
|
|
max_occurrences: Maximum number of times to repeat (None = infinite)
|
|
timer_name: Optional name for the timer
|
|
callback_message: Message for each occurrence
|
|
|
|
Returns:
|
|
Dictionary with recurring timer ID
|
|
"""
|
|
try:
|
|
if interval_seconds is None or interval_seconds <= 0:
|
|
return {
|
|
"success": False,
|
|
"error": "interval_seconds must be positive",
|
|
"message": "Failed to set recurring timer"
|
|
}
|
|
|
|
timer_id = str(uuid.uuid4())
|
|
|
|
timer_data = {
|
|
"timer_id": timer_id,
|
|
"name": timer_name or f"Recurring-{timer_id[:8]}",
|
|
"type": "recurring",
|
|
"interval_seconds": interval_seconds,
|
|
"max_occurrences": max_occurrences,
|
|
"occurrences": 0,
|
|
"callback_message": callback_message,
|
|
"status": "active",
|
|
"created_at": datetime.now().isoformat()
|
|
}
|
|
|
|
_active_timers[timer_id] = timer_data
|
|
|
|
# Start recurring task
|
|
task = asyncio.create_task(
|
|
_run_recurring_timer(timer_id, interval_seconds, max_occurrences)
|
|
)
|
|
_timer_tasks[timer_id] = task
|
|
|
|
await _save_timers()
|
|
|
|
return {
|
|
"success": True,
|
|
"timer_id": timer_id,
|
|
"name": timer_data["name"],
|
|
"interval_seconds": interval_seconds,
|
|
"max_occurrences": max_occurrences,
|
|
"message": f"Recurring timer set with {interval_seconds}s interval"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to set recurring timer: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"message": "Failed to set recurring timer"
|
|
}
|
|
|
|
|
|
async def _run_recurring_timer(
|
|
timer_id: str,
|
|
interval_seconds: int,
|
|
max_occurrences: Optional[int]
|
|
):
|
|
"""Internal function to run a recurring timer."""
|
|
try:
|
|
timer_data = _active_timers.get(timer_id)
|
|
if timer_data is None:
|
|
return
|
|
|
|
occurrence = timer_data.get("occurrences", 0)
|
|
|
|
# max_occurrences=0 means "never fire" (None means infinite).
|
|
if max_occurrences is not None and occurrence >= max_occurrences:
|
|
timer_data["status"] = "completed"
|
|
await _save_timers()
|
|
return
|
|
|
|
while True:
|
|
await asyncio.sleep(interval_seconds)
|
|
|
|
timer_data = _active_timers.get(timer_id)
|
|
if timer_data is None:
|
|
return
|
|
|
|
occurrence += 1
|
|
timer_data["occurrences"] = occurrence
|
|
timer_data["last_occurrence"] = datetime.now().isoformat()
|
|
|
|
logger.info(f"Recurring timer {timer_id} occurrence {occurrence}")
|
|
|
|
await _trigger_timer_callback(timer_data)
|
|
|
|
# Persist the terminal state, not an active record at the limit.
|
|
# Use `is not None`: max_occurrences=0 means "never fire", not infinite.
|
|
if max_occurrences is not None and occurrence >= max_occurrences:
|
|
timer_data["status"] = "completed"
|
|
logger.info(f"Recurring timer {timer_id} completed after {occurrence} occurrences")
|
|
|
|
await _save_timers()
|
|
|
|
if timer_data["status"] == "completed":
|
|
break
|
|
|
|
except asyncio.CancelledError:
|
|
if timer_id in _active_timers:
|
|
_active_timers[timer_id]["status"] = "cancelled"
|
|
await _save_timers()
|
|
logger.info(f"Recurring timer {timer_id} was cancelled")
|
|
except Exception as e:
|
|
logger.error(f"Error in recurring timer {timer_id}: {e}")
|
|
|
|
|
|
async def _save_timers():
|
|
"""Save timer state to storage."""
|
|
try:
|
|
from config import config
|
|
storage_path = Path(config.timer.storage_path).expanduser()
|
|
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Only save timers with relevant status
|
|
timers_to_save = {
|
|
tid: timer for tid, timer in _active_timers.items()
|
|
if timer["status"] in ["active", "expired", "completed"]
|
|
}
|
|
|
|
with open(storage_path, 'w') as f:
|
|
json.dump(timers_to_save, f, indent=2)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to save timers: {e}")
|
|
|
|
|
|
async def _load_timers():
|
|
"""Load timer state from storage."""
|
|
try:
|
|
from config import config
|
|
storage_path = Path(config.timer.storage_path).expanduser()
|
|
|
|
if not storage_path.exists():
|
|
return
|
|
|
|
with open(storage_path, 'r') as f:
|
|
saved_timers = json.load(f)
|
|
|
|
# Restore active timers
|
|
state_changed = False
|
|
for timer_id, timer_data in saved_timers.items():
|
|
if timer_data["status"] == "active":
|
|
if timer_data.get("type") == "recurring":
|
|
_active_timers[timer_id] = timer_data
|
|
|
|
max_occurrences = timer_data.get("max_occurrences")
|
|
occurrences = timer_data.get("occurrences", 0)
|
|
if max_occurrences is not None and occurrences >= max_occurrences:
|
|
# Older versions persisted the final occurrence before
|
|
# changing the in-memory status to completed.
|
|
timer_data["status"] = "completed"
|
|
state_changed = True
|
|
continue
|
|
|
|
task = asyncio.create_task(
|
|
_run_recurring_timer(
|
|
timer_id,
|
|
timer_data["interval_seconds"],
|
|
max_occurrences
|
|
)
|
|
)
|
|
_timer_tasks[timer_id] = task
|
|
logger.info(
|
|
f"Restored recurring timer {timer_id} after "
|
|
f"{occurrences} occurrences"
|
|
)
|
|
continue
|
|
|
|
# Calculate remaining time
|
|
expiry_str = timer_data["expiry_time"].replace("Z", "+00:00")
|
|
expiry = datetime.fromisoformat(expiry_str)
|
|
now = datetime.now(expiry.tzinfo) if expiry.tzinfo is not None else datetime.now()
|
|
remaining = (expiry - now).total_seconds()
|
|
|
|
if remaining > 0:
|
|
# Timer still active, restart it
|
|
_active_timers[timer_id] = timer_data
|
|
task = asyncio.create_task(_run_timer(timer_id, int(remaining)))
|
|
_timer_tasks[timer_id] = task
|
|
logger.info(f"Restored timer {timer_id} with {remaining}s remaining")
|
|
else:
|
|
# Timer already expired
|
|
timer_data["status"] = "expired"
|
|
timer_data["completed_at"] = datetime.now().isoformat()
|
|
_active_timers[timer_id] = timer_data
|
|
state_changed = True
|
|
await _trigger_timer_callback(timer_data)
|
|
else:
|
|
_active_timers[timer_id] = timer_data
|
|
|
|
if state_changed:
|
|
await _save_timers()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to load timers: {e}")
|