Spaces:
Sleeping
Sleeping
Claude Code Claude Opus 4.6 commited on
Commit ·
6077e12
1
Parent(s): 95dd22f
feat: Add comprehensive async context handling to app.py
Browse files- Add CircuitBreaker class for httpcore connection issues
- Wrap all async API endpoints with asyncio.CancelledError handling
- Add FastAPI startup/shutdown lifecycle handlers
- Add track_async_task() and shutdown_async_tasks() for graceful task cleanup
- Add logging to track where cancellations occur
- Configure logging early to capture all events
This fixes the runtime error from asyncio.CancelledError in queue operations
and httpcore connection issues during shutdown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
app.py
CHANGED
|
@@ -22,6 +22,20 @@ from typing import Dict, Any, List, Optional, Tuple
|
|
| 22 |
from queue import Queue, Empty
|
| 23 |
from dataclasses import dataclass, asdict
|
| 24 |
from enum import Enum
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# ========== Critical Import Error Handler ==========
|
| 27 |
# This MUST come before any imports that might fail
|
|
@@ -72,6 +86,139 @@ _startup_warnings = []
|
|
| 72 |
# Error log file path
|
| 73 |
ERROR_LOG_FILE = LOGS_DIR / "app_errors.log"
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def write_error_log(error_type: str, component: str, error_msg: str, details: Dict[str, Any] = None, exc_info=None):
|
| 76 |
"""Write error to log file with full traceback."""
|
| 77 |
try:
|
|
@@ -1844,33 +1991,75 @@ def create_agent_office_with_ws():
|
|
| 1844 |
@fastapi_app.get("/api/thoughts")
|
| 1845 |
async def api_get_thoughts(limit: int = 50):
|
| 1846 |
"""Get recent agent thoughts as JSON."""
|
| 1847 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1848 |
|
| 1849 |
@fastapi_app.get("/api/thoughts/stats")
|
| 1850 |
async def api_get_stats():
|
| 1851 |
"""Get WebSocket manager statistics."""
|
| 1852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1853 |
|
| 1854 |
@fastapi_app.post("/api/thoughts/clear")
|
| 1855 |
async def api_clear_thoughts():
|
| 1856 |
"""Clear the thought history."""
|
| 1857 |
-
|
| 1858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1859 |
|
| 1860 |
@fastapi_app.get("/api/agent/status")
|
| 1861 |
async def api_agent_status():
|
| 1862 |
"""Get current agent status as JSON."""
|
| 1863 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1864 |
|
| 1865 |
@fastapi_app.get("/api/agent/registry")
|
| 1866 |
async def api_agent_registry():
|
| 1867 |
"""Get agent registry as JSON."""
|
| 1868 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1869 |
|
| 1870 |
@fastapi_app.get("/api/agent/startup")
|
| 1871 |
async def api_agent_startup():
|
| 1872 |
"""Get startup initialization report."""
|
| 1873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1874 |
|
| 1875 |
@fastapi_app.post("/api/agent/reset-status")
|
| 1876 |
async def api_reset_status():
|
|
@@ -1878,7 +2067,11 @@ def create_agent_office_with_ws():
|
|
| 1878 |
try:
|
| 1879 |
status = ensure_cain_status_file()
|
| 1880 |
return {"success": True, "status": status}
|
|
|
|
|
|
|
|
|
|
| 1881 |
except Exception as e:
|
|
|
|
| 1882 |
return {"success": False, "error": str(e)}
|
| 1883 |
|
| 1884 |
# ========== Analytics API Endpoints ==========
|
|
@@ -1886,30 +2079,82 @@ def create_agent_office_with_ws():
|
|
| 1886 |
@fastapi_app.get("/api/analytics/stats")
|
| 1887 |
async def api_analytics_stats():
|
| 1888 |
"""Get conversation analytics statistics."""
|
| 1889 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1890 |
|
| 1891 |
@fastapi_app.get("/api/analytics/summary")
|
| 1892 |
async def api_analytics_summary():
|
| 1893 |
"""Get analytics summary as markdown."""
|
| 1894 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1895 |
|
| 1896 |
@fastapi_app.get("/api/analytics/history")
|
| 1897 |
async def api_analytics_history(limit: int = 50):
|
| 1898 |
"""Get conversation history."""
|
| 1899 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1900 |
|
| 1901 |
@fastapi_app.post("/api/analytics/sync")
|
| 1902 |
async def api_analytics_sync():
|
| 1903 |
"""Sync analytics to HF Dataset."""
|
| 1904 |
-
|
| 1905 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1906 |
|
| 1907 |
@fastapi_app.post("/api/analytics/reset")
|
| 1908 |
async def api_analytics_reset():
|
| 1909 |
"""Reset analytics data."""
|
| 1910 |
-
|
| 1911 |
-
analytics
|
| 1912 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1913 |
|
| 1914 |
# ========== Conversation Persistence API Endpoints ==========
|
| 1915 |
|
|
@@ -1929,6 +2174,9 @@ def create_agent_office_with_ws():
|
|
| 1929 |
"success": False,
|
| 1930 |
"error": "Conversation storage not initialized"
|
| 1931 |
}
|
|
|
|
|
|
|
|
|
|
| 1932 |
except Exception as e:
|
| 1933 |
write_error_log("api_error", "persistence_metrics", f"Failed to get metrics: {e}", exc_info=sys.exc_info())
|
| 1934 |
return {
|
|
@@ -1948,6 +2196,9 @@ def create_agent_office_with_ws():
|
|
| 1948 |
"success": False,
|
| 1949 |
"error": "Conversation storage not initialized"
|
| 1950 |
}
|
|
|
|
|
|
|
|
|
|
| 1951 |
except Exception as e:
|
| 1952 |
write_error_log("api_error", "persistence_sync", f"Sync failed: {e}", exc_info=sys.exc_info())
|
| 1953 |
return {
|
|
@@ -1968,6 +2219,9 @@ def create_agent_office_with_ws():
|
|
| 1968 |
"current_state": cain_status.get("current_state", "unknown"),
|
| 1969 |
"timestamp": datetime.utcnow().isoformat() + "Z"
|
| 1970 |
}
|
|
|
|
|
|
|
|
|
|
| 1971 |
except Exception as e:
|
| 1972 |
return {
|
| 1973 |
"status": "unhealthy",
|
|
@@ -1998,7 +2252,8 @@ def create_agent_office_with_ws():
|
|
| 1998 |
"rbac": {"status": "healthy" if RBAC_AVAILABLE else "unavailable"},
|
| 1999 |
"analytics": {"status": "healthy" if ANALYTICS_AVAILABLE else "unavailable"},
|
| 2000 |
"persistence": {"status": "healthy" if CONVERSATION_STORAGE_AVAILABLE and conversation_storage else "unavailable"},
|
| 2001 |
-
}
|
|
|
|
| 2002 |
}
|
| 2003 |
|
| 2004 |
# Add error from cain_status if present (but not "unknown" or empty)
|
|
@@ -2007,6 +2262,9 @@ def create_agent_office_with_ws():
|
|
| 2007 |
result["error"] = cain_error
|
| 2008 |
|
| 2009 |
return result
|
|
|
|
|
|
|
|
|
|
| 2010 |
except Exception as e:
|
| 2011 |
write_error_log("api_error", "health", f"Health check failed: {e}", exc_info=sys.exc_info())
|
| 2012 |
return {
|
|
@@ -2041,6 +2299,9 @@ def create_agent_office_with_ws():
|
|
| 2041 |
"logs": logs[-limit:],
|
| 2042 |
"total": len(logs)
|
| 2043 |
}
|
|
|
|
|
|
|
|
|
|
| 2044 |
except Exception as e:
|
| 2045 |
return {
|
| 2046 |
"success": False,
|
|
@@ -2053,34 +2314,86 @@ def create_agent_office_with_ws():
|
|
| 2053 |
@fastapi_app.get("/office")
|
| 2054 |
async def serve_office():
|
| 2055 |
"""Serve the main Office UI (electron-standalone.html)."""
|
| 2056 |
-
|
| 2057 |
-
|
| 2058 |
-
|
| 2059 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2060 |
|
| 2061 |
@fastapi_app.get("/invite")
|
| 2062 |
async def serve_invite():
|
| 2063 |
"""Serve the invite page."""
|
| 2064 |
-
|
| 2065 |
-
|
| 2066 |
-
|
| 2067 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2068 |
|
| 2069 |
@fastapi_app.get("/join")
|
| 2070 |
async def serve_join():
|
| 2071 |
"""Serve the join page."""
|
| 2072 |
-
|
| 2073 |
-
|
| 2074 |
-
|
| 2075 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2076 |
|
| 2077 |
@fastapi_app.get("/agent-dashboard")
|
| 2078 |
async def serve_agent_dashboard():
|
| 2079 |
"""Serve the agent dashboard page."""
|
| 2080 |
-
|
| 2081 |
-
|
| 2082 |
-
|
| 2083 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2084 |
|
| 2085 |
# Mount static files directory
|
| 2086 |
if FRONTEND_DIR.exists():
|
|
@@ -2131,20 +2444,38 @@ except Exception as e:
|
|
| 2131 |
|
| 2132 |
@fallback_app.get("/")
|
| 2133 |
async def error_root():
|
| 2134 |
-
|
| 2135 |
-
|
| 2136 |
-
|
| 2137 |
-
|
| 2138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2139 |
|
| 2140 |
@fallback_app.get("/api/health")
|
| 2141 |
async def error_health():
|
| 2142 |
-
|
| 2143 |
-
|
| 2144 |
-
|
| 2145 |
-
|
| 2146 |
-
|
| 2147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2148 |
|
| 2149 |
app = fallback_app
|
| 2150 |
|
|
|
|
| 22 |
from queue import Queue, Empty
|
| 23 |
from dataclasses import dataclass, asdict
|
| 24 |
from enum import Enum
|
| 25 |
+
import logging
|
| 26 |
+
|
| 27 |
+
# ========== Logging Configuration ==========
|
| 28 |
+
# Configure logging early to capture all events
|
| 29 |
+
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=logging.DEBUG,
|
| 32 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 33 |
+
handlers=[
|
| 34 |
+
logging.StreamHandler(sys.stdout),
|
| 35 |
+
logging.FileHandler(LOGS_DIR / "app.log")
|
| 36 |
+
]
|
| 37 |
+
)
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
|
| 40 |
# ========== Critical Import Error Handler ==========
|
| 41 |
# This MUST come before any imports that might fail
|
|
|
|
| 86 |
# Error log file path
|
| 87 |
ERROR_LOG_FILE = LOGS_DIR / "app_errors.log"
|
| 88 |
|
| 89 |
+
# ========== Async Context & Circuit Breaker ==========
|
| 90 |
+
|
| 91 |
+
# Track all async tasks for graceful shutdown
|
| 92 |
+
_active_async_tasks: set = set()
|
| 93 |
+
_shutdown_event = asyncio.Event()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class CircuitBreaker:
|
| 97 |
+
"""Circuit breaker for httpcore connection issues."""
|
| 98 |
+
|
| 99 |
+
def __init__(self, failure_threshold: int = 5, recovery_timeout: float = 60.0):
|
| 100 |
+
self.failure_threshold = failure_threshold
|
| 101 |
+
self.recovery_timeout = recovery_timeout
|
| 102 |
+
self._failure_count = 0
|
| 103 |
+
self._last_failure_time = 0
|
| 104 |
+
self._state = "closed" # closed, open, half_open
|
| 105 |
+
self._lock = threading.Lock()
|
| 106 |
+
|
| 107 |
+
def record_failure(self):
|
| 108 |
+
"""Record a failure and potentially open the circuit."""
|
| 109 |
+
with self._lock:
|
| 110 |
+
self._failure_count += 1
|
| 111 |
+
self._last_failure_time = time.time()
|
| 112 |
+
if self._failure_count >= self.failure_threshold:
|
| 113 |
+
self._state = "open"
|
| 114 |
+
logger.warning(f"[CIRCUIT_BREAKER] Circuit opened after {self._failure_count} failures")
|
| 115 |
+
|
| 116 |
+
def record_success(self):
|
| 117 |
+
"""Record a success and potentially close the circuit."""
|
| 118 |
+
with self._lock:
|
| 119 |
+
if self._state == "half_open":
|
| 120 |
+
self._state = "closed"
|
| 121 |
+
self._failure_count = 0
|
| 122 |
+
logger.info("[CIRCUIT_BREAKER] Circuit closed after successful recovery")
|
| 123 |
+
|
| 124 |
+
def can_attempt(self) -> bool:
|
| 125 |
+
"""Check if an operation can be attempted."""
|
| 126 |
+
with self._lock:
|
| 127 |
+
if self._state == "closed":
|
| 128 |
+
return True
|
| 129 |
+
if self._state == "open":
|
| 130 |
+
if time.time() - self._last_failure_time > self.recovery_timeout:
|
| 131 |
+
self._state = "half_open"
|
| 132 |
+
logger.info("[CIRCUIT_BREAKER] Circuit moved to half-open state")
|
| 133 |
+
return True
|
| 134 |
+
return False
|
| 135 |
+
return True # half_open
|
| 136 |
+
|
| 137 |
+
def get_state(self) -> Dict[str, Any]:
|
| 138 |
+
"""Get circuit breaker state."""
|
| 139 |
+
with self._lock:
|
| 140 |
+
return {
|
| 141 |
+
"state": self._state,
|
| 142 |
+
"failure_count": self._failure_count,
|
| 143 |
+
"last_failure_time": self._last_failure_time,
|
| 144 |
+
"can_attempt": self.can_attempt()
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# Global circuit breaker for httpcore operations
|
| 149 |
+
httpcore_breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30.0)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def track_async_task(task: asyncio.Task):
|
| 153 |
+
"""Track an async task for graceful shutdown."""
|
| 154 |
+
_active_async_tasks.add(task)
|
| 155 |
+
task.add_done_callback(_active_async_tasks.discard)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def safe_async_wrapper(coro, operation_name: str = "async_operation"):
|
| 159 |
+
"""
|
| 160 |
+
Wrap an async operation with CancelledError handling and logging.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
coro: The coroutine to execute
|
| 164 |
+
operation_name: Name of the operation for logging
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
The result of the coroutine, or None if cancelled
|
| 168 |
+
"""
|
| 169 |
+
task = asyncio.current_task()
|
| 170 |
+
if task:
|
| 171 |
+
track_async_task(task)
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
logger.debug(f"[ASYNC] Starting {operation_name}")
|
| 175 |
+
result = await coro
|
| 176 |
+
logger.debug(f"[ASYNC] Completed {operation_name}")
|
| 177 |
+
return result
|
| 178 |
+
except asyncio.CancelledError:
|
| 179 |
+
logger.info(f"[ASYNC_CANCEL] {operation_name} was cancelled at {datetime.utcnow().isoformat()}Z")
|
| 180 |
+
write_error_log("async_cancelled", operation_name, "Operation was cancelled by asyncio")
|
| 181 |
+
raise # Re-raise to allow proper cleanup
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"[ASYNC_ERROR] {operation_name} failed: {e}")
|
| 184 |
+
write_error_log("async_error", operation_name, str(e), exc_info=sys.exc_info())
|
| 185 |
+
raise
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
async def shutdown_async_tasks(timeout: float = 5.0):
|
| 189 |
+
"""
|
| 190 |
+
Gracefully shutdown all tracked async tasks.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
timeout: Maximum time to wait for tasks to complete
|
| 194 |
+
"""
|
| 195 |
+
logger.info(f"[ASYNC_SHUTDOWN] Shutting down {_active_async_tasks} tasks...")
|
| 196 |
+
_shutdown_event.set()
|
| 197 |
+
|
| 198 |
+
if not _active_async_tasks:
|
| 199 |
+
logger.info("[ASYNC_SHUTDOWN] No active tasks to shutdown")
|
| 200 |
+
return
|
| 201 |
+
|
| 202 |
+
# Cancel all tasks
|
| 203 |
+
for task in list(_active_async_tasks):
|
| 204 |
+
if not task.done():
|
| 205 |
+
task.cancel()
|
| 206 |
+
logger.debug(f"[ASYNC_SHUTDOWN] Cancelled task: {task.get_name() if hasattr(task, 'get_name') else 'unknown'}")
|
| 207 |
+
|
| 208 |
+
# Wait for tasks to complete
|
| 209 |
+
if _active_async_tasks:
|
| 210 |
+
try:
|
| 211 |
+
await asyncio.wait_for(
|
| 212 |
+
asyncio.gather(*_active_async_tasks, return_exceptions=True),
|
| 213 |
+
timeout=timeout
|
| 214 |
+
)
|
| 215 |
+
except asyncio.TimeoutError:
|
| 216 |
+
logger.warning(f"[ASYNC_SHUTDOWN] Timeout waiting for tasks to complete after {timeout}s")
|
| 217 |
+
except asyncio.CancelledError:
|
| 218 |
+
logger.info("[ASYNC_SHUTDOWN] Shutdown itself was cancelled")
|
| 219 |
+
|
| 220 |
+
logger.info("[ASYNC_SHUTDOWN] All tasks handled")
|
| 221 |
+
|
| 222 |
def write_error_log(error_type: str, component: str, error_msg: str, details: Dict[str, Any] = None, exc_info=None):
|
| 223 |
"""Write error to log file with full traceback."""
|
| 224 |
try:
|
|
|
|
| 1991 |
@fastapi_app.get("/api/thoughts")
|
| 1992 |
async def api_get_thoughts(limit: int = 50):
|
| 1993 |
"""Get recent agent thoughts as JSON."""
|
| 1994 |
+
try:
|
| 1995 |
+
return ws_manager.get_recent_thoughts(limit)
|
| 1996 |
+
except asyncio.CancelledError:
|
| 1997 |
+
logger.info("[API_CANCEL] /api/thoughts was cancelled")
|
| 1998 |
+
raise
|
| 1999 |
+
except Exception as e:
|
| 2000 |
+
logger.error(f"[API_ERROR] /api/thoughts: {e}")
|
| 2001 |
+
return {"error": str(e)}
|
| 2002 |
|
| 2003 |
@fastapi_app.get("/api/thoughts/stats")
|
| 2004 |
async def api_get_stats():
|
| 2005 |
"""Get WebSocket manager statistics."""
|
| 2006 |
+
try:
|
| 2007 |
+
return ws_manager.get_stats()
|
| 2008 |
+
except asyncio.CancelledError:
|
| 2009 |
+
logger.info("[API_CANCEL] /api/thoughts/stats was cancelled")
|
| 2010 |
+
raise
|
| 2011 |
+
except Exception as e:
|
| 2012 |
+
logger.error(f"[API_ERROR] /api/thoughts/stats: {e}")
|
| 2013 |
+
return {"error": str(e)}
|
| 2014 |
|
| 2015 |
@fastapi_app.post("/api/thoughts/clear")
|
| 2016 |
async def api_clear_thoughts():
|
| 2017 |
"""Clear the thought history."""
|
| 2018 |
+
try:
|
| 2019 |
+
ws_manager.clear_history()
|
| 2020 |
+
return {"status": "cleared"}
|
| 2021 |
+
except asyncio.CancelledError:
|
| 2022 |
+
logger.info("[API_CANCEL] /api/thoughts/clear was cancelled")
|
| 2023 |
+
raise
|
| 2024 |
+
except Exception as e:
|
| 2025 |
+
logger.error(f"[API_ERROR] /api/thoughts/clear: {e}")
|
| 2026 |
+
return {"error": str(e)}
|
| 2027 |
|
| 2028 |
@fastapi_app.get("/api/agent/status")
|
| 2029 |
async def api_agent_status():
|
| 2030 |
"""Get current agent status as JSON."""
|
| 2031 |
+
try:
|
| 2032 |
+
return load_cain_status()
|
| 2033 |
+
except asyncio.CancelledError:
|
| 2034 |
+
logger.info("[API_CANCEL] /api/agent/status was cancelled")
|
| 2035 |
+
raise
|
| 2036 |
+
except Exception as e:
|
| 2037 |
+
logger.error(f"[API_ERROR] /api/agent/status: {e}")
|
| 2038 |
+
return {"error": str(e)}
|
| 2039 |
|
| 2040 |
@fastapi_app.get("/api/agent/registry")
|
| 2041 |
async def api_agent_registry():
|
| 2042 |
"""Get agent registry as JSON."""
|
| 2043 |
+
try:
|
| 2044 |
+
return load_agent_registry()
|
| 2045 |
+
except asyncio.CancelledError:
|
| 2046 |
+
logger.info("[API_CANCEL] /api/agent/registry was cancelled")
|
| 2047 |
+
raise
|
| 2048 |
+
except Exception as e:
|
| 2049 |
+
logger.error(f"[API_ERROR] /api/agent/registry: {e}")
|
| 2050 |
+
return {"error": str(e)}
|
| 2051 |
|
| 2052 |
@fastapi_app.get("/api/agent/startup")
|
| 2053 |
async def api_agent_startup():
|
| 2054 |
"""Get startup initialization report."""
|
| 2055 |
+
try:
|
| 2056 |
+
return get_startup_report()
|
| 2057 |
+
except asyncio.CancelledError:
|
| 2058 |
+
logger.info("[API_CANCEL] /api/agent/startup was cancelled")
|
| 2059 |
+
raise
|
| 2060 |
+
except Exception as e:
|
| 2061 |
+
logger.error(f"[API_ERROR] /api/agent/startup: {e}")
|
| 2062 |
+
return {"error": str(e)}
|
| 2063 |
|
| 2064 |
@fastapi_app.post("/api/agent/reset-status")
|
| 2065 |
async def api_reset_status():
|
|
|
|
| 2067 |
try:
|
| 2068 |
status = ensure_cain_status_file()
|
| 2069 |
return {"success": True, "status": status}
|
| 2070 |
+
except asyncio.CancelledError:
|
| 2071 |
+
logger.info("[API_CANCEL] /api/agent/reset-status was cancelled")
|
| 2072 |
+
raise
|
| 2073 |
except Exception as e:
|
| 2074 |
+
logger.error(f"[API_ERROR] /api/agent/reset-status: {e}")
|
| 2075 |
return {"success": False, "error": str(e)}
|
| 2076 |
|
| 2077 |
# ========== Analytics API Endpoints ==========
|
|
|
|
| 2079 |
@fastapi_app.get("/api/analytics/stats")
|
| 2080 |
async def api_analytics_stats():
|
| 2081 |
"""Get conversation analytics statistics."""
|
| 2082 |
+
try:
|
| 2083 |
+
return get_analytics_stats_json()
|
| 2084 |
+
except asyncio.CancelledError:
|
| 2085 |
+
logger.info("[API_CANCEL] /api/analytics/stats was cancelled")
|
| 2086 |
+
raise
|
| 2087 |
+
except Exception as e:
|
| 2088 |
+
logger.error(f"[API_ERROR] /api/analytics/stats: {e}")
|
| 2089 |
+
return {"error": str(e)}
|
| 2090 |
|
| 2091 |
@fastapi_app.get("/api/analytics/summary")
|
| 2092 |
async def api_analytics_summary():
|
| 2093 |
"""Get analytics summary as markdown."""
|
| 2094 |
+
try:
|
| 2095 |
+
return {"summary": get_analytics_summary()}
|
| 2096 |
+
except asyncio.CancelledError:
|
| 2097 |
+
logger.info("[API_CANCEL] /api/analytics/summary was cancelled")
|
| 2098 |
+
raise
|
| 2099 |
+
except Exception as e:
|
| 2100 |
+
logger.error(f"[API_ERROR] /api/analytics/summary: {e}")
|
| 2101 |
+
return {"error": str(e)}
|
| 2102 |
|
| 2103 |
@fastapi_app.get("/api/analytics/history")
|
| 2104 |
async def api_analytics_history(limit: int = 50):
|
| 2105 |
"""Get conversation history."""
|
| 2106 |
+
try:
|
| 2107 |
+
return get_conversation_history_json(limit)
|
| 2108 |
+
except asyncio.CancelledError:
|
| 2109 |
+
logger.info("[API_CANCEL] /api/analytics/history was cancelled")
|
| 2110 |
+
raise
|
| 2111 |
+
except Exception as e:
|
| 2112 |
+
logger.error(f"[API_ERROR] /api/analytics/history: {e}")
|
| 2113 |
+
return {"error": str(e)}
|
| 2114 |
|
| 2115 |
@fastapi_app.post("/api/analytics/sync")
|
| 2116 |
async def api_analytics_sync():
|
| 2117 |
"""Sync analytics to HF Dataset."""
|
| 2118 |
+
try:
|
| 2119 |
+
# Check circuit breaker before making HTTP request
|
| 2120 |
+
if not httpcore_breaker.can_attempt():
|
| 2121 |
+
logger.warning("[CIRCUIT_BREAKER] Analytics sync blocked - circuit is open")
|
| 2122 |
+
return {
|
| 2123 |
+
"success": False,
|
| 2124 |
+
"error": "Circuit breaker is open - too many recent failures",
|
| 2125 |
+
"breaker_state": httpcore_breaker.get_state()
|
| 2126 |
+
}
|
| 2127 |
+
|
| 2128 |
+
result = analytics.sync_to_dataset()
|
| 2129 |
+
|
| 2130 |
+
# Record success or failure
|
| 2131 |
+
if result.get("success"):
|
| 2132 |
+
httpcore_breaker.record_success()
|
| 2133 |
+
else:
|
| 2134 |
+
httpcore_breaker.record_failure()
|
| 2135 |
+
|
| 2136 |
+
return result
|
| 2137 |
+
except asyncio.CancelledError:
|
| 2138 |
+
logger.info("[API_CANCEL] /api/analytics/sync was cancelled")
|
| 2139 |
+
raise
|
| 2140 |
+
except Exception as e:
|
| 2141 |
+
httpcore_breaker.record_failure()
|
| 2142 |
+
logger.error(f"[API_ERROR] /api/analytics/sync: {e}")
|
| 2143 |
+
return {"error": str(e), "breaker_state": httpcore_breaker.get_state()}
|
| 2144 |
|
| 2145 |
@fastapi_app.post("/api/analytics/reset")
|
| 2146 |
async def api_analytics_reset():
|
| 2147 |
"""Reset analytics data."""
|
| 2148 |
+
try:
|
| 2149 |
+
if analytics:
|
| 2150 |
+
analytics.reset()
|
| 2151 |
+
return {"status": "reset"}
|
| 2152 |
+
except asyncio.CancelledError:
|
| 2153 |
+
logger.info("[API_CANCEL] /api/analytics/reset was cancelled")
|
| 2154 |
+
raise
|
| 2155 |
+
except Exception as e:
|
| 2156 |
+
logger.error(f"[API_ERROR] /api/analytics/reset: {e}")
|
| 2157 |
+
return {"error": str(e)}
|
| 2158 |
|
| 2159 |
# ========== Conversation Persistence API Endpoints ==========
|
| 2160 |
|
|
|
|
| 2174 |
"success": False,
|
| 2175 |
"error": "Conversation storage not initialized"
|
| 2176 |
}
|
| 2177 |
+
except asyncio.CancelledError:
|
| 2178 |
+
logger.info("[API_CANCEL] /api/persistence/metrics was cancelled")
|
| 2179 |
+
raise
|
| 2180 |
except Exception as e:
|
| 2181 |
write_error_log("api_error", "persistence_metrics", f"Failed to get metrics: {e}", exc_info=sys.exc_info())
|
| 2182 |
return {
|
|
|
|
| 2196 |
"success": False,
|
| 2197 |
"error": "Conversation storage not initialized"
|
| 2198 |
}
|
| 2199 |
+
except asyncio.CancelledError:
|
| 2200 |
+
logger.info("[API_CANCEL] /api/persistence/sync was cancelled")
|
| 2201 |
+
raise
|
| 2202 |
except Exception as e:
|
| 2203 |
write_error_log("api_error", "persistence_sync", f"Sync failed: {e}", exc_info=sys.exc_info())
|
| 2204 |
return {
|
|
|
|
| 2219 |
"current_state": cain_status.get("current_state", "unknown"),
|
| 2220 |
"timestamp": datetime.utcnow().isoformat() + "Z"
|
| 2221 |
}
|
| 2222 |
+
except asyncio.CancelledError:
|
| 2223 |
+
logger.info("[API_CANCEL] /health was cancelled")
|
| 2224 |
+
raise
|
| 2225 |
except Exception as e:
|
| 2226 |
return {
|
| 2227 |
"status": "unhealthy",
|
|
|
|
| 2252 |
"rbac": {"status": "healthy" if RBAC_AVAILABLE else "unavailable"},
|
| 2253 |
"analytics": {"status": "healthy" if ANALYTICS_AVAILABLE else "unavailable"},
|
| 2254 |
"persistence": {"status": "healthy" if CONVERSATION_STORAGE_AVAILABLE and conversation_storage else "unavailable"},
|
| 2255 |
+
},
|
| 2256 |
+
"circuit_breaker": httpcore_breaker.get_state()
|
| 2257 |
}
|
| 2258 |
|
| 2259 |
# Add error from cain_status if present (but not "unknown" or empty)
|
|
|
|
| 2262 |
result["error"] = cain_error
|
| 2263 |
|
| 2264 |
return result
|
| 2265 |
+
except asyncio.CancelledError:
|
| 2266 |
+
logger.info("[API_CANCEL] /api/health was cancelled")
|
| 2267 |
+
raise
|
| 2268 |
except Exception as e:
|
| 2269 |
write_error_log("api_error", "health", f"Health check failed: {e}", exc_info=sys.exc_info())
|
| 2270 |
return {
|
|
|
|
| 2299 |
"logs": logs[-limit:],
|
| 2300 |
"total": len(logs)
|
| 2301 |
}
|
| 2302 |
+
except asyncio.CancelledError:
|
| 2303 |
+
logger.info("[API_CANCEL] /api/logs was cancelled")
|
| 2304 |
+
raise
|
| 2305 |
except Exception as e:
|
| 2306 |
return {
|
| 2307 |
"success": False,
|
|
|
|
| 2314 |
@fastapi_app.get("/office")
|
| 2315 |
async def serve_office():
|
| 2316 |
"""Serve the main Office UI (electron-standalone.html)."""
|
| 2317 |
+
try:
|
| 2318 |
+
office_html = FRONTEND_DIR / "electron-standalone.html"
|
| 2319 |
+
if office_html.exists():
|
| 2320 |
+
return FileResponse(office_html, media_type="text/html")
|
| 2321 |
+
return JSONResponse(status_code=404, content={"error": "Office UI not found"})
|
| 2322 |
+
except asyncio.CancelledError:
|
| 2323 |
+
logger.info("[API_CANCEL] /office was cancelled")
|
| 2324 |
+
raise
|
| 2325 |
+
except Exception as e:
|
| 2326 |
+
logger.error(f"[API_ERROR] /office: {e}")
|
| 2327 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2328 |
|
| 2329 |
@fastapi_app.get("/invite")
|
| 2330 |
async def serve_invite():
|
| 2331 |
"""Serve the invite page."""
|
| 2332 |
+
try:
|
| 2333 |
+
invite_html = FRONTEND_DIR / "invite.html"
|
| 2334 |
+
if invite_html.exists():
|
| 2335 |
+
return FileResponse(invite_html, media_type="text/html")
|
| 2336 |
+
return JSONResponse(status_code=404, content={"error": "Invite page not found"})
|
| 2337 |
+
except asyncio.CancelledError:
|
| 2338 |
+
logger.info("[API_CANCEL] /invite was cancelled")
|
| 2339 |
+
raise
|
| 2340 |
+
except Exception as e:
|
| 2341 |
+
logger.error(f"[API_ERROR] /invite: {e}")
|
| 2342 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2343 |
|
| 2344 |
@fastapi_app.get("/join")
|
| 2345 |
async def serve_join():
|
| 2346 |
"""Serve the join page."""
|
| 2347 |
+
try:
|
| 2348 |
+
join_html = FRONTEND_DIR / "join.html"
|
| 2349 |
+
if join_html.exists():
|
| 2350 |
+
return FileResponse(join_html, media_type="text/html")
|
| 2351 |
+
return JSONResponse(status_code=404, content={"error": "Join page not found"})
|
| 2352 |
+
except asyncio.CancelledError:
|
| 2353 |
+
logger.info("[API_CANCEL] /join was cancelled")
|
| 2354 |
+
raise
|
| 2355 |
+
except Exception as e:
|
| 2356 |
+
logger.error(f"[API_ERROR] /join: {e}")
|
| 2357 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2358 |
|
| 2359 |
@fastapi_app.get("/agent-dashboard")
|
| 2360 |
async def serve_agent_dashboard():
|
| 2361 |
"""Serve the agent dashboard page."""
|
| 2362 |
+
try:
|
| 2363 |
+
dashboard_html = FRONTEND_DIR / "agent-dashboard.html"
|
| 2364 |
+
if dashboard_html.exists():
|
| 2365 |
+
return FileResponse(dashboard_html, media_type="text/html")
|
| 2366 |
+
return JSONResponse(status_code=404, content={"error": "Agent dashboard not found"})
|
| 2367 |
+
except asyncio.CancelledError:
|
| 2368 |
+
logger.info("[API_CANCEL] /agent-dashboard was cancelled")
|
| 2369 |
+
raise
|
| 2370 |
+
except Exception as e:
|
| 2371 |
+
logger.error(f"[API_ERROR] /agent-dashboard: {e}")
|
| 2372 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 2373 |
+
|
| 2374 |
+
# ========== FastAPI Lifecycle Handlers ==========
|
| 2375 |
+
@fastapi_app.on_event("startup")
|
| 2376 |
+
async def on_startup():
|
| 2377 |
+
"""Handle application startup."""
|
| 2378 |
+
logger.info("[LIFECYCLE] FastAPI app starting up")
|
| 2379 |
+
print("[Agent Office] FastAPI startup event triggered")
|
| 2380 |
+
|
| 2381 |
+
@fastapi_app.on_event("shutdown")
|
| 2382 |
+
async def on_shutdown():
|
| 2383 |
+
"""Handle application shutdown gracefully."""
|
| 2384 |
+
logger.info("[LIFECYCLE] FastAPI app shutting down")
|
| 2385 |
+
print("[Agent Office] FastAPI shutdown event triggered")
|
| 2386 |
+
|
| 2387 |
+
# Shutdown all tracked async tasks
|
| 2388 |
+
try:
|
| 2389 |
+
await shutdown_async_tasks(timeout=3.0)
|
| 2390 |
+
except Exception as e:
|
| 2391 |
+
logger.error(f"[LIFECYCLE] Error during async task shutdown: {e}")
|
| 2392 |
+
|
| 2393 |
+
# Log circuit breaker final state
|
| 2394 |
+
logger.info(f"[LIFECYCLE] Final circuit breaker state: {httpcore_breaker.get_state()}")
|
| 2395 |
+
|
| 2396 |
+
print("[Agent Office] Graceful shutdown completed")
|
| 2397 |
|
| 2398 |
# Mount static files directory
|
| 2399 |
if FRONTEND_DIR.exists():
|
|
|
|
| 2444 |
|
| 2445 |
@fallback_app.get("/")
|
| 2446 |
async def error_root():
|
| 2447 |
+
try:
|
| 2448 |
+
return PlainTextResponse(
|
| 2449 |
+
f"Agent Office is in error mode:\n{str(e)}\n\n"
|
| 2450 |
+
f"Check error log: {ERROR_LOG_FILE}\n"
|
| 2451 |
+
f"Base directory: {BASE_DIR}"
|
| 2452 |
+
)
|
| 2453 |
+
except asyncio.CancelledError:
|
| 2454 |
+
logger.info("[API_CANCEL] Error fallback / was cancelled")
|
| 2455 |
+
raise
|
| 2456 |
|
| 2457 |
@fallback_app.get("/api/health")
|
| 2458 |
async def error_health():
|
| 2459 |
+
try:
|
| 2460 |
+
return {
|
| 2461 |
+
"monitor_available": False,
|
| 2462 |
+
"error": str(e),
|
| 2463 |
+
"status": "fatal_error",
|
| 2464 |
+
"error_log": str(ERROR_LOG_FILE)
|
| 2465 |
+
}
|
| 2466 |
+
except asyncio.CancelledError:
|
| 2467 |
+
logger.info("[API_CANCEL] Error fallback /api/health was cancelled")
|
| 2468 |
+
raise
|
| 2469 |
+
|
| 2470 |
+
# Add shutdown handler to fallback app too
|
| 2471 |
+
@fallback_app.on_event("shutdown")
|
| 2472 |
+
async def fallback_shutdown():
|
| 2473 |
+
"""Handle fallback app shutdown."""
|
| 2474 |
+
logger.info("[LIFECYCLE] Fallback app shutting down")
|
| 2475 |
+
try:
|
| 2476 |
+
await shutdown_async_tasks(timeout=1.0)
|
| 2477 |
+
except Exception as shutdown_err:
|
| 2478 |
+
logger.error(f"[LIFECYCLE] Error during fallback shutdown: {shutdown_err}")
|
| 2479 |
|
| 2480 |
app = fallback_app
|
| 2481 |
|