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>

Files changed (1) hide show
  1. .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
- self.agent_name = agent_name or self._detect_agent()
350
-
351
- # Load personality for this agent
352
- self.personality = get_personality(self.agent_name) if CORE_MODULES_AVAILABLE else {"name": self.agent_name, "role": "Agent"}
353
- self.legacy_mode = legacy_mode or not RBAC_AVAILABLE
354
-
355
- # Initialize RBAC system
356
- if RBAC_AVAILABLE and not self.legacy_mode:
357
- self.rbac = MultiAgentSystem(legacy_mode=False)
358
- self.current_role = self.rbac.current_role
359
- else:
360
- self.rbac = None
361
- self.current_role = None
362
- self.legacy_mode = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
- # Initialize State persistence
365
- self.state_store: Optional[StateStore] = None
366
- if STATE_PERSISTENCE_AVAILABLE:
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
- # Initialize Conversation persistence
374
- self.conversation_storage: Optional[ConversationStorage] = None
375
- if CONVERSATION_PERSISTENCE_AVAILABLE:
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
- # Brain state - use AgentStateMachine for state management
383
- self.brain_state = BrainState.IDLE # Legacy brain state
384
- self.state_machine = AgentStateMachine(agent_name or "cain") # New state machine
385
- self.tools: Dict[str, Tool] = {}
386
- self.memory: Dict[str, Any] = {}
387
- self.last_action = None
388
- self.last_action_time = None
389
 
390
- # Load memory from state store
391
- self._load_memory_from_state()
392
 
393
- # Register default tools
394
- self._register_default_tools()
 
 
 
 
 
 
 
 
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
- # Sync and stop conversation storage
 
 
1188
  if self.conversation_storage is not None:
1189
  try:
1190
- self.conversation_storage.stop()
 
 
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.state_store.stop()
 
 
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 {