mirrors / run_agent_background.py
hoekashinov's picture
Upload 1011 files
4f625a6 verified
Raw
History Blame Contribute Delete
34.2 kB
import dotenv
dotenv.load_dotenv(".env")
import sentry
import asyncio
import json
import traceback
from datetime import datetime, timezone
from typing import Optional, Dict, Any, Tuple
from core.services import redis
from core.run import run_agent
from core.utils.logger import logger, structlog
from core.utils.tool_discovery import warm_up_tools_cache
import uuid
from core.services.supabase import DBConnection
from core.services.langfuse import langfuse
from core.utils.retry import retry
import time
# Note: This file contains helper functions used by Temporal activities
# The actual worker entry point is now in core/temporal/worker.py
_initialized = False
db = DBConnection()
instance_id = ""
# TTL for Redis stream keys - ensures cleanup even if process crashes
REDIS_STREAM_TTL_SECONDS = 600 # 10 minutes
_STATIC_CORE_PROMPT = None
def check_terminating_tool_call(response: Dict[str, Any]) -> Optional[str]:
if response.get('type') != 'status':
return None
metadata = response.get('metadata', {})
if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except (json.JSONDecodeError, TypeError):
metadata = {}
if not metadata.get('agent_should_terminate'):
return None
content = response.get('content', {})
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
content = {}
if isinstance(content, dict):
function_name = content.get('function_name')
if function_name in ['ask', 'complete']:
return function_name
return None
async def initialize():
global db, instance_id, _initialized, _STATIC_CORE_PROMPT
if _initialized:
return
if not instance_id:
instance_id = str(uuid.uuid4())[:8]
logger.info(f"Initializing worker async resources (instance: {instance_id})")
await retry(lambda: redis.initialize_async())
await redis.verify_connection()
await db.initialize()
from core.utils.tool_discovery import warm_up_tools_cache
warm_up_tools_cache()
try:
from core.runtime_cache import warm_up_suna_config_cache
await warm_up_suna_config_cache()
except Exception as e:
logger.warning(f"Failed to pre-cache Suna configs (non-fatal): {e}")
if not _STATIC_CORE_PROMPT:
try:
from core.prompts.core_prompt import get_core_system_prompt
_STATIC_CORE_PROMPT = get_core_system_prompt()
logger.info(f"✅ Cached static core prompt at worker boot ({len(_STATIC_CORE_PROMPT):,} chars)")
except Exception as e:
logger.warning(f"Failed to cache core prompt (non-fatal): {e}")
_initialized = True
logger.info(f"✅ Worker async resources initialized successfully (instance: {instance_id})")
# check_health function removed - health checks now handled by worker_health.py
async def acquire_run_lock(agent_run_id: str, instance_id: str, client) -> bool:
run_lock_key = f"agent_run_lock:{agent_run_id}"
lock_acquired = await redis.set(run_lock_key, instance_id, nx=True, ex=redis.REDIS_KEY_TTL)
if not lock_acquired:
existing_instance = await redis.get(run_lock_key)
existing_instance_str = existing_instance.decode() if isinstance(existing_instance, bytes) else existing_instance if existing_instance else None
if existing_instance_str:
instance_active_key = f"active_run:{existing_instance_str}:{agent_run_id}"
instance_still_alive = await redis.get(instance_active_key)
db_run_status = None
try:
run_result = await client.table('agent_runs').select('status').eq('id', agent_run_id).maybe_single().execute()
if run_result.data:
db_run_status = run_result.data.get('status')
except Exception as db_err:
logger.warning(f"Failed to check database status for {agent_run_id}: {db_err}")
if instance_still_alive or db_run_status == 'running':
logger.info(f"Agent run {agent_run_id} is already being processed by instance {existing_instance_str}. Skipping duplicate execution.")
return False
else:
logger.warning(f"Stale lock detected for {agent_run_id} (instance {existing_instance_str} is dead, DB status: {db_run_status}). Attempting to acquire lock.")
await redis.delete(run_lock_key)
lock_acquired = await redis.set(run_lock_key, instance_id, nx=True, ex=redis.REDIS_KEY_TTL)
if not lock_acquired:
logger.info(f"Another worker acquired lock for {agent_run_id} while cleaning up stale lock. Skipping.")
return False
else:
lock_acquired = await redis.set(run_lock_key, instance_id, nx=True, ex=redis.REDIS_KEY_TTL)
if not lock_acquired:
logger.info(f"Agent run {agent_run_id} is already being processed by another instance. Skipping duplicate execution.")
return False
return True
async def load_agent_config(agent_id: Optional[str], account_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not agent_id:
return None
t = time.time()
try:
from core.runtime_cache import (
get_static_suna_config,
get_cached_user_mcps,
get_cached_agent_config
)
static_config = get_static_suna_config()
cached_mcps = await get_cached_user_mcps(agent_id)
if static_config and cached_mcps is not None:
agent_config = {
'agent_id': agent_id,
'system_prompt': static_config['system_prompt'],
'model': static_config['model'],
'agentpress_tools': static_config['agentpress_tools'],
'centrally_managed': static_config['centrally_managed'],
'is_suna_default': static_config['is_suna_default'],
'restrictions': static_config['restrictions'],
'configured_mcps': cached_mcps.get('configured_mcps', []),
'custom_mcps': cached_mcps.get('custom_mcps', []),
'triggers': cached_mcps.get('triggers', []),
}
logger.info(f"⏱️ [TIMING] ⚡ Suna config from memory + Redis MCPs: {(time.time() - t) * 1000:.1f}ms")
else:
cached_config = await get_cached_agent_config(agent_id)
if cached_config:
agent_config = cached_config
logger.info(f"⏱️ [TIMING] ⚡ Custom agent config from cache: {(time.time() - t) * 1000:.1f}ms")
elif account_id:
from core.agent_loader import get_agent_loader
loader = await get_agent_loader()
agent_data = await loader.load_agent(agent_id, account_id, load_config=True)
agent_config = agent_data.to_dict()
logger.info(f"⏱️ [TIMING] Agent config from DB (cached for next time): {(time.time() - t) * 1000:.1f}ms")
else:
from core.agent_loader import get_agent_loader
loader = await get_agent_loader()
agent_data = await loader.load_agent(agent_id, agent_id, load_config=True)
agent_config = agent_data.to_dict()
logger.info(f"⏱️ [TIMING] Agent config from DB (public agent): {(time.time() - t) * 1000:.1f}ms")
return agent_config
except Exception as e:
logger.warning(f"Failed to fetch agent config for agent_id {agent_id}: {e}. Using default config.")
return None
async def get_thread_data(client, thread_id: str) -> dict:
try:
thread_info = await client.table('threads').select('project_id').eq('thread_id', thread_id).maybe_single().execute()
if thread_info and thread_info.data:
project_id = thread_info.data.get('project_id')
if project_id:
project_info = await client.table('projects').select('name').eq('project_id', project_id).maybe_single().execute()
task_name = 'Task'
if project_info and project_info.data:
task_name = project_info.data.get('name', 'Task')
return {
'task_name': task_name,
'task_url': f"/projects/{project_id}/thread/{thread_id}"
}
except Exception as e:
logger.warning(f"Failed to get notification data for thread {thread_id}: {e}")
return {
'task_name': 'Task',
'task_url': f"/thread/{thread_id}"
}
async def send_completion_notification(client, thread_id: str, agent_config: Optional[Dict[str, Any]], complete_tool_called: bool):
if not complete_tool_called:
return
try:
from core.notifications.notification_service import notification_service
thread_info = await client.table('threads').select('account_id').eq('thread_id', thread_id).maybe_single().execute()
if thread_info and thread_info.data:
user_id = thread_info.data.get('account_id')
if user_id:
notification_data = await get_thread_data(client, thread_id)
result = await notification_service.send_task_completion_notification(
account_id=user_id,
task_name=notification_data['task_name'],
thread_id=thread_id,
agent_name=agent_config.get('name') if agent_config else None,
result_summary="Task completed successfully"
)
logger.info(f"Task completion notification sent (complete tool called): {result}")
except Exception as notif_error:
logger.warning(f"Failed to send completion notification: {notif_error}")
async def send_failure_notification(client, thread_id: str, error_message: str):
try:
from core.notifications.notification_service import notification_service
thread_info = await client.table('threads').select('account_id').eq('thread_id', thread_id).maybe_single().execute()
if thread_info and thread_info.data:
user_id = thread_info.data.get('account_id')
if user_id:
notification_data = await get_thread_data(client, thread_id)
result = await notification_service.send_task_failed_notification(
account_id=user_id,
task_name=notification_data['task_name'],
task_url=notification_data['task_url'],
failure_reason=error_message,
first_name='User',
thread_id=thread_id
)
logger.info(f"Task failed notification result: {result}")
except Exception as notif_error:
logger.warning(f"Failed to send failure notification: {notif_error}")
def create_redis_keys(agent_run_id: str, instance_id: str) -> Dict[str, str]:
return {
'response_stream': f"agent_run:{agent_run_id}:stream",
'instance_active': f"active_run:{instance_id}:{agent_run_id}"
}
MAX_PENDING_REDIS_OPS = 500
async def process_agent_responses(
agent_gen,
agent_run_id: str,
redis_keys: Dict[str, str],
trace,
worker_start: float,
stop_signal_checker_state: Dict[str, Any]
) -> Tuple[str, Optional[str], bool, int]:
final_status = "running"
error_message = None
first_response_logged = False
complete_tool_called = False
total_responses = 0
redis_streaming_enabled = True
stream_key = redis_keys['response_stream']
stream_ttl_set = False # Track if we've set initial TTL on the stream
async for response in agent_gen:
if not first_response_logged:
first_token_time = (time.time() - worker_start) * 1000
logger.info(f"⏱️ [TIMING] 🎯 FIRST RESPONSE from agent: {first_token_time:.1f}ms from job start")
first_response_logged = True
if stop_signal_checker_state.get('stop_signal_received'):
stop_reason = stop_signal_checker_state.get('stop_reason', 'external_stop_signal')
logger.warning(f"🛑 Agent run {agent_run_id} stopped by signal. Reason: {stop_reason}. Total responses processed: {total_responses}")
final_status = "stopped"
error_message = f"Stopped by {stop_reason}"
trace.span(name="agent_run_stopped").end(status_message=f"agent_run_stopped: {stop_reason}", level="WARNING")
break
response_json = json.dumps(response)
# Write to stream directly - no fire-and-forget, no pubsub
if redis_streaming_enabled:
try:
await redis.stream_add(
stream_key,
{"data": response_json},
maxlen=200,
approximate=True
)
# Set initial TTL on stream after first entry is added (safety net if cleanup fails)
if not stream_ttl_set:
try:
await asyncio.wait_for(redis.expire(stream_key, REDIS_STREAM_TTL_SECONDS), timeout=2.0)
stream_ttl_set = True
logger.debug(f"Set initial TTL ({REDIS_STREAM_TTL_SECONDS}s) on stream {stream_key}")
except (asyncio.TimeoutError, Exception) as e:
logger.debug(f"Failed to set initial TTL on stream (non-critical): {e}")
except Exception as e:
logger.warning(f"Failed to write to stream for {agent_run_id}: {e}")
# Don't disable streaming on single failure - redis-py will retry
total_responses += 1
stop_signal_checker_state['total_responses'] = total_responses
# Refresh stream TTL periodically
if total_responses % 50 == 0:
try:
await asyncio.wait_for(redis.expire(stream_key, REDIS_STREAM_TTL_SECONDS), timeout=2.0)
except (asyncio.TimeoutError, Exception):
pass
terminating_tool = check_terminating_tool_call(response)
if terminating_tool == 'complete':
complete_tool_called = True
logger.info(f"Complete tool was called in agent run {agent_run_id}")
elif terminating_tool == 'ask':
logger.debug(f"Ask tool was called in agent run {agent_run_id} (terminating but no notification)")
if response.get('type') == 'status':
status_val = response.get('status')
if status_val in ['completed', 'failed', 'stopped', 'error']:
logger.info(f"Agent run {agent_run_id} finished with status: {status_val}")
final_status = status_val if status_val != 'error' else 'failed'
if status_val in ['failed', 'stopped', 'error']:
error_message = response.get('message', f"Run ended with status: {status_val}")
logger.error(f"Agent run failed: {error_message}")
break
# All stream writes are synchronous now, so no pending operations to await
return final_status, error_message, complete_tool_called, total_responses
async def handle_normal_completion(
agent_run_id: str,
start_time: datetime,
total_responses: int,
redis_keys: Dict[str, str],
trace
) -> Dict[str, str]:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
logger.info(f"Agent run {agent_run_id} completed normally (duration: {duration:.2f}s, responses: {total_responses})")
completion_message = {"type": "status", "status": "completed", "message": "Worker run completed successfully"}
trace.span(name="agent_run_completed").end(status_message="agent_run_completed")
completion_json = json.dumps(completion_message)
try:
await asyncio.wait_for(
redis.stream_add(
redis_keys['response_stream'],
{'data': completion_json},
maxlen=200,
approximate=True
),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning(f"Timeout writing completion message to Redis stream for {agent_run_id}")
except Exception as e:
logger.warning(f"Failed to write completion message to stream for {agent_run_id}: {e}")
return completion_message
async def publish_final_control_signal(agent_run_id: str, final_status: str, stop_reason: Optional[str] = None):
"""Set final control signal via stop signal key (no longer using pubsub)."""
# For completed/failed, we don't need a control signal - the status in the stream is enough
# Only set stop signal if explicitly stopped
if final_status == "stopped":
try:
await asyncio.wait_for(
redis.set_stop_signal(agent_run_id),
timeout=3.0
)
logger.warning(f"🛑 Set stop signal for agent run {agent_run_id} (reason: {stop_reason or 'unknown'})")
except asyncio.TimeoutError:
logger.warning(f"Timeout setting stop signal for {agent_run_id}")
except Exception as e:
logger.warning(f"Failed to set stop signal for {agent_run_id}: {str(e)}")
from core.tool_output_streaming_context import set_tool_output_streaming_context, clear_tool_output_streaming_context
async def run_agent_background(
agent_run_id: str,
thread_id: str,
instance_id: str,
project_id: str,
model_name: str = "openai/gpt-5-mini",
agent_id: Optional[str] = None,
account_id: Optional[str] = None,
request_id: Optional[str] = None
):
worker_start = time.time()
timings = {}
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
agent_run_id=agent_run_id,
thread_id=thread_id,
request_id=request_id,
)
logger.info(f"⏱️ [TIMING] Worker received job at {worker_start}")
t = time.time()
try:
await initialize()
except Exception as e:
logger.critical(f"Failed to initialize worker resources (Redis/DB): {e}")
raise e
timings['initialize'] = (time.time() - t) * 1000
client = None
try:
client = await db.client
lock_acquired = await acquire_run_lock(agent_run_id, instance_id, client)
if not lock_acquired:
# No cleanup needed - we didn't acquire the lock, another instance has it
return
sentry.sentry.set_tag("thread_id", thread_id)
timings['lock_acquisition'] = (time.time() - worker_start) * 1000 - timings['initialize']
logger.info(f"⏱️ [TIMING] Worker init: {timings['initialize']:.1f}ms | Lock: {timings['lock_acquisition']:.1f}ms")
logger.info(f"Starting background agent run: {agent_run_id} for thread: {thread_id} (Instance: {instance_id})")
from core.ai_models import model_manager
effective_model = model_manager.resolve_model_id(model_name)
logger.info(f"🚀 Using model: {effective_model}")
start_time = datetime.now(timezone.utc)
stop_checker = None
cancellation_event = asyncio.Event()
redis_keys = create_redis_keys(agent_run_id, instance_id)
await redis.verify_stream_writable(redis_keys['response_stream'])
logger.info(f"✅ Verified Redis stream {redis_keys['response_stream']} is writable")
trace = langfuse.trace(
name="agent_run",
id=agent_run_id,
session_id=thread_id,
metadata={"project_id": project_id, "instance_id": instance_id}
)
except Exception as e:
logger.error(f"Critical error during worker setup for {agent_run_id}: {e}", exc_info=True)
try:
if not client:
client = await db.client
await update_agent_run_status(client, agent_run_id, "failed", error=f"Worker setup failed: {str(e)}", account_id=account_id)
except Exception as inner_e:
logger.error(f"Failed to update status after setup error: {inner_e}")
# Clean up any Redis keys that might have been created (e.g., lock key)
try:
await cleanup_redis_keys_for_agent_run(agent_run_id, instance_id)
except Exception as cleanup_err:
logger.warning(f"Failed to clean up Redis keys after setup error: {cleanup_err}")
return
stop_signal_checker_state = {'stop_signal_received': False, 'total_responses': 0, 'stop_reason': None}
async def check_for_stop_signal():
"""Simple polling-based stop signal checker using Redis key."""
while not stop_signal_checker_state.get('stop_signal_received'):
try:
# Check stop signal key
if await redis.check_stop_signal(agent_run_id):
logger.warning(f"🛑 Received STOP signal for agent run {agent_run_id}")
stop_signal_checker_state['stop_signal_received'] = True
stop_signal_checker_state['stop_reason'] = 'stop_signal_key'
cancellation_event.set()
break
# Refresh instance_active TTL periodically
if stop_signal_checker_state.get('total_responses', 0) % 50 == 0:
try:
await asyncio.wait_for(
redis.expire(redis_keys['instance_active'], redis.REDIS_KEY_TTL),
timeout=3.0
)
except (asyncio.TimeoutError, Exception):
pass
await asyncio.sleep(0.5) # Poll every 500ms
except asyncio.CancelledError:
logger.debug(f"Stop signal checker cancelled for {agent_run_id}")
break
except Exception as e:
logger.error(f"Error in stop signal checker for {agent_run_id}: {e}", exc_info=True)
await asyncio.sleep(1)
stop_checker = asyncio.create_task(check_for_stop_signal())
try:
try:
await asyncio.wait_for(
redis.set(redis_keys['instance_active'], "running", ex=redis.REDIS_KEY_TTL),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning(f"Redis timeout setting instance_active key for {agent_run_id} - continuing without")
except Exception as e:
logger.warning(f"Redis error setting instance_active key for {agent_run_id}: {e} - continuing without")
agent_config = await load_agent_config(agent_id, account_id)
# Set tool output streaming context for tools to publish real-time output
set_tool_output_streaming_context(
agent_run_id=agent_run_id,
stream_key=redis_keys['response_stream']
)
agent_gen = run_agent(
thread_id=thread_id,
project_id=project_id,
model_name=effective_model,
agent_config=agent_config,
trace=trace,
cancellation_event=cancellation_event,
account_id=account_id,
)
total_to_ready = (time.time() - worker_start) * 1000
logger.info(f"⏱️ [TIMING] 🏁 Worker ready for first LLM call: {total_to_ready:.1f}ms from job start")
final_status, error_message, complete_tool_called, total_responses = await process_agent_responses(
agent_gen, agent_run_id, redis_keys, trace, worker_start, stop_signal_checker_state
)
if final_status == "running":
final_status = "completed"
await handle_normal_completion(agent_run_id, start_time, total_responses, redis_keys, trace)
await send_completion_notification(client, thread_id, agent_config, complete_tool_called)
if not complete_tool_called:
logger.info(f"Agent run {agent_run_id} completed without explicit complete tool call - skipping notification")
await update_agent_run_status(client, agent_run_id, final_status, error=error_message, account_id=account_id)
if final_status == "failed" and error_message:
await send_failure_notification(client, thread_id, error_message)
stop_reason = stop_signal_checker_state.get('stop_reason')
await publish_final_control_signal(agent_run_id, final_status, stop_reason=stop_reason)
except Exception as e:
error_message = str(e)
traceback_str = traceback.format_exc()
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
logger.error(f"Error in agent run {agent_run_id} after {duration:.2f}s: {error_message}\n{traceback_str} (Instance: {instance_id})")
final_status = "failed"
trace.span(name="agent_run_failed").end(status_message=error_message, level="ERROR")
await send_failure_notification(client, thread_id, error_message)
error_response = {"type": "status", "status": "error", "message": error_message}
try:
error_json = json.dumps(error_response)
await asyncio.wait_for(
redis.stream_add(
redis_keys['response_stream'],
{'data': error_json},
maxlen=200,
approximate=True
),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning(f"Timeout writing error response to Redis stream for {agent_run_id}")
except Exception as redis_err:
logger.error(f"Failed to write error response to Redis stream for {agent_run_id}: {redis_err}")
await update_agent_run_status(client, agent_run_id, "failed", error=f"{error_message}\n{traceback_str}", account_id=account_id)
finally:
# Clear tool output streaming context
clear_tool_output_streaming_context()
if stop_checker and not stop_checker.done():
stop_checker.cancel()
try:
await stop_checker
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning(f"Error during stop_checker cancellation: {e}")
# Comprehensive cleanup of all Redis keys for this agent run
await cleanup_redis_keys_for_agent_run(agent_run_id, instance_id)
if final_status == "completed" and account_id:
try:
from core.temporal.client import get_temporal_client
from core.temporal.workflows import MemoryExtractionWorkflow
messages_result = await client.table('messages').select('message_id').eq('thread_id', thread_id).order('created_at', desc=False).execute()
if messages_result.data:
message_ids = [m['message_id'] for m in messages_result.data]
temporal_client = await get_temporal_client()
# Temporal SDK requires multiple args passed via 'args' parameter
# Use background queue for memory extraction (lower priority)
from core.temporal.workflows import MemoryExtractionWorkflow, TASK_QUEUE_BACKGROUND
await temporal_client.start_workflow(
MemoryExtractionWorkflow.run,
args=[thread_id, account_id, message_ids],
id=f"memory-extraction-{thread_id}",
task_queue=TASK_QUEUE_BACKGROUND,
)
logger.debug(f"Started memory extraction workflow for thread {thread_id}")
except Exception as mem_error:
logger.warning(f"Failed to start memory extraction workflow: {mem_error}")
# MEMORY CLEANUP: Explicitly release memory after agent run completes
try:
import gc
# Force garbage collection to free memory from completed agent run
collected = gc.collect()
if collected > 0:
logger.debug(f"Garbage collected {collected} objects after agent run {agent_run_id}")
except Exception as gc_error:
logger.debug(f"Garbage collection error (non-critical): {gc_error}")
# All stream writes are synchronous now, no pending operations to await
logger.debug(f"Agent run background task fully completed for: {agent_run_id} (Instance: {instance_id}) with final status: {final_status}")
async def cleanup_redis_keys_for_agent_run(agent_run_id: str, instance_id: Optional[str] = None):
"""
Comprehensive cleanup of all Redis keys associated with an agent run.
This function cleans up:
- Response stream (deleted immediately)
- Run lock key
- Instance active key (if instance_id provided)
- Any other instance active keys for this agent run
Args:
agent_run_id: The ID of the agent run to clean up
instance_id: Optional instance ID for instance-specific cleanup
"""
logger.debug(f"Cleaning up Redis keys for agent run: {agent_run_id}")
# List of keys to delete
keys_to_delete = []
# Response stream - delete immediately
stream_key = f"agent_run:{agent_run_id}:stream"
keys_to_delete.append(stream_key)
# Run lock key
run_lock_key = f"agent_run_lock:{agent_run_id}"
keys_to_delete.append(run_lock_key)
# Instance-specific active key (if instance_id provided)
if instance_id:
instance_active_key = f"active_run:{instance_id}:{agent_run_id}"
keys_to_delete.append(instance_active_key)
# Delete all keys
for key in keys_to_delete:
try:
deleted = await redis.delete(key)
if deleted:
logger.debug(f"Deleted Redis key: {key}")
except Exception as e:
logger.warning(f"Failed to delete Redis key {key}: {str(e)}")
# Also find and clean up any other instance active keys for this agent run
# (in case there are stale keys from other instances)
try:
# Use scan_keys instead of keys() to avoid blocking Redis
instance_keys = await redis.scan_keys(f"active_run:*:{agent_run_id}")
for key in instance_keys:
# Decode bytes if needed
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
if key_str not in keys_to_delete:
try:
await redis.delete(key_str)
logger.debug(f"Deleted stale instance active key: {key_str}")
except Exception as e:
logger.warning(f"Failed to delete stale instance active key {key_str}: {str(e)}")
except Exception as e:
logger.warning(f"Failed to find and clean up instance active keys for {agent_run_id}: {str(e)}")
logger.debug(f"Completed Redis cleanup for agent run: {agent_run_id}")
async def update_agent_run_status(
client,
agent_run_id: str,
status: str,
error: Optional[str] = None,
account_id: Optional[str] = None,
) -> bool:
try:
update_data = {
"status": status,
"completed_at": datetime.now(timezone.utc).isoformat()
}
if error:
update_data["error"] = error
for retry in range(3):
try:
update_result = await client.table('agent_runs').update(update_data).eq("id", agent_run_id).execute()
if hasattr(update_result, 'data') and update_result.data:
verify_result = await client.table('agent_runs').select('status', 'completed_at').eq("id", agent_run_id).execute()
if verify_result.data:
actual_status = verify_result.data[0].get('status')
completed_at = verify_result.data[0].get('completed_at')
if account_id:
try:
from core.runtime_cache import invalidate_running_runs_cache
await invalidate_running_runs_cache(account_id)
except Exception as cache_error:
logger.warning(f"Failed to invalidate running runs cache: {cache_error}")
# Invalidate account-state cache to refresh concurrent runs limit
try:
from core.billing.shared.cache_utils import invalidate_account_state_cache
await invalidate_account_state_cache(account_id)
except Exception as cache_error:
logger.warning(f"Failed to invalidate account-state cache: {cache_error}")
return True
else:
logger.warning(f"Database update returned no data for agent run {agent_run_id} on retry {retry}: {update_result}")
if retry == 2:
logger.error(f"Failed to update agent run status after all retries: {agent_run_id}")
return False
except Exception as db_error:
logger.error(f"Database error on retry {retry} updating status for {agent_run_id}: {str(db_error)}")
if retry < 2:
await asyncio.sleep(0.5 * (2 ** retry))
else:
logger.error(f"Failed to update agent run status after all retries: {agent_run_id}", exc_info=True)
return False
except Exception as e:
logger.error(f"Unexpected error updating agent run status for {agent_run_id}: {str(e)}", exc_info=True)
return False
return False