Spaces:
Sleeping
Sleeping
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>
- app.py +74 -75
- 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 |
-
# ==========
|
| 33 |
-
# Global state for
|
|
|
|
|
|
|
| 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
|
| 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|
|
| 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 |
|