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>

Files changed (3) hide show
  1. app.py +69 -87
  2. brain_minimal.py +66 -33
  3. 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. 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)
@@ -197,24 +200,15 @@ class ProcessWatchdog:
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:
@@ -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 file exists but is >15s old")
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
- """Return Cain's current state for Office/A2A polling."""
 
 
 
 
 
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 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", "")
569
- current_state = status_data.get("current_state", "idle")
570
 
571
- # If watchdog says process is dead OR heartbeat is stale, force state to idle
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 = 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
 
@@ -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": 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
 
 
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
- Write heartbeat to file for health monitoring.
216
- This is the main heartbeat that WorkerManager checks.
217
  """
218
- logger.info("[WORKER] Heartbeat loop started")
219
 
220
- data_dir = Path(os.environ.get("OPENCLAW_DATA_DIR", "/data"))
221
- status_file = data_dir / "cain_status.json"
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
- # Update worker heartbeat in status file
228
- if status_file.exists():
229
- try:
230
- with open(status_file, "r") as f:
231
- data = json.load(f)
232
- data["worker_heartbeat"] = datetime.utcnow().isoformat() + "+00:00"
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 as e:
253
- logger.warning(f"[WORKER] Failed to write heartbeat: {e}")
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
- # Write initial heartbeat
279
- heartbeat_path = Path("/app/logs/worker_heartbeat.txt")
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