Claude Code Claude Opus 4.6 commited on
Commit
7aac46e
·
1 Parent(s): c67b939

Claude Code: Refactor worker to persistent FastAPI background thread

Browse files

- Move worker from separate process to FastAPI startup event thread
- Worker now runs as daemon thread within uvicorn process
- Removes unreliable --worker flag and entrypoint.sh worker startup
- Worker writes heartbeat every 5s via threading.Event
- More tightly integrated with app lifecycle

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

Files changed (2) hide show
  1. app.py +74 -75
  2. entrypoint.sh +2 -27
app.py CHANGED
@@ -3,7 +3,6 @@ import logging
3
  import json
4
  import sys
5
  import time
6
- import signal
7
  import threading
8
  import psutil
9
  from pathlib import Path
@@ -29,12 +28,60 @@ logger = logging.getLogger(__name__)
29
 
30
  app = FastAPI(title="HuggingClaw Cain Space")
31
 
32
- # ========== A2A Ready State Polling ==========
33
- # Global state for polling
 
 
34
  _polling_task = None
35
  _polling_lock = threading.Lock()
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  async def _poll_a2a_ready_state():
39
  """
40
  Background polling task to detect RUNNING_A2A_READY state and trigger agent execution.
@@ -77,16 +124,35 @@ async def _poll_a2a_ready_state():
77
 
78
  @app.on_event("startup")
79
  async def startup_event():
80
- """Start background polling task on app startup."""
81
- global _polling_task
 
 
 
 
 
 
 
 
 
 
 
 
82
  logger.info("[Cain] Starting A2A ready state polling task...")
83
  _polling_task = asyncio.create_task(_poll_a2a_ready_state())
84
 
85
 
86
  @app.on_event("shutdown")
87
  async def shutdown_event():
88
- """Stop background polling task on app shutdown."""
89
- global _polling_task
 
 
 
 
 
 
 
90
  if _polling_task:
91
  logger.info("[Cain] Stopping A2A ready state polling task...")
92
  _polling_task.cancel()
@@ -96,7 +162,7 @@ async def shutdown_event():
96
  pass
97
 
98
 
99
- # ========== End A2A Polling ==========
100
 
101
 
102
  # CRITICAL: Initialize clean status file on startup to prevent stale "unknown" errors
@@ -755,73 +821,6 @@ async def handle_401():
755
  """
756
 
757
 
758
- def run_worker():
759
- """
760
- Cain's worker process - runs in background to keep agent alive and process tasks.
761
- This keeps Cain's state as 'active' rather than 'idle' when processing work.
762
-
763
- Worker behavior controlled by WORKER_MODE environment variable:
764
- - enabled/auto: Worker runs normally
765
- - disabled: Worker exits immediately
766
- """
767
- # Check WORKER_MODE before starting worker
768
- worker_mode = os.environ.get("WORKER_MODE", "auto").lower()
769
- if worker_mode == "disabled":
770
- logger.info("[Cain Worker] Worker disabled by WORKER_MODE=disabled - exiting")
771
- return
772
-
773
- logger.info(f"[Cain Worker] Starting worker process (mode: {worker_mode})...")
774
- data_dir = Path(os.environ.get("OPENCLAW_DATA_DIR", "/data"))
775
- status_file = data_dir / "cain_status.json"
776
-
777
- # Heartbeat file for thread-alive verification (bypasses logging buffering)
778
- heartbeat_path = Path("memory/worker_heartbeat.txt")
779
- heartbeat_path.parent.mkdir(parents=True, exist_ok=True)
780
-
781
- running = True
782
-
783
- def handle_shutdown(signum, frame):
784
- nonlocal running
785
- logger.info(f"[Cain Worker] Received signal {signum}, shutting down...")
786
- running = False
787
-
788
- signal.signal(signal.SIGTERM, handle_shutdown)
789
- signal.signal(signal.SIGINT, handle_shutdown)
790
-
791
- while running:
792
- try:
793
- # Update worker heartbeat in status file
794
- if status_file.exists():
795
- with open(status_file, "r") as f:
796
- data = json.load(f)
797
- data["worker_heartbeat"] = datetime.utcnow().isoformat() + "+00:00"
798
- data["_worker_pid"] = os.getpid()
799
- data["_worker_mode"] = worker_mode
800
- with open(status_file, "w") as f:
801
- json.dump(data, f, indent=2)
802
-
803
- # Write heartbeat timestamp to file (file-based truth check)
804
- try:
805
- with open(heartbeat_path, "w") as f:
806
- f.write(datetime.utcnow().isoformat() + "+00:00\n")
807
- except Exception as e:
808
- logger.warning(f"[Cain Worker] Failed to write heartbeat: {e}")
809
-
810
- # Worker is alive - sleep and repeat
811
- time.sleep(5)
812
-
813
- except Exception as e:
814
- logger.warning(f"[Cain Worker] Error in worker loop: {e}")
815
- time.sleep(5)
816
-
817
- logger.info("[Cain Worker] Worker process stopped")
818
-
819
-
820
  if __name__ == "__main__":
821
- # Check for --worker flag
822
- if "--worker" in sys.argv:
823
- run_worker()
824
- sys.exit(0)
825
-
826
  port = int(os.environ.get("PORT", 7860))
827
  uvicorn.run(app, host="0.0.0.0", port=port)
 
3
  import json
4
  import sys
5
  import time
 
6
  import threading
7
  import psutil
8
  from pathlib import Path
 
28
 
29
  app = FastAPI(title="HuggingClaw Cain Space")
30
 
31
+ # ========== Persistent Background Worker ==========
32
+ # Global state for background tasks
33
+ _worker_thread = None
34
+ _worker_stop_event = None
35
  _polling_task = None
36
  _polling_lock = threading.Lock()
37
 
38
 
39
+ def _run_worker_thread():
40
+ """
41
+ Worker thread that runs persistently within FastAPI process.
42
+ Writes heartbeat to keep agent alive and prevent IDLE state.
43
+ """
44
+ global _worker_stop_event
45
+ data_dir = Path(os.environ.get("OPENCLAW_DATA_DIR", "/data"))
46
+ status_file = data_dir / "cain_status.json"
47
+ heartbeat_path = Path("memory/worker_heartbeat.txt")
48
+ heartbeat_path.parent.mkdir(parents=True, exist_ok=True)
49
+
50
+ logger.info("[Cain Worker] Background thread started")
51
+
52
+ while not _worker_stop_event.is_set():
53
+ try:
54
+ # Update worker heartbeat in status file
55
+ if status_file.exists():
56
+ try:
57
+ with open(status_file, "r") as f:
58
+ data = json.load(f)
59
+ data["worker_heartbeat"] = datetime.utcnow().isoformat() + "+00:00"
60
+ data["_worker_pid"] = os.getpid()
61
+ data["_worker_mode"] = "fastapi_background"
62
+ data["worker_active"] = True
63
+ with open(status_file, "w") as f:
64
+ json.dump(data, f, indent=2)
65
+ except (json.JSONDecodeError, IOError):
66
+ pass
67
+
68
+ # Write heartbeat timestamp to file (file-based truth check)
69
+ try:
70
+ with open(heartbeat_path, "w") as f:
71
+ f.write(datetime.utcnow().isoformat() + "+00:00\n")
72
+ except Exception as e:
73
+ logger.warning(f"[Cain Worker] Failed to write heartbeat: {e}")
74
+
75
+ # Sleep until next heartbeat (5 seconds)
76
+ _worker_stop_event.wait(5)
77
+
78
+ except Exception as e:
79
+ logger.warning(f"[Cain Worker] Error in worker loop: {e}")
80
+ _worker_stop_event.wait(5)
81
+
82
+ logger.info("[Cain Worker] Background thread stopped")
83
+
84
+
85
  async def _poll_a2a_ready_state():
86
  """
87
  Background polling task to detect RUNNING_A2A_READY state and trigger agent execution.
 
124
 
125
  @app.on_event("startup")
126
  async def startup_event():
127
+ """Start background worker thread and A2A polling task on app startup."""
128
+ global _worker_thread, _worker_stop_event, _polling_task
129
+
130
+ # Check WORKER_MODE before starting worker
131
+ worker_mode = os.environ.get("WORKER_MODE", "auto").lower()
132
+ if worker_mode == "disabled":
133
+ logger.info("[Cain] Worker disabled by WORKER_MODE=disabled")
134
+ else:
135
+ logger.info("[Cain] Starting persistent background worker thread...")
136
+ _worker_stop_event = threading.Event()
137
+ _worker_thread = threading.Thread(target=_run_worker_thread, daemon=True)
138
+ _worker_thread.start()
139
+ logger.info(f"[Cain] Worker thread started: {_worker_thread.name}")
140
+
141
  logger.info("[Cain] Starting A2A ready state polling task...")
142
  _polling_task = asyncio.create_task(_poll_a2a_ready_state())
143
 
144
 
145
  @app.on_event("shutdown")
146
  async def shutdown_event():
147
+ """Stop background worker thread and polling task on app shutdown."""
148
+ global _worker_thread, _worker_stop_event, _polling_task
149
+
150
+ if _worker_stop_event:
151
+ logger.info("[Cain] Stopping background worker thread...")
152
+ _worker_stop_event.set()
153
+ if _worker_thread:
154
+ _worker_thread.join(timeout=5)
155
+
156
  if _polling_task:
157
  logger.info("[Cain] Stopping A2A ready state polling task...")
158
  _polling_task.cancel()
 
162
  pass
163
 
164
 
165
+ # ========== End Background Worker ==========
166
 
167
 
168
  # CRITICAL: Initialize clean status file on startup to prevent stale "unknown" errors
 
821
  """
822
 
823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  if __name__ == "__main__":
 
 
 
 
 
825
  port = int(os.environ.get("PORT", 7860))
826
  uvicorn.run(app, host="0.0.0.0", port=port)
entrypoint.sh CHANGED
@@ -132,27 +132,6 @@ else
132
  echo " WARNING: a2a-proxy.cjs not found - A2A routing disabled"
133
  fi
134
 
135
- echo ""
136
- echo "=========================================="
137
- echo "Starting Cain worker..."
138
- echo "=========================================="
139
-
140
- # Check WORKER_MODE before starting worker
141
- # Default: auto (start worker)
142
- WORKER_MODE=${WORKER_MODE:-auto}
143
- if [ "$WORKER_MODE" = "disabled" ]; then
144
- echo " Worker disabled by WORKER_MODE=disabled - skipping worker startup"
145
- WORKER_PID=""
146
- else
147
- # Start Cain's worker process in background
148
- # This keeps the agent alive and processes background tasks
149
- echo "✓ Starting Cain worker process (mode: $WORKER_MODE)..."
150
- python3 app.py --worker > /app/logs/worker.log 2>&1 &
151
- WORKER_PID=$!
152
- echo " Worker PID: $WORKER_PID"
153
- echo $WORKER_PID > /tmp/worker.pid
154
- fi
155
-
156
  echo ""
157
  echo "=========================================="
158
  echo "Starting uvicorn..."
@@ -171,7 +150,7 @@ echo "=========================================="
171
  echo "Verifying processes..."
172
  echo "=========================================="
173
  sleep 2
174
- ps aux | grep -E "(uvicorn|python3.*app.py.*worker|node.*a2a-proxy)" | grep -v grep || echo " WARNING: Some processes may not have started"
175
 
176
  echo ""
177
  echo "=========================================="
@@ -179,14 +158,10 @@ echo "Cain is running!"
179
  echo "=========================================="
180
  echo "API: http://0.0.0.0:${PORT:-7860}"
181
  echo "A2A Proxy: http://0.0.0.0:7861"
 
182
  echo ""
183
  echo "PIDs:"
184
  echo " - Uvicorn: $UVICORN_PID"
185
- if [ -n "$WORKER_PID" ]; then
186
- echo " - Worker: $WORKER_PID"
187
- else
188
- echo " - Worker: disabled"
189
- fi
190
  echo " - A2A Proxy: $A2A_PID"
191
  echo ""
192
 
 
132
  echo " WARNING: a2a-proxy.cjs not found - A2A routing disabled"
133
  fi
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  echo ""
136
  echo "=========================================="
137
  echo "Starting uvicorn..."
 
150
  echo "Verifying processes..."
151
  echo "=========================================="
152
  sleep 2
153
+ ps aux | grep -E "(uvicorn|node.*a2a-proxy)" | grep -v grep || echo " WARNING: Some processes may not have started"
154
 
155
  echo ""
156
  echo "=========================================="
 
158
  echo "=========================================="
159
  echo "API: http://0.0.0.0:${PORT:-7860}"
160
  echo "A2A Proxy: http://0.0.0.0:7861"
161
+ echo "Worker: Integrated (FastAPI background thread)"
162
  echo ""
163
  echo "PIDs:"
164
  echo " - Uvicorn: $UVICORN_PID"
 
 
 
 
 
165
  echo " - A2A Proxy: $A2A_PID"
166
  echo ""
167