Spaces:
Sleeping
Sleeping
Claude Code Claude Opus 4.6 commited on
Commit ·
53ca47b
1
Parent(s): 3dc48f5
fix: Add asyncio CancelledError handling and graceful shutdown
Browse files- Add SIGTERM signal handler for graceful shutdown coordination
- Wrap initialization in try-except for CancelledError with stack trace logging
- Add 2-second timeout wrappers to async operations (state store, conversation storage)
- Add global shutdown flag to prevent race conditions during shutdown
- Update shutdown() method to use timeout wrappers
This prevents asyncio CancelledError noise during container shutdown when
background threads from state_manager and persistence are still using the
event loop.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .openclaw/agents/brain_minimal.py +232 -45
.openclaw/agents/brain_minimal.py
CHANGED
|
@@ -16,9 +16,17 @@ from datetime import datetime
|
|
| 16 |
from enum import Enum
|
| 17 |
import threading
|
| 18 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# Import core modules for personality and formatting
|
| 23 |
try:
|
| 24 |
from .core.personality import get_personality, get_system_prompt
|
|
@@ -293,6 +301,92 @@ class BrainState(Enum):
|
|
| 293 |
ERROR = "error"
|
| 294 |
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
class Tool:
|
| 297 |
"""Represents a tool that can be executed by the brain"""
|
| 298 |
|
|
@@ -346,52 +440,101 @@ class BrainMinimal:
|
|
| 346 |
agent_name: Name of the agent (adam, eve, cain)
|
| 347 |
legacy_mode: If True, operate in single-agent mode
|
| 348 |
"""
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
try:
|
| 368 |
-
self.state_store = get_state_store()
|
| 369 |
-
logger.info(f"[{self.agent_name}] State persistence initialized")
|
| 370 |
-
except Exception as e:
|
| 371 |
-
logger.warning(f"[{self.agent_name}] State persistence initialization failed: {e}")
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
try:
|
| 377 |
-
self.conversation_storage = get_conversation_storage(self.agent_name)
|
| 378 |
-
logger.info(f"[{self.agent_name}] Conversation persistence initialized")
|
| 379 |
-
except Exception as e:
|
| 380 |
-
logger.warning(f"[{self.agent_name}] Conversation persistence initialization failed: {e}")
|
| 381 |
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
self.memory: Dict[str, Any] = {}
|
| 387 |
-
self.last_action = None
|
| 388 |
-
self.last_action_time = None
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
|
| 393 |
-
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
def _load_memory_from_state(self):
|
| 397 |
"""Load memory from state store on initialization."""
|
|
@@ -1179,25 +1322,46 @@ class BrainMinimal:
|
|
| 1179 |
"""
|
| 1180 |
Shutdown the brain and sync all data.
|
| 1181 |
|
|
|
|
|
|
|
|
|
|
| 1182 |
Returns:
|
| 1183 |
Result dict with shutdown status
|
| 1184 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1185 |
results = {}
|
| 1186 |
|
| 1187 |
-
|
|
|
|
|
|
|
| 1188 |
if self.conversation_storage is not None:
|
| 1189 |
try:
|
| 1190 |
-
self.
|
|
|
|
|
|
|
| 1191 |
results["conversation_storage"] = "stopped"
|
|
|
|
|
|
|
|
|
|
| 1192 |
except Exception as e:
|
|
|
|
| 1193 |
results["conversation_storage"] = f"error: {e}"
|
| 1194 |
|
| 1195 |
-
# Stop state store
|
| 1196 |
if self.state_store is not None:
|
| 1197 |
try:
|
| 1198 |
-
self.
|
|
|
|
|
|
|
| 1199 |
results["state_store"] = "stopped"
|
|
|
|
|
|
|
|
|
|
| 1200 |
except Exception as e:
|
|
|
|
| 1201 |
results["state_store"] = f"error: {e}"
|
| 1202 |
|
| 1203 |
logger.info(f"[{self.agent_name}] Brain shutdown complete")
|
|
@@ -1208,6 +1372,29 @@ class BrainMinimal:
|
|
| 1208 |
"shutdown_results": results
|
| 1209 |
}
|
| 1210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
def get_info(self) -> Dict[str, Any]:
|
| 1212 |
"""Get information about the brain"""
|
| 1213 |
return {
|
|
|
|
| 16 |
from enum import Enum
|
| 17 |
import threading
|
| 18 |
import logging
|
| 19 |
+
import signal
|
| 20 |
+
import sys
|
| 21 |
+
import traceback
|
| 22 |
+
import asyncio
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
+
# Global shutdown flag for graceful shutdown
|
| 27 |
+
_shutdown_in_progress = False
|
| 28 |
+
_shutdown_lock = threading.Lock()
|
| 29 |
+
|
| 30 |
# Import core modules for personality and formatting
|
| 31 |
try:
|
| 32 |
from .core.personality import get_personality, get_system_prompt
|
|
|
|
| 301 |
ERROR = "error"
|
| 302 |
|
| 303 |
|
| 304 |
+
# ========== Signal Handler for Graceful Shutdown ==========
|
| 305 |
+
|
| 306 |
+
def _sigterm_handler(signum, frame):
|
| 307 |
+
"""
|
| 308 |
+
Handle SIGTERM signal for graceful shutdown.
|
| 309 |
+
|
| 310 |
+
This ensures all background tasks are properly closed before
|
| 311 |
+
the event loop closes, preventing CancelledError noise.
|
| 312 |
+
"""
|
| 313 |
+
global _shutdown_in_progress
|
| 314 |
+
with _shutdown_lock:
|
| 315 |
+
if _shutdown_in_progress:
|
| 316 |
+
return # Already shutting down
|
| 317 |
+
_shutdown_in_progress = True
|
| 318 |
+
|
| 319 |
+
logger.info(f"[SIGNAL] Received signal {signum}, initiating graceful shutdown...")
|
| 320 |
+
|
| 321 |
+
# Get the current brain instance and trigger shutdown
|
| 322 |
+
try:
|
| 323 |
+
global _global_brain_instance
|
| 324 |
+
if _global_brain_instance is not None:
|
| 325 |
+
logger.info("[SIGNAL] Shutting down brain gracefully...")
|
| 326 |
+
_global_brain_instance.shutdown()
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"[SIGNAL] Error during brain shutdown: {e}")
|
| 329 |
+
|
| 330 |
+
# Also shutdown state store and conversation storage if available
|
| 331 |
+
try:
|
| 332 |
+
if STATE_PERSISTENCE_AVAILABLE:
|
| 333 |
+
from core.state_manager import get_state_store
|
| 334 |
+
state_store = get_state_store()
|
| 335 |
+
if state_store:
|
| 336 |
+
state_store.stop()
|
| 337 |
+
logger.info("[SIGNAL] State store stopped")
|
| 338 |
+
except Exception as e:
|
| 339 |
+
logger.warning(f"[SIGNAL] Error stopping state store: {e}")
|
| 340 |
+
|
| 341 |
+
try:
|
| 342 |
+
if CONVERSATION_PERSISTENCE_AVAILABLE:
|
| 343 |
+
from core.persistence import get_conversation_storage
|
| 344 |
+
storage = get_conversation_storage()
|
| 345 |
+
if storage:
|
| 346 |
+
storage.stop()
|
| 347 |
+
logger.info("[SIGNAL] Conversation storage stopped")
|
| 348 |
+
except Exception as e:
|
| 349 |
+
logger.warning(f"[SIGNAL] Error stopping conversation storage: {e}")
|
| 350 |
+
|
| 351 |
+
logger.info("[SIGNAL] Graceful shutdown complete")
|
| 352 |
+
|
| 353 |
+
# Register signal handler for SIGTERM
|
| 354 |
+
try:
|
| 355 |
+
signal.signal(signal.SIGTERM, _sigterm_handler)
|
| 356 |
+
logger.info("[INIT] SIGTERM handler registered for graceful shutdown")
|
| 357 |
+
except ValueError:
|
| 358 |
+
# Signal handlers can only be set from main thread
|
| 359 |
+
logger.debug("[INIT] Could not set SIGTERM handler from non-main thread")
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def with_http_timeout(func):
|
| 363 |
+
"""
|
| 364 |
+
Decorator to add 2-second timeout to HTTP calls.
|
| 365 |
+
|
| 366 |
+
This prevents hanging HTTP operations during shutdown.
|
| 367 |
+
"""
|
| 368 |
+
def wrapper(*args, **kwargs):
|
| 369 |
+
import concurrent.futures
|
| 370 |
+
import time
|
| 371 |
+
|
| 372 |
+
def _execute():
|
| 373 |
+
return func(*args, **kwargs)
|
| 374 |
+
|
| 375 |
+
try:
|
| 376 |
+
# Use ThreadPoolExecutor with 2-second timeout
|
| 377 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
| 378 |
+
future = executor.submit(_execute)
|
| 379 |
+
return future.result(timeout=2.0)
|
| 380 |
+
except concurrent.futures.TimeoutError:
|
| 381 |
+
logger.warning(f"[HTTP_TIMEOUT] Operation {func.__name__} timed out after 2 seconds")
|
| 382 |
+
return None
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"[HTTP_ERROR] Operation {func.__name__} failed: {e}")
|
| 385 |
+
raise
|
| 386 |
+
|
| 387 |
+
return wrapper
|
| 388 |
+
|
| 389 |
+
|
| 390 |
class Tool:
|
| 391 |
"""Represents a tool that can be executed by the brain"""
|
| 392 |
|
|
|
|
| 440 |
agent_name: Name of the agent (adam, eve, cain)
|
| 441 |
legacy_mode: If True, operate in single-agent mode
|
| 442 |
"""
|
| 443 |
+
# Wrap entire initialization in try-except for asyncio.CancelledError
|
| 444 |
+
try:
|
| 445 |
+
self.agent_name = agent_name or self._detect_agent()
|
| 446 |
+
|
| 447 |
+
# Load personality for this agent
|
| 448 |
+
self.personality = get_personality(self.agent_name) if CORE_MODULES_AVAILABLE else {"name": self.agent_name, "role": "Agent"}
|
| 449 |
+
self.legacy_mode = legacy_mode or not RBAC_AVAILABLE
|
| 450 |
+
|
| 451 |
+
# Initialize RBAC system
|
| 452 |
+
if RBAC_AVAILABLE and not self.legacy_mode:
|
| 453 |
+
self.rbac = MultiAgentSystem(legacy_mode=False)
|
| 454 |
+
self.current_role = self.rbac.current_role
|
| 455 |
+
else:
|
| 456 |
+
self.rbac = None
|
| 457 |
+
self.current_role = None
|
| 458 |
+
self.legacy_mode = True
|
| 459 |
+
|
| 460 |
+
# Initialize State persistence with timeout wrapper
|
| 461 |
+
self.state_store: Optional[StateStore] = None
|
| 462 |
+
if STATE_PERSISTENCE_AVAILABLE:
|
| 463 |
+
try:
|
| 464 |
+
# Use timeout wrapper to prevent hanging during shutdown
|
| 465 |
+
self.state_store = self._init_with_timeout(get_state_store, 2.0)
|
| 466 |
+
logger.info(f"[{self.agent_name}] State persistence initialized")
|
| 467 |
+
except asyncio.CancelledError:
|
| 468 |
+
logger.info(f"[{self.agent_name}] State persistence initialization cancelled (shutdown in progress)")
|
| 469 |
+
raise
|
| 470 |
+
except Exception as e:
|
| 471 |
+
logger.warning(f"[{self.agent_name}] State persistence initialization failed: {e}")
|
| 472 |
+
|
| 473 |
+
# Initialize Conversation persistence with timeout wrapper
|
| 474 |
+
self.conversation_storage: Optional[ConversationStorage] = None
|
| 475 |
+
if CONVERSATION_PERSISTENCE_AVAILABLE:
|
| 476 |
+
try:
|
| 477 |
+
# Use timeout wrapper to prevent hanging during shutdown
|
| 478 |
+
self.conversation_storage = self._init_with_timeout(
|
| 479 |
+
lambda: get_conversation_storage(self.agent_name), 2.0
|
| 480 |
+
)
|
| 481 |
+
logger.info(f"[{self.agent_name}] Conversation persistence initialized")
|
| 482 |
+
except asyncio.CancelledError:
|
| 483 |
+
logger.info(f"[{self.agent_name}] Conversation persistence initialization cancelled (shutdown in progress)")
|
| 484 |
+
raise
|
| 485 |
+
except Exception as e:
|
| 486 |
+
logger.warning(f"[{self.agent_name}] Conversation persistence initialization failed: {e}")
|
| 487 |
+
|
| 488 |
+
# Brain state - use AgentStateMachine for state management
|
| 489 |
+
self.brain_state = BrainState.IDLE # Legacy brain state
|
| 490 |
+
self.state_machine = AgentStateMachine(agent_name or "cain") # New state machine
|
| 491 |
+
self.tools: Dict[str, Tool] = {}
|
| 492 |
+
self.memory: Dict[str, Any] = {}
|
| 493 |
+
self.last_action = None
|
| 494 |
+
self.last_action_time = None
|
| 495 |
+
|
| 496 |
+
# Load memory from state store
|
| 497 |
+
self._load_memory_from_state()
|
| 498 |
+
|
| 499 |
+
# Register default tools
|
| 500 |
+
self._register_default_tools()
|
| 501 |
+
|
| 502 |
+
except asyncio.CancelledError:
|
| 503 |
+
# Log cancellation with stack trace for debugging
|
| 504 |
+
logger.info(f"[{self.agent_name if hasattr(self, 'agent_name') else 'brain'}] Initialization cancelled gracefully")
|
| 505 |
+
logger.debug(f"[CANCELLED] Stack trace:\n{''.join(traceback.format_stack())}")
|
| 506 |
+
# Re-raise to allow proper cleanup
|
| 507 |
+
raise
|
| 508 |
+
except Exception as e:
|
| 509 |
+
logger.error(f"[{agent_name or 'brain'}] Initialization failed: {e}")
|
| 510 |
+
raise
|
| 511 |
|
| 512 |
+
def _init_with_timeout(self, init_func, timeout: float = 2.0):
|
| 513 |
+
"""
|
| 514 |
+
Initialize a component with timeout to prevent hanging during shutdown.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
+
Args:
|
| 517 |
+
init_func: Function to call for initialization
|
| 518 |
+
timeout: Timeout in seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
+
Returns:
|
| 521 |
+
Result of the initialization function
|
| 522 |
+
"""
|
| 523 |
+
import concurrent.futures
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
+
def _execute():
|
| 526 |
+
return init_func()
|
| 527 |
|
| 528 |
+
try:
|
| 529 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
| 530 |
+
future = executor.submit(_execute)
|
| 531 |
+
return future.result(timeout=timeout)
|
| 532 |
+
except concurrent.futures.TimeoutError:
|
| 533 |
+
logger.warning(f"[INIT_TIMEOUT] Initialization timed out after {timeout} seconds")
|
| 534 |
+
return None
|
| 535 |
+
except asyncio.CancelledError:
|
| 536 |
+
logger.info(f"[INIT_CANCELLED] Initialization cancelled by asyncio")
|
| 537 |
+
raise
|
| 538 |
|
| 539 |
def _load_memory_from_state(self):
|
| 540 |
"""Load memory from state store on initialization."""
|
|
|
|
| 1322 |
"""
|
| 1323 |
Shutdown the brain and sync all data.
|
| 1324 |
|
| 1325 |
+
Ensures graceful shutdown of all background tasks before
|
| 1326 |
+
the event loop closes, preventing CancelledError noise.
|
| 1327 |
+
|
| 1328 |
Returns:
|
| 1329 |
Result dict with shutdown status
|
| 1330 |
"""
|
| 1331 |
+
global _shutdown_in_progress
|
| 1332 |
+
with _shutdown_lock:
|
| 1333 |
+
_shutdown_in_progress = True
|
| 1334 |
+
|
| 1335 |
results = {}
|
| 1336 |
|
| 1337 |
+
logger.info(f"[{self.agent_name}] Starting graceful shutdown...")
|
| 1338 |
+
|
| 1339 |
+
# Sync and stop conversation storage with timeout
|
| 1340 |
if self.conversation_storage is not None:
|
| 1341 |
try:
|
| 1342 |
+
logger.debug(f"[{self.agent_name}] Stopping conversation storage...")
|
| 1343 |
+
# Use a short timeout to prevent hanging
|
| 1344 |
+
self._shutdown_with_timeout(self.conversation_storage.stop, 2.0)
|
| 1345 |
results["conversation_storage"] = "stopped"
|
| 1346 |
+
except asyncio.CancelledError:
|
| 1347 |
+
logger.info(f"[{self.agent_name}] Conversation storage shutdown cancelled")
|
| 1348 |
+
results["conversation_storage"] = "cancelled"
|
| 1349 |
except Exception as e:
|
| 1350 |
+
logger.warning(f"[{self.agent_name}] Error stopping conversation storage: {e}")
|
| 1351 |
results["conversation_storage"] = f"error: {e}"
|
| 1352 |
|
| 1353 |
+
# Stop state store with timeout
|
| 1354 |
if self.state_store is not None:
|
| 1355 |
try:
|
| 1356 |
+
logger.debug(f"[{self.agent_name}] Stopping state store...")
|
| 1357 |
+
# Use a short timeout to prevent hanging
|
| 1358 |
+
self._shutdown_with_timeout(self.state_store.stop, 2.0)
|
| 1359 |
results["state_store"] = "stopped"
|
| 1360 |
+
except asyncio.CancelledError:
|
| 1361 |
+
logger.info(f"[{self.agent_name}] State store shutdown cancelled")
|
| 1362 |
+
results["state_store"] = "cancelled"
|
| 1363 |
except Exception as e:
|
| 1364 |
+
logger.warning(f"[{self.agent_name}] Error stopping state store: {e}")
|
| 1365 |
results["state_store"] = f"error: {e}"
|
| 1366 |
|
| 1367 |
logger.info(f"[{self.agent_name}] Brain shutdown complete")
|
|
|
|
| 1372 |
"shutdown_results": results
|
| 1373 |
}
|
| 1374 |
|
| 1375 |
+
def _shutdown_with_timeout(self, shutdown_func, timeout: float = 2.0):
|
| 1376 |
+
"""
|
| 1377 |
+
Execute a shutdown function with timeout to prevent hanging.
|
| 1378 |
+
|
| 1379 |
+
Args:
|
| 1380 |
+
shutdown_func: Function to call for shutdown
|
| 1381 |
+
timeout: Timeout in seconds
|
| 1382 |
+
"""
|
| 1383 |
+
import concurrent.futures
|
| 1384 |
+
|
| 1385 |
+
def _execute():
|
| 1386 |
+
return shutdown_func()
|
| 1387 |
+
|
| 1388 |
+
try:
|
| 1389 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
| 1390 |
+
future = executor.submit(_execute)
|
| 1391 |
+
future.result(timeout=timeout)
|
| 1392 |
+
except concurrent.futures.TimeoutError:
|
| 1393 |
+
logger.warning(f"[SHUTDOWN_TIMEOUT] Shutdown operation timed out after {timeout} seconds")
|
| 1394 |
+
except asyncio.CancelledError:
|
| 1395 |
+
logger.info(f"[SHUTDOWN_CANCELLED] Shutdown cancelled by asyncio")
|
| 1396 |
+
raise
|
| 1397 |
+
|
| 1398 |
def get_info(self) -> Dict[str, Any]:
|
| 1399 |
"""Get information about the brain"""
|
| 1400 |
return {
|