Spaces:
Sleeping
Sleeping
Claude Code Claude Code commited on
Commit ·
6692e0f
1
Parent(s): a1b9d55
Fix UNHEALTHY status: Add file-based heartbeat as primary health check
Browse filesThe issue was that heartbeat validation relied solely on JSON file reads
which could be stale or fail to reflect true worker state.
Changes:
- /api/state now checks /app/logs/worker_heartbeat.txt directly (primary source)
- Added fallback to JSON heartbeat if file check fails
- Added detailed diagnostics with failure_reason logging
- Watchdog _is_process_alive now uses file heartbeat as primary check
- Added comprehensive logging for health check failures
This fixes the issue where brain_pid exists but heartbeat_fresh is False
by using the worker-written file as the source of truth for activity.
Co-Authored-By: Claude Code <noreply@anthropic.com>
app.py
CHANGED
|
@@ -185,11 +185,38 @@ class ProcessWatchdog:
|
|
| 185 |
self._log("info", "Monitor thread stopped")
|
| 186 |
|
| 187 |
def _is_process_alive(self) -> bool:
|
| 188 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
if self.process is None or self.pid is None:
|
| 190 |
return False
|
| 191 |
|
| 192 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
try:
|
| 194 |
poll_result = self.process.poll()
|
| 195 |
if poll_result is not None:
|
|
@@ -200,7 +227,7 @@ class ProcessWatchdog:
|
|
| 200 |
self._log("warning", "Process poll failed", error=str(e))
|
| 201 |
return False
|
| 202 |
|
| 203 |
-
#
|
| 204 |
try:
|
| 205 |
if not psutil.pid_exists(self.pid):
|
| 206 |
self._log("warning", "Ghost PID detected - process not found in system", pid=self.pid)
|
|
@@ -208,7 +235,7 @@ class ProcessWatchdog:
|
|
| 208 |
except Exception as e:
|
| 209 |
self._log("warning", "PID check failed", error=str(e))
|
| 210 |
|
| 211 |
-
#
|
| 212 |
try:
|
| 213 |
proc = psutil.Process(self.pid)
|
| 214 |
if "python" not in proc.name().lower():
|
|
@@ -220,8 +247,11 @@ class ProcessWatchdog:
|
|
| 220 |
except Exception as e:
|
| 221 |
self._log("warning", "Process name check failed", error=str(e))
|
| 222 |
|
| 223 |
-
# All checks passed
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
def _update_status_file(self):
|
| 227 |
"""Update cain_status.json with current process info."""
|
|
@@ -483,19 +513,56 @@ async def api_state(request: Request):
|
|
| 483 |
|
| 484 |
watchdog_status = _watchdog.get_status() if _watchdog else {"is_alive": False, "pid": None}
|
| 485 |
|
| 486 |
-
#
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
| 488 |
heartbeat_fresh = False
|
| 489 |
-
|
|
|
|
| 490 |
try:
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
if
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
|
| 500 |
# Determine actual state based on watchdog health and heartbeat freshness
|
| 501 |
worker_state = status_data.get("_worker_state", "")
|
|
@@ -510,15 +577,30 @@ async def api_state(request: Request):
|
|
| 510 |
else:
|
| 511 |
reported_state = current_state
|
| 512 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
return {
|
| 514 |
"state": reported_state,
|
| 515 |
-
"detail":
|
| 516 |
"updated_at": datetime.utcnow().isoformat() + "+00:00",
|
| 517 |
"stage": status_data.get("stage", "RUNNING"),
|
| 518 |
"health": "HEALTHY" if watchdog_status["is_alive"] and heartbeat_fresh else "UNHEALTHY",
|
| 519 |
"is_alive": watchdog_status["is_alive"],
|
| 520 |
"brain_pid": watchdog_status.get("pid"),
|
| 521 |
-
"heartbeat_fresh": heartbeat_fresh
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
}
|
| 523 |
|
| 524 |
|
|
|
|
| 185 |
self._log("info", "Monitor thread stopped")
|
| 186 |
|
| 187 |
def _is_process_alive(self) -> bool:
|
| 188 |
+
"""
|
| 189 |
+
Check if the managed process is alive and actively running.
|
| 190 |
+
|
| 191 |
+
Uses multiple checks in order:
|
| 192 |
+
1. File-based heartbeat (primary truth source - written by worker)
|
| 193 |
+
2. Subprocess polling (detects crashes)
|
| 194 |
+
3. PID existence verification (detects ghost PIDs)
|
| 195 |
+
4. Process name verification (ensures it's our python process)
|
| 196 |
+
"""
|
| 197 |
if self.process is None or self.pid is None:
|
| 198 |
return False
|
| 199 |
|
| 200 |
+
# PRIMARY CHECK: File-based heartbeat (source of truth for worker activity)
|
| 201 |
+
heartbeat_path = Path("/app/logs/worker_heartbeat.txt")
|
| 202 |
+
if heartbeat_path.exists():
|
| 203 |
+
try:
|
| 204 |
+
with open(heartbeat_path, "r") as f:
|
| 205 |
+
heartbeat_content = f.read().strip()
|
| 206 |
+
if heartbeat_content:
|
| 207 |
+
heartbeat_time = datetime.fromisoformat(heartbeat_content.replace("+00:00", "").replace("Z", ""))
|
| 208 |
+
# Make timezone-naive for comparison
|
| 209 |
+
if heartbeat_time.tzinfo is not None:
|
| 210 |
+
heartbeat_time = heartbeat_time.replace(tzinfo=None)
|
| 211 |
+
heartbeat_age = (datetime.utcnow() - heartbeat_time).total_seconds()
|
| 212 |
+
# Heartbeat is fresh if updated within 15 seconds
|
| 213 |
+
if heartbeat_age < 15:
|
| 214 |
+
# Fresh heartbeat means worker is actively running
|
| 215 |
+
return True
|
| 216 |
+
except Exception as e:
|
| 217 |
+
self._log("warning", "Heartbeat file check failed", error=str(e))
|
| 218 |
+
|
| 219 |
+
# SECONDARY CHECK: Try polling the subprocess
|
| 220 |
try:
|
| 221 |
poll_result = self.process.poll()
|
| 222 |
if poll_result is not None:
|
|
|
|
| 227 |
self._log("warning", "Process poll failed", error=str(e))
|
| 228 |
return False
|
| 229 |
|
| 230 |
+
# TERTIARY CHECK: Verify PID exists in system (detect ghost PIDs)
|
| 231 |
try:
|
| 232 |
if not psutil.pid_exists(self.pid):
|
| 233 |
self._log("warning", "Ghost PID detected - process not found in system", pid=self.pid)
|
|
|
|
| 235 |
except Exception as e:
|
| 236 |
self._log("warning", "PID check failed", error=str(e))
|
| 237 |
|
| 238 |
+
# QUATERNARY CHECK: Verify process name matches python (double-verify)
|
| 239 |
try:
|
| 240 |
proc = psutil.Process(self.pid)
|
| 241 |
if "python" not in proc.name().lower():
|
|
|
|
| 247 |
except Exception as e:
|
| 248 |
self._log("warning", "Process name check failed", error=str(e))
|
| 249 |
|
| 250 |
+
# All checks passed (but heartbeat was stale)
|
| 251 |
+
# Log this case - process exists but heartbeat is stale
|
| 252 |
+
self._log("info", "Process exists but heartbeat stale", pid=self.pid,
|
| 253 |
+
note="Heartbeat file exists but is >15s old")
|
| 254 |
+
return False
|
| 255 |
|
| 256 |
def _update_status_file(self):
|
| 257 |
"""Update cain_status.json with current process info."""
|
|
|
|
| 513 |
|
| 514 |
watchdog_status = _watchdog.get_status() if _watchdog else {"is_alive": False, "pid": None}
|
| 515 |
|
| 516 |
+
# PRIMARY HEARTBEAT CHECK: Read file-based heartbeat (written directly by worker process)
|
| 517 |
+
# This is the source of truth for detecting a live worker process
|
| 518 |
+
heartbeat_path = Path("/app/logs/worker_heartbeat.txt")
|
| 519 |
+
file_heartbeat_time = None
|
| 520 |
+
file_heartbeat_age = None
|
| 521 |
heartbeat_fresh = False
|
| 522 |
+
|
| 523 |
+
if heartbeat_path.exists():
|
| 524 |
try:
|
| 525 |
+
with open(heartbeat_path, "r") as f:
|
| 526 |
+
content = f.read().strip()
|
| 527 |
+
if content:
|
| 528 |
+
# Parse ISO timestamp
|
| 529 |
+
file_heartbeat_time = datetime.fromisoformat(content.replace("+00:00", "").replace("Z", "+00:00"))
|
| 530 |
+
if file_heartbeat_time.tzinfo is not None:
|
| 531 |
+
file_heartbeat_time = file_heartbeat_time.replace(tzinfo=None)
|
| 532 |
+
file_heartbeat_age = (datetime.utcnow() - file_heartbeat_time).total_seconds()
|
| 533 |
+
# Heartbeat is fresh if updated within 15 seconds
|
| 534 |
+
heartbeat_fresh = file_heartbeat_age < 15
|
| 535 |
+
except Exception as e:
|
| 536 |
+
logger.warning(f"[STATE] Failed to read heartbeat file: {e}")
|
| 537 |
+
|
| 538 |
+
# FALLBACK: Check heartbeat in JSON file (secondary source)
|
| 539 |
+
json_heartbeat_str = status_data.get("worker_heartbeat")
|
| 540 |
+
json_heartbeat_age = None
|
| 541 |
+
if json_heartbeat_str and not heartbeat_fresh:
|
| 542 |
+
try:
|
| 543 |
+
json_heartbeat_time = datetime.fromisoformat(json_heartbeat_str.replace("+00:00", "").replace("Z", "+00:00"))
|
| 544 |
+
if json_heartbeat_time.endswith("+00:00"):
|
| 545 |
+
json_heartbeat_time = json_heartbeat_time.replace(tzinfo=None)
|
| 546 |
+
json_heartbeat_age = (datetime.utcnow() - json_heartbeat_time.replace(tzinfo=None)).total_seconds()
|
| 547 |
+
# Use JSON heartbeat if it's fresh (even if file check failed)
|
| 548 |
+
if json_heartbeat_age < 15:
|
| 549 |
+
heartbeat_fresh = True
|
| 550 |
+
file_heartbeat_age = json_heartbeat_age
|
| 551 |
+
except Exception as e:
|
| 552 |
+
logger.warning(f"[STATE] Failed to parse JSON heartbeat: {e}")
|
| 553 |
+
|
| 554 |
+
# DETAILED DIAGNOSTICS: Log why state is determined as unhealthy
|
| 555 |
+
failure_reason = None
|
| 556 |
+
if not heartbeat_fresh:
|
| 557 |
+
if file_heartbeat_age is not None:
|
| 558 |
+
failure_reason = f"File heartbeat stale: {file_heartbeat_age:.1f}s old (threshold: 15s)"
|
| 559 |
+
elif json_heartbeat_age is not None:
|
| 560 |
+
failure_reason = f"JSON heartbeat stale: {json_heartbeat_age:.1f}s old"
|
| 561 |
+
else:
|
| 562 |
+
failure_reason = "No heartbeat found (file or JSON)"
|
| 563 |
+
|
| 564 |
+
# Log with full diagnostic info
|
| 565 |
+
logger.warning(f"[STATE] Heartbeat check failed: {failure_reason} | PID={watchdog_status.get('pid')} | File exists={heartbeat_path.exists()}")
|
| 566 |
|
| 567 |
# Determine actual state based on watchdog health and heartbeat freshness
|
| 568 |
worker_state = status_data.get("_worker_state", "")
|
|
|
|
| 577 |
else:
|
| 578 |
reported_state = current_state
|
| 579 |
|
| 580 |
+
# Build detail message with diagnostic info
|
| 581 |
+
if watchdog_status["is_alive"] and heartbeat_fresh:
|
| 582 |
+
detail = "Cain is operational"
|
| 583 |
+
elif failure_reason:
|
| 584 |
+
detail = f"Brain process issue: {failure_reason}"
|
| 585 |
+
else:
|
| 586 |
+
detail = "Brain process not running or stale"
|
| 587 |
+
|
| 588 |
return {
|
| 589 |
"state": reported_state,
|
| 590 |
+
"detail": detail,
|
| 591 |
"updated_at": datetime.utcnow().isoformat() + "+00:00",
|
| 592 |
"stage": status_data.get("stage", "RUNNING"),
|
| 593 |
"health": "HEALTHY" if watchdog_status["is_alive"] and heartbeat_fresh else "UNHEALTHY",
|
| 594 |
"is_alive": watchdog_status["is_alive"],
|
| 595 |
"brain_pid": watchdog_status.get("pid"),
|
| 596 |
+
"heartbeat_fresh": heartbeat_fresh,
|
| 597 |
+
"heartbeat_age_seconds": round(file_heartbeat_age, 2) if file_heartbeat_age is not None else None,
|
| 598 |
+
"_diagnostics": {
|
| 599 |
+
"heartbeat_file_exists": heartbeat_path.exists(),
|
| 600 |
+
"failure_reason": failure_reason,
|
| 601 |
+
"file_heartbeat_age": round(file_heartbeat_age, 2) if file_heartbeat_age is not None else None,
|
| 602 |
+
"json_heartbeat_age": round(json_heartbeat_age, 2) if json_heartbeat_age is not None else None
|
| 603 |
+
}
|
| 604 |
}
|
| 605 |
|
| 606 |
|