Claude Code Claude Code commited on
Commit
6692e0f
·
1 Parent(s): a1b9d55

Fix UNHEALTHY status: Add file-based heartbeat as primary health check

Browse files

The 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>

Files changed (1) hide show
  1. app.py +101 -19
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
- """Check if the managed process is alive and the PID is real."""
 
 
 
 
 
 
 
 
189
  if self.process is None or self.pid is None:
190
  return False
191
 
192
- # First check: try polling the subprocess
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Second check: verify PID exists in system (detect ghost PIDs)
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
- # Third check: verify process name matches python (double-verify)
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
- return True
 
 
 
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
- # Check heartbeat freshness - if heartbeat is older than 15 seconds, worker is likely dead
487
- heartbeat_str = status_data.get("worker_heartbeat")
 
 
 
488
  heartbeat_fresh = False
489
- if heartbeat_str:
 
490
  try:
491
- from datetime import timedelta
492
- heartbeat_time = datetime.fromisoformat(heartbeat_str.replace("+00:00", "").replace("Z", "+00:00"))
493
- if heartbeat_time.endswith("+00:00"):
494
- heartbeat_time = heartbeat_time.replace(tzinfo=None)
495
- heartbeat_age = (datetime.utcnow() - heartbeat_time.replace(tzinfo=None)).total_seconds()
496
- heartbeat_fresh = heartbeat_age < 15
497
- except Exception:
498
- heartbeat_fresh = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": "Cain is operational" if watchdog_status["is_alive"] and heartbeat_fresh else "Brain process not running or stale",
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