Claude Code Claude Opus 4.6 commited on
Commit
a1b9d55
·
1 Parent(s): 89207d6

Claude Code: Fix 'active' state drift with ghost PID detection and subprocess logging

Browse files

1. Added stdout/stderr logging to subprocess.Popen - writes to /app/logs/brain_*.log
This prevents silent crashes of brain_minimal.py

2. Enhanced _is_process_alive() with 3-tier validation:
- subprocess.poll() to detect exited processes
- psutil.pid_exists() to detect ghost PIDs
- Process name check to verify it's a python process

3. Added heartbeat freshness check to /api/state endpoint:
- If heartbeat > 15s old, forces state to 'idle'
- Prevents stale 'active' states from dead processes
- Returns heartbeat_fresh in response for debugging

Port binding: App already correctly bound to 0.0.0.0:7860 (line 696-697)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +71 -15
app.py CHANGED
@@ -115,11 +115,15 @@ class ProcessWatchdog:
115
  # Ensure logs directory exists
116
  Path("/app/logs").mkdir(parents=True, exist_ok=True)
117
 
118
- # Spawn subprocess
 
 
 
 
119
  self.process = subprocess.Popen(
120
  [sys.executable, self.script_path],
121
- stdout=subprocess.PIPE,
122
- stderr=subprocess.PIPE,
123
  env=env,
124
  cwd="/app",
125
  start_new_session=True # Create process group for clean termination
@@ -181,16 +185,44 @@ class ProcessWatchdog:
181
  self._log("info", "Monitor thread stopped")
182
 
183
  def _is_process_alive(self) -> bool:
184
- """Check if the managed process is alive."""
185
- if self.process is None:
186
  return False
187
 
188
- # Try polling the process
189
  try:
190
- return self.process.poll() is None
191
- except Exception:
 
 
 
 
 
192
  return False
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def _update_status_file(self):
195
  """Update cain_status.json with current process info."""
196
  try:
@@ -449,20 +481,44 @@ async def api_state(request: Request):
449
  except ImportError:
450
  status_data = {"current_state": "idle", "stage": "RUNNING", "health": "HEALTHY"}
451
 
452
- watchdog_status = _watchdog.get_status() if _watchdog else {"is_alive": False}
453
 
454
- # Report state: _worker_state (heartbeat) takes precedence over current_state (polling)
455
- # This allows heartbeat to show "active" while polling loop uses "idle" for trigger detection
456
- reported_state = status_data.get("_worker_state") or status_data.get("current_state", "idle")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
 
458
  return {
459
  "state": reported_state,
460
- "detail": "Cain is operational" if watchdog_status["is_alive"] else "Brain process not running",
461
  "updated_at": datetime.utcnow().isoformat() + "+00:00",
462
  "stage": status_data.get("stage", "RUNNING"),
463
- "health": "HEALTHY" if watchdog_status["is_alive"] else "UNHEALTHY",
464
  "is_alive": watchdog_status["is_alive"],
465
- "brain_pid": watchdog_status.get("pid")
 
466
  }
467
 
468
 
 
115
  # Ensure logs directory exists
116
  Path("/app/logs").mkdir(parents=True, exist_ok=True)
117
 
118
+ # Open log files for subprocess output (for debugging crashes)
119
+ stdout_log = open("/app/logs/brain_stdout.log", "a")
120
+ stderr_log = open("/app/logs/brain_stderr.log", "a")
121
+
122
+ # Spawn subprocess with stdout/stderr piped to log files
123
  self.process = subprocess.Popen(
124
  [sys.executable, self.script_path],
125
+ stdout=stdout_log,
126
+ stderr=stderr_log,
127
  env=env,
128
  cwd="/app",
129
  start_new_session=True # Create process group for clean termination
 
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:
196
+ # Process has exited
197
+ self._log("warning", "Process poll returned exit code", exit_code=poll_result, pid=self.pid)
198
+ return False
199
+ except Exception as e:
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)
207
+ return False
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():
215
+ self._log("warning", "PID exists but is not a python process", pid=self.pid, name=proc.name())
216
+ return False
217
+ except psutil.NoSuchProcess:
218
+ self._log("warning", "Process vanished during verification", pid=self.pid)
219
+ return False
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."""
228
  try:
 
481
  except ImportError:
482
  status_data = {"current_state": "idle", "stage": "RUNNING", "health": "HEALTHY"}
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", "")
502
+ current_state = status_data.get("current_state", "idle")
503
+
504
+ # If watchdog says process is dead OR heartbeat is stale, force state to idle
505
+ if not watchdog_status["is_alive"] or not heartbeat_fresh:
506
+ reported_state = "idle"
507
+ elif worker_state:
508
+ # Only trust _worker_state if heartbeat is fresh and process is alive
509
+ reported_state = worker_state
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