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>

Files changed (1) hide show
  1. app.py +374 -43
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
- return ws_manager.get_recent_thoughts(limit)
 
 
 
 
 
 
 
1848
 
1849
  @fastapi_app.get("/api/thoughts/stats")
1850
  async def api_get_stats():
1851
  """Get WebSocket manager statistics."""
1852
- return ws_manager.get_stats()
 
 
 
 
 
 
 
1853
 
1854
  @fastapi_app.post("/api/thoughts/clear")
1855
  async def api_clear_thoughts():
1856
  """Clear the thought history."""
1857
- ws_manager.clear_history()
1858
- return {"status": "cleared"}
 
 
 
 
 
 
 
1859
 
1860
  @fastapi_app.get("/api/agent/status")
1861
  async def api_agent_status():
1862
  """Get current agent status as JSON."""
1863
- return load_cain_status()
 
 
 
 
 
 
 
1864
 
1865
  @fastapi_app.get("/api/agent/registry")
1866
  async def api_agent_registry():
1867
  """Get agent registry as JSON."""
1868
- return load_agent_registry()
 
 
 
 
 
 
 
1869
 
1870
  @fastapi_app.get("/api/agent/startup")
1871
  async def api_agent_startup():
1872
  """Get startup initialization report."""
1873
- return get_startup_report()
 
 
 
 
 
 
 
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
- return get_analytics_stats_json()
 
 
 
 
 
 
 
1890
 
1891
  @fastapi_app.get("/api/analytics/summary")
1892
  async def api_analytics_summary():
1893
  """Get analytics summary as markdown."""
1894
- return {"summary": get_analytics_summary()}
 
 
 
 
 
 
 
1895
 
1896
  @fastapi_app.get("/api/analytics/history")
1897
  async def api_analytics_history(limit: int = 50):
1898
  """Get conversation history."""
1899
- return get_conversation_history_json(limit)
 
 
 
 
 
 
 
1900
 
1901
  @fastapi_app.post("/api/analytics/sync")
1902
  async def api_analytics_sync():
1903
  """Sync analytics to HF Dataset."""
1904
- result = analytics.sync_to_dataset()
1905
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1906
 
1907
  @fastapi_app.post("/api/analytics/reset")
1908
  async def api_analytics_reset():
1909
  """Reset analytics data."""
1910
- if analytics:
1911
- analytics.reset()
1912
- return {"status": "reset"}
 
 
 
 
 
 
 
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
- office_html = FRONTEND_DIR / "electron-standalone.html"
2057
- if office_html.exists():
2058
- return FileResponse(office_html, media_type="text/html")
2059
- return JSONResponse(status_code=404, content={"error": "Office UI not found"})
 
 
 
 
 
 
 
2060
 
2061
  @fastapi_app.get("/invite")
2062
  async def serve_invite():
2063
  """Serve the invite page."""
2064
- invite_html = FRONTEND_DIR / "invite.html"
2065
- if invite_html.exists():
2066
- return FileResponse(invite_html, media_type="text/html")
2067
- return JSONResponse(status_code=404, content={"error": "Invite page not found"})
 
 
 
 
 
 
 
2068
 
2069
  @fastapi_app.get("/join")
2070
  async def serve_join():
2071
  """Serve the join page."""
2072
- join_html = FRONTEND_DIR / "join.html"
2073
- if join_html.exists():
2074
- return FileResponse(join_html, media_type="text/html")
2075
- return JSONResponse(status_code=404, content={"error": "Join page not found"})
 
 
 
 
 
 
 
2076
 
2077
  @fastapi_app.get("/agent-dashboard")
2078
  async def serve_agent_dashboard():
2079
  """Serve the agent dashboard page."""
2080
- dashboard_html = FRONTEND_DIR / "agent-dashboard.html"
2081
- if dashboard_html.exists():
2082
- return FileResponse(dashboard_html, media_type="text/html")
2083
- return JSONResponse(status_code=404, content={"error": "Agent dashboard not found"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return PlainTextResponse(
2135
- f"Agent Office is in error mode:\n{str(e)}\n\n"
2136
- f"Check error log: {ERROR_LOG_FILE}\n"
2137
- f"Base directory: {BASE_DIR}"
2138
- )
 
 
 
 
2139
 
2140
  @fallback_app.get("/api/health")
2141
  async def error_health():
2142
- return {
2143
- "monitor_available": False,
2144
- "error": str(e),
2145
- "status": "fatal_error",
2146
- "error_log": str(ERROR_LOG_FILE)
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