Spaces:
Sleeping
Sleeping
Claude Code Claude Code commited on
Commit ·
41564f9
1
Parent(s): 6692e0f
Refactor heartbeat from File I/O to In-Memory IPC
Browse files- Add shared_state.py: Thread-safe in-memory worker state cache
- brain_minimal.py: Replace file writes with HTTP POST to /internal/heartbeat
- app.py: Add /internal/heartbeat endpoint, read from shared state cache
- Decouple worker_status from PID check - now reflects activity level
- Eliminate Read-After-Write race conditions
This moves from File I/O to In-Memory/IPC for heartbeat monitoring,
removing the file-based race conditions diagnosed earlier.
Co-Authored-By: Claude Code <noreply@anthropic.com>
- app.py +69 -87
- brain_minimal.py +66 -33
- shared_state.py +120 -0
app.py
CHANGED
|
@@ -14,6 +14,9 @@ from fastapi.staticfiles import StaticFiles
|
|
| 14 |
import uvicorn
|
| 15 |
import asyncio
|
| 16 |
|
|
|
|
|
|
|
|
|
|
| 17 |
# CRITICAL: Initialize .env before any imports that might load environment variables
|
| 18 |
# This ensures .env exists even if app.py is run directly (without entrypoint.sh)
|
| 19 |
try:
|
|
@@ -189,7 +192,7 @@ class ProcessWatchdog:
|
|
| 189 |
Check if the managed process is alive and actively running.
|
| 190 |
|
| 191 |
Uses multiple checks in order:
|
| 192 |
-
1.
|
| 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)
|
|
@@ -197,24 +200,15 @@ class ProcessWatchdog:
|
|
| 197 |
if self.process is None or self.pid is None:
|
| 198 |
return False
|
| 199 |
|
| 200 |
-
# PRIMARY CHECK:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 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:
|
|
@@ -249,8 +243,8 @@ class ProcessWatchdog:
|
|
| 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
|
| 254 |
return False
|
| 255 |
|
| 256 |
def _update_status_file(self):
|
|
@@ -467,6 +461,31 @@ async def health(request: Request):
|
|
| 467 |
}
|
| 468 |
|
| 469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
@app.get("/api/status")
|
| 471 |
async def status():
|
| 472 |
"""
|
|
@@ -500,7 +519,12 @@ async def status():
|
|
| 500 |
|
| 501 |
@app.get("/api/state")
|
| 502 |
async def api_state(request: Request):
|
| 503 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
user_agent = request.headers.get("User-Agent")
|
| 505 |
if not user_agent:
|
| 506 |
return JSONResponse(status_code=401, content={})
|
|
@@ -513,75 +537,32 @@ async def api_state(request: Request):
|
|
| 513 |
|
| 514 |
watchdog_status = _watchdog.get_status() if _watchdog else {"is_alive": False, "pid": None}
|
| 515 |
|
| 516 |
-
# PRIMARY
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
file_heartbeat_time = None
|
| 520 |
-
file_heartbeat_age = None
|
| 521 |
-
heartbeat_fresh = False
|
| 522 |
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 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 |
-
|
| 565 |
-
|
|
|
|
| 566 |
|
| 567 |
-
#
|
| 568 |
-
|
| 569 |
-
current_state = status_data.get("current_state", "idle")
|
| 570 |
|
| 571 |
-
#
|
| 572 |
if not watchdog_status["is_alive"] or not heartbeat_fresh:
|
| 573 |
reported_state = "idle"
|
| 574 |
-
elif worker_state:
|
| 575 |
-
# Only trust _worker_state if heartbeat is fresh and process is alive
|
| 576 |
-
reported_state = worker_state
|
| 577 |
else:
|
| 578 |
-
reported_state =
|
| 579 |
|
| 580 |
-
# Build detail message
|
| 581 |
if watchdog_status["is_alive"] and heartbeat_fresh:
|
| 582 |
detail = "Cain is operational"
|
| 583 |
-
elif
|
| 584 |
-
detail = f"
|
| 585 |
else:
|
| 586 |
detail = "Brain process not running or stale"
|
| 587 |
|
|
@@ -589,17 +570,18 @@ async def api_state(request: Request):
|
|
| 589 |
"state": reported_state,
|
| 590 |
"detail": detail,
|
| 591 |
"updated_at": datetime.utcnow().isoformat() + "+00:00",
|
| 592 |
-
"stage":
|
| 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(
|
|
|
|
| 598 |
"_diagnostics": {
|
| 599 |
-
"
|
| 600 |
-
"
|
| 601 |
-
"
|
| 602 |
-
"
|
| 603 |
}
|
| 604 |
}
|
| 605 |
|
|
|
|
| 14 |
import uvicorn
|
| 15 |
import asyncio
|
| 16 |
|
| 17 |
+
# Import shared state module for in-memory IPC with worker
|
| 18 |
+
from shared_state import get_worker_state
|
| 19 |
+
|
| 20 |
# CRITICAL: Initialize .env before any imports that might load environment variables
|
| 21 |
# This ensures .env exists even if app.py is run directly (without entrypoint.sh)
|
| 22 |
try:
|
|
|
|
| 192 |
Check if the managed process is alive and actively running.
|
| 193 |
|
| 194 |
Uses multiple checks in order:
|
| 195 |
+
1. In-memory state cache via shared_state (primary - updated by worker HTTP POST)
|
| 196 |
2. Subprocess polling (detects crashes)
|
| 197 |
3. PID existence verification (detects ghost PIDs)
|
| 198 |
4. Process name verification (ensures it's our python process)
|
|
|
|
| 200 |
if self.process is None or self.pid is None:
|
| 201 |
return False
|
| 202 |
|
| 203 |
+
# PRIMARY CHECK: In-memory state cache (IPC from worker via HTTP POST)
|
| 204 |
+
# This is the source of truth for worker activity
|
| 205 |
+
try:
|
| 206 |
+
worker_mem_state = get_worker_state()
|
| 207 |
+
if worker_mem_state.is_healthy(max_age_seconds=15):
|
| 208 |
+
# Fresh in-memory heartbeat means worker is actively running
|
| 209 |
+
return True
|
| 210 |
+
except Exception as e:
|
| 211 |
+
self._log("warning", "In-memory state check failed", error=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
# SECONDARY CHECK: Try polling the subprocess
|
| 214 |
try:
|
|
|
|
| 243 |
|
| 244 |
# All checks passed (but heartbeat was stale)
|
| 245 |
# Log this case - process exists but heartbeat is stale
|
| 246 |
+
self._log("info", "Process exists but in-memory heartbeat stale", pid=self.pid,
|
| 247 |
+
note="Heartbeat in cache is >15s old")
|
| 248 |
return False
|
| 249 |
|
| 250 |
def _update_status_file(self):
|
|
|
|
| 461 |
}
|
| 462 |
|
| 463 |
|
| 464 |
+
@app.post("/internal/heartbeat")
|
| 465 |
+
async def internal_heartbeat(request_data: dict):
|
| 466 |
+
"""
|
| 467 |
+
Internal endpoint for worker process (brain_minimal.py) to report heartbeat.
|
| 468 |
+
Updates in-memory state cache - NO FILE WRITES.
|
| 469 |
+
"""
|
| 470 |
+
try:
|
| 471 |
+
worker_state = get_worker_state()
|
| 472 |
+
|
| 473 |
+
# Update in-memory state from worker heartbeat
|
| 474 |
+
worker_state.update(
|
| 475 |
+
worker_pid=request_data.get("worker_pid"),
|
| 476 |
+
worker_mode=request_data.get("worker_mode"),
|
| 477 |
+
worker_state=request_data.get("worker_state", "active"), # activity level
|
| 478 |
+
current_state=request_data.get("current_state", "idle"),
|
| 479 |
+
worker_active=request_data.get("worker_active", True),
|
| 480 |
+
stage=request_data.get("stage", "RUNNING_A2A_READY"),
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
return {"status": "ok", "received": True}
|
| 484 |
+
except Exception as e:
|
| 485 |
+
logger.warning(f"[HEARTBEAT] Failed to update worker state: {e}")
|
| 486 |
+
return {"status": "error", "error": str(e)}
|
| 487 |
+
|
| 488 |
+
|
| 489 |
@app.get("/api/status")
|
| 490 |
async def status():
|
| 491 |
"""
|
|
|
|
| 519 |
|
| 520 |
@app.get("/api/state")
|
| 521 |
async def api_state(request: Request):
|
| 522 |
+
"""
|
| 523 |
+
Return Cain's current state for Office/A2A polling.
|
| 524 |
+
|
| 525 |
+
NOW USES IN-MEMORY STATE CACHE (no file I/O for heartbeat).
|
| 526 |
+
Worker state reflects activity level, not just PID existence.
|
| 527 |
+
"""
|
| 528 |
user_agent = request.headers.get("User-Agent")
|
| 529 |
if not user_agent:
|
| 530 |
return JSONResponse(status_code=401, content={})
|
|
|
|
| 537 |
|
| 538 |
watchdog_status = _watchdog.get_status() if _watchdog else {"is_alive": False, "pid": None}
|
| 539 |
|
| 540 |
+
# PRIMARY: Use in-memory state cache (IPC from worker via HTTP POST)
|
| 541 |
+
worker_mem_state = get_worker_state()
|
| 542 |
+
mem_state_data = worker_mem_state.get()
|
|
|
|
|
|
|
|
|
|
| 543 |
|
| 544 |
+
# Heartbeat freshness from in-memory state
|
| 545 |
+
heartbeat_fresh = worker_mem_state.is_healthy(max_age_seconds=15)
|
| 546 |
+
heartbeat_age = mem_state_data.get("heartbeat_age_seconds", 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
|
| 548 |
+
# Worker activity state (decoupled from PID check)
|
| 549 |
+
# This reflects actual worker activity, not just process existence
|
| 550 |
+
worker_activity_state = worker_mem_state.get_worker_state()
|
| 551 |
|
| 552 |
+
# Current processing state (idle/processing)
|
| 553 |
+
current_state = mem_state_data.get("current_state", "idle")
|
|
|
|
| 554 |
|
| 555 |
+
# Determine reported state
|
| 556 |
if not watchdog_status["is_alive"] or not heartbeat_fresh:
|
| 557 |
reported_state = "idle"
|
|
|
|
|
|
|
|
|
|
| 558 |
else:
|
| 559 |
+
reported_state = worker_activity_state
|
| 560 |
|
| 561 |
+
# Build detail message
|
| 562 |
if watchdog_status["is_alive"] and heartbeat_fresh:
|
| 563 |
detail = "Cain is operational"
|
| 564 |
+
elif not heartbeat_fresh:
|
| 565 |
+
detail = f"Worker heartbeat stale: {heartbeat_age:.1f}s old (threshold: 15s)"
|
| 566 |
else:
|
| 567 |
detail = "Brain process not running or stale"
|
| 568 |
|
|
|
|
| 570 |
"state": reported_state,
|
| 571 |
"detail": detail,
|
| 572 |
"updated_at": datetime.utcnow().isoformat() + "+00:00",
|
| 573 |
+
"stage": mem_state_data.get("stage", "RUNNING_A2A_READY"),
|
| 574 |
"health": "HEALTHY" if watchdog_status["is_alive"] and heartbeat_fresh else "UNHEALTHY",
|
| 575 |
"is_alive": watchdog_status["is_alive"],
|
| 576 |
"brain_pid": watchdog_status.get("pid"),
|
| 577 |
"heartbeat_fresh": heartbeat_fresh,
|
| 578 |
+
"heartbeat_age_seconds": round(heartbeat_age, 2),
|
| 579 |
+
"worker_activity_state": worker_activity_state, # NEW: activity level
|
| 580 |
"_diagnostics": {
|
| 581 |
+
"heartbeat_source": "in_memory_ipc",
|
| 582 |
+
"worker_pid": mem_state_data.get("worker_pid"),
|
| 583 |
+
"worker_mode": mem_state_data.get("worker_mode"),
|
| 584 |
+
"last_heartbeat": mem_state_data.get("last_heartbeat")
|
| 585 |
}
|
| 586 |
}
|
| 587 |
|
brain_minimal.py
CHANGED
|
@@ -18,6 +18,8 @@ import signal
|
|
| 18 |
import time
|
| 19 |
import logging
|
| 20 |
import asyncio
|
|
|
|
|
|
|
| 21 |
from pathlib import Path
|
| 22 |
from datetime import datetime
|
| 23 |
from threading import Thread, Event
|
|
@@ -210,47 +212,80 @@ async def _poll_a2a_ready_state():
|
|
| 210 |
logger.info("[WORKER] A2A polling loop stopped")
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
def worker_heartbeat_loop():
|
| 214 |
"""
|
| 215 |
-
|
| 216 |
-
|
| 217 |
"""
|
| 218 |
-
logger.info("[WORKER] Heartbeat loop started")
|
| 219 |
|
| 220 |
-
|
| 221 |
-
|
| 222 |
heartbeat_path = Path("/app/logs/worker_heartbeat.txt")
|
| 223 |
heartbeat_path.parent.mkdir(parents=True, exist_ok=True)
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
while not _shutdown_event.is_set():
|
| 226 |
try:
|
| 227 |
-
#
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
data["_worker_pid"] = os.getpid()
|
| 234 |
-
data["_worker_mode"] = "standalone_process"
|
| 235 |
-
data["worker_active"] = True
|
| 236 |
-
data["stage"] = "RUNNING_A2A_READY"
|
| 237 |
-
# CRITICAL: Use separate key for heartbeat activity to avoid race condition
|
| 238 |
-
# The polling loop needs current_state to stay "idle" for trigger detection
|
| 239 |
-
data["_worker_state"] = "active" # Worker is actively running and polling
|
| 240 |
-
# Only update current_state if it doesn't exist (init) or is processing-complete
|
| 241 |
-
if data.get("current_state") not in ("idle", "processing"):
|
| 242 |
-
data["current_state"] = "idle"
|
| 243 |
-
with open(status_file, "w") as f:
|
| 244 |
-
json.dump(data, f, indent=2)
|
| 245 |
-
except (json.JSONDecodeError, IOError):
|
| 246 |
-
pass
|
| 247 |
-
|
| 248 |
-
# Write heartbeat timestamp to file (file-based truth check)
|
| 249 |
try:
|
| 250 |
with open(heartbeat_path, "w") as f:
|
| 251 |
f.write(datetime.utcnow().isoformat() + "+00:00\n")
|
| 252 |
-
except Exception
|
| 253 |
-
|
| 254 |
|
| 255 |
# Sleep until next heartbeat (5 seconds)
|
| 256 |
if _shutdown_event.wait(5):
|
|
@@ -275,10 +310,8 @@ def main():
|
|
| 275 |
# Ensure logs directory exists
|
| 276 |
Path("/app/logs").mkdir(parents=True, exist_ok=True)
|
| 277 |
|
| 278 |
-
#
|
| 279 |
-
|
| 280 |
-
with open(heartbeat_path, "w") as f:
|
| 281 |
-
f.write(datetime.utcnow().isoformat() + "+00:00\n")
|
| 282 |
|
| 283 |
# Start heartbeat loop in thread
|
| 284 |
heartbeat_thread = Thread(target=worker_heartbeat_loop, daemon=True)
|
|
|
|
| 18 |
import time
|
| 19 |
import logging
|
| 20 |
import asyncio
|
| 21 |
+
import urllib.request
|
| 22 |
+
import urllib.error
|
| 23 |
from pathlib import Path
|
| 24 |
from datetime import datetime
|
| 25 |
from threading import Thread, Event
|
|
|
|
| 212 |
logger.info("[WORKER] A2A polling loop stopped")
|
| 213 |
|
| 214 |
|
| 215 |
+
def _send_heartbeat_to_app(worker_state: str = "active", current_state: str = "idle"):
|
| 216 |
+
"""
|
| 217 |
+
Send heartbeat to app.py via HTTP POST (in-memory IPC).
|
| 218 |
+
This replaces file-based heartbeat writes entirely.
|
| 219 |
+
"""
|
| 220 |
+
try:
|
| 221 |
+
port = os.environ.get("PORT", "7860")
|
| 222 |
+
url = f"http://127.0.0.1:{port}/internal/heartbeat"
|
| 223 |
+
|
| 224 |
+
payload = {
|
| 225 |
+
"worker_pid": os.getpid(),
|
| 226 |
+
"worker_mode": "standalone_process",
|
| 227 |
+
"worker_state": worker_state, # activity state, not PID-based
|
| 228 |
+
"current_state": current_state,
|
| 229 |
+
"worker_active": True,
|
| 230 |
+
"stage": "RUNNING_A2A_READY",
|
| 231 |
+
"timestamp": datetime.utcnow().isoformat() + "+00:00"
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
data = json.dumps(payload).encode("utf-8")
|
| 235 |
+
req = urllib.request.Request(
|
| 236 |
+
url,
|
| 237 |
+
data=data,
|
| 238 |
+
headers={"Content-Type": "application/json"},
|
| 239 |
+
method="POST"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Set short timeout to avoid blocking worker loop
|
| 243 |
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
| 244 |
+
if resp.status != 200:
|
| 245 |
+
logger.warning(f"[WORKER] Heartbeat POST returned {resp.status}")
|
| 246 |
+
|
| 247 |
+
except urllib.error.HTTPError as e:
|
| 248 |
+
if e.code != 404:
|
| 249 |
+
logger.debug(f"[WORKER] Heartbeat POST HTTP error: {e.code}")
|
| 250 |
+
except urllib.error.URLError:
|
| 251 |
+
# App may not be ready yet - silent retry
|
| 252 |
+
pass
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.debug(f"[WORKER] Heartbeat POST failed: {e}")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
def worker_heartbeat_loop():
|
| 258 |
"""
|
| 259 |
+
Send heartbeat to app.py via HTTP POST (in-memory IPC).
|
| 260 |
+
NO FILE WRITES - eliminates race conditions.
|
| 261 |
"""
|
| 262 |
+
logger.info("[WORKER] Heartbeat loop started (in-memory IPC mode)")
|
| 263 |
|
| 264 |
+
# Optional: Keep minimal file writes for backward compatibility/debugging
|
| 265 |
+
# But PRIMARY heartbeat is now HTTP-based
|
| 266 |
heartbeat_path = Path("/app/logs/worker_heartbeat.txt")
|
| 267 |
heartbeat_path.parent.mkdir(parents=True, exist_ok=True)
|
| 268 |
|
| 269 |
+
# Write initial timestamp for debugging
|
| 270 |
+
try:
|
| 271 |
+
with open(heartbeat_path, "w") as f:
|
| 272 |
+
f.write(datetime.utcnow().isoformat() + "+00:00\n")
|
| 273 |
+
except Exception:
|
| 274 |
+
pass
|
| 275 |
+
|
| 276 |
while not _shutdown_event.is_set():
|
| 277 |
try:
|
| 278 |
+
# PRIMARY: Send heartbeat via HTTP to app.py (in-memory state)
|
| 279 |
+
# Worker state reflects activity level, not just PID existence
|
| 280 |
+
_send_heartbeat_to_app(worker_state="active", current_state="idle")
|
| 281 |
+
|
| 282 |
+
# SECONDARY: Optional fallback file write (for debugging only)
|
| 283 |
+
# Not used for health checks - just for manual inspection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
try:
|
| 285 |
with open(heartbeat_path, "w") as f:
|
| 286 |
f.write(datetime.utcnow().isoformat() + "+00:00\n")
|
| 287 |
+
except Exception:
|
| 288 |
+
pass
|
| 289 |
|
| 290 |
# Sleep until next heartbeat (5 seconds)
|
| 291 |
if _shutdown_event.wait(5):
|
|
|
|
| 310 |
# Ensure logs directory exists
|
| 311 |
Path("/app/logs").mkdir(parents=True, exist_ok=True)
|
| 312 |
|
| 313 |
+
# Initial heartbeat via HTTP (in-memory IPC)
|
| 314 |
+
_send_heartbeat_to_app(worker_state="active", current_state="idle")
|
|
|
|
|
|
|
| 315 |
|
| 316 |
# Start heartbeat loop in thread
|
| 317 |
heartbeat_thread = Thread(target=worker_heartbeat_loop, daemon=True)
|
shared_state.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Shared in-memory state for Cain worker process.
|
| 4 |
+
|
| 5 |
+
This module provides a thread-safe in-memory cache for worker state
|
| 6 |
+
that replaces file-based heartbeat writes. The worker process (brain_minimal.py)
|
| 7 |
+
updates this state via HTTP POST to app.py's internal endpoint.
|
| 8 |
+
|
| 9 |
+
Key design:
|
| 10 |
+
- No file I/O for heartbeat (eliminates race conditions)
|
| 11 |
+
- In-memory state cache in app.py
|
| 12 |
+
- Worker state reflects activity level, not just PID existence
|
| 13 |
+
"""
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from typing import Any, Optional
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class WorkerState:
|
| 21 |
+
"""
|
| 22 |
+
Thread-safe in-memory cache for worker state.
|
| 23 |
+
|
| 24 |
+
This is the source of truth for worker activity status.
|
| 25 |
+
Updated by brain_minimal.py via HTTP POST to /internal/heartbeat.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self._lock = threading.Lock()
|
| 30 |
+
self._state = {
|
| 31 |
+
# Worker activity state (decoupled from PID)
|
| 32 |
+
"worker_state": "idle", # idle, active, processing
|
| 33 |
+
"worker_active": False,
|
| 34 |
+
"current_state": "idle", # backward compatibility
|
| 35 |
+
|
| 36 |
+
# Process info (for logging/debugging)
|
| 37 |
+
"worker_pid": None,
|
| 38 |
+
"worker_mode": None,
|
| 39 |
+
|
| 40 |
+
# Heartbeat timestamps
|
| 41 |
+
"last_heartbeat": None,
|
| 42 |
+
"heartbeat_age_seconds": 0,
|
| 43 |
+
|
| 44 |
+
# Stage info
|
| 45 |
+
"stage": "RUNNING_A2A_READY",
|
| 46 |
+
|
| 47 |
+
# Health status
|
| 48 |
+
"health": "HEALTHY",
|
| 49 |
+
"error": None,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
def update(self, **kwargs):
|
| 53 |
+
"""Update worker state with new values (thread-safe)."""
|
| 54 |
+
with self._lock:
|
| 55 |
+
# Update heartbeat timestamp
|
| 56 |
+
self._state["last_heartbeat"] = datetime.utcnow().isoformat() + "+00:00"
|
| 57 |
+
self._state["heartbeat_age_seconds"] = 0
|
| 58 |
+
|
| 59 |
+
# Update provided fields
|
| 60 |
+
for key, value in kwargs.items():
|
| 61 |
+
if value is not None:
|
| 62 |
+
self._state[key] = value
|
| 63 |
+
|
| 64 |
+
def get(self) -> dict[str, Any]:
|
| 65 |
+
"""Get current state snapshot (thread-safe)."""
|
| 66 |
+
with self._lock:
|
| 67 |
+
# Calculate heartbeat age
|
| 68 |
+
if self._state["last_heartbeat"]:
|
| 69 |
+
try:
|
| 70 |
+
heartbeat_time = datetime.fromisoformat(
|
| 71 |
+
self._state["last_heartbeat"].replace("+00:00", "").replace("Z", "")
|
| 72 |
+
)
|
| 73 |
+
if heartbeat_time.tzinfo is not None:
|
| 74 |
+
heartbeat_time = heartbeat_time.replace(tzinfo=None)
|
| 75 |
+
age = (datetime.utcnow() - heartbeat_time).total_seconds()
|
| 76 |
+
self._state["heartbeat_age_seconds"] = age
|
| 77 |
+
except Exception:
|
| 78 |
+
self._state["heartbeat_age_seconds"] = 999
|
| 79 |
+
|
| 80 |
+
return self._state.copy()
|
| 81 |
+
|
| 82 |
+
def is_healthy(self, max_age_seconds: int = 15) -> bool:
|
| 83 |
+
"""
|
| 84 |
+
Check if worker is healthy based on heartbeat age.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
max_age_seconds: Maximum acceptable heartbeat age (default 15s)
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
True if heartbeat is fresh, False otherwise
|
| 91 |
+
"""
|
| 92 |
+
with self._lock:
|
| 93 |
+
return self._state["heartbeat_age_seconds"] < max_age_seconds
|
| 94 |
+
|
| 95 |
+
def get_worker_state(self) -> str:
|
| 96 |
+
"""
|
| 97 |
+
Get worker activity state (decoupled from PID).
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
"idle", "active", or "processing" based on actual activity
|
| 101 |
+
"""
|
| 102 |
+
with self._lock:
|
| 103 |
+
# If heartbeat is stale, worker is not active
|
| 104 |
+
if not self.is_healthy():
|
| 105 |
+
return "idle"
|
| 106 |
+
|
| 107 |
+
# Return the worker activity state (not PID-based)
|
| 108 |
+
return self._state.get("worker_state", "idle")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Global singleton
|
| 112 |
+
_worker_state: Optional[WorkerState] = None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def get_worker_state() -> WorkerState:
|
| 116 |
+
"""Get or create the global worker state singleton."""
|
| 117 |
+
global _worker_state
|
| 118 |
+
if _worker_state is None:
|
| 119 |
+
_worker_state = WorkerState()
|
| 120 |
+
return _worker_state
|