Spaces:
Sleeping
Sleeping
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 files1. 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>
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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
self.process = subprocess.Popen(
|
| 120 |
[sys.executable, self.script_path],
|
| 121 |
-
stdout=
|
| 122 |
-
stderr=
|
| 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 |
-
#
|
| 189 |
try:
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 455 |
-
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|