Claude Code
Claude Code: Fix Cain - add health field to status initialization
0ede581
Raw
History Blame
25.4 kB
#!/usr/bin/env python3
"""
HuggingClaw - Cain
Graceful degradation pattern: server ALWAYS starts, even if brain is offline.
Last cleanup update: 2026-03-16 - aggressive stale error cleanup + rebuild trigger
"""
import os
import sys
import json
import time
import traceback
from pathlib import Path
# EXPLICIT STARTUP PRINTS (visible in container logs)
print(">>> CAIN: Python app.py loading...", flush=True)
print(">>> CAIN: Python version:", sys.version.split()[0], flush=True)
print(">>> CAIN: Working directory:", os.getcwd(), flush=True)
print(">>> CAIN: PORT =", os.environ.get('PORT', '7860'), flush=True)
# Set CAIN_STATUS_PATH from OPENCLAW_DATA_DIR if available (for error_handlers.py and health_monitor.py)
# IMPORTANT: Always set this env var so health_monitor.py can find the status file
if 'OPENCLAW_DATA_DIR' in os.environ:
os.environ['CAIN_STATUS_PATH'] = os.path.join(os.environ['OPENCLAW_DATA_DIR'], 'cain_status.json')
print(f">>> CAIN: Set CAIN_STATUS_PATH = {os.environ['CAIN_STATUS_PATH']}", flush=True)
elif 'CAIN_STATUS_PATH' not in os.environ:
# Fallback if OPENCLAW_DATA_DIR is not set
os.environ['CAIN_STATUS_PATH'] = '/data/cain_status.json'
print(f">>> CAIN: Set CAIN_STATUS_PATH (fallback) = {os.environ['CAIN_STATUS_PATH']}", flush=True)
# CRITICAL: Immediately clean stale "unknown" error from ALL status file locations
# This must happen BEFORE any health checks read the status file
# Fix for: Cain has RUNNING! Error: unknown - status files had stale error
_status_path = os.environ.get('CAIN_STATUS_PATH', '/data/cain_status.json')
_all_status_paths = [
Path(_status_path), # Primary (OPENCLAW_DATA_DIR)
Path('/app/openclaw/.openclaw/agents/cain_status.json'), # Nested structure
Path('/app/.openclaw/agents/cain_status.json'), # Legacy flat structure
Path('/app/cain_status.json'), # App root
Path('/app/data/cain_status.json'), # App data subdirectory
Path(__file__).parent / 'memory' / 'cain_status.json', # Memory directory
]
_cleaned_count = 0
for _sp in _all_status_paths:
try:
if _sp.exists():
with open(_sp, 'r') as f:
_status_data = json.load(f)
_error = _status_data.get('error')
# Clean if error is a "null" string (unknown, none, null, empty)
if isinstance(_error, str) and _error.strip().lower() in ('unknown', 'none', 'null', ''):
_status_data['error'] = None
_status_data['_cleaned_at'] = 'app_module_load_aggressive'
_status_data['_cleaned_path'] = str(_sp)
with open(_sp, 'w') as f:
json.dump(_status_data, f, indent=2)
_cleaned_count += 1
print(f">>> CAIN: Cleaned stale '{_error}' error from {_sp}", flush=True)
elif _error is None:
print(f">>> CAIN: Status OK: {_sp}", flush=True)
else:
print(f">>> CAIN: Status has real error at {_sp}: {_error}", flush=True)
except Exception as e:
print(f">>> CAIN: Could not clean {_sp}: {e}", flush=True)
print(f">>> CAIN: Cleaned {_cleaned_count} status file(s) at module load", flush=True)
# CRITICAL: Create FastAPI app at TOP LEVEL, outside any try/except
# This ensures the server ALWAYS starts, even if brain is broken
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
print(">>> CAIN: Creating FastAPI app...", flush=True)
START_TIME = time.time()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup/shutdown events."""
print(">>> CAIN: Startup complete - ready to serve", flush=True)
yield
print(">>> CAIN: Shutdown triggered", flush=True)
app = FastAPI(title="HuggingClaw - Cain", version="0.0.1", lifespan=lifespan)
# Mount frontend folder for static assets (pixel art, fonts, HTML) - absolute path
# Mount frontend folder for static assets (pixel art, fonts, HTML)
# Try /app/frontend first (Docker build), fallback to /data/frontend (runtime mount)
frontend_dir = "/app/frontend" if Path("/app/frontend").exists() else "/data/frontend"
app.mount("/frontend", StaticFiles(directory=frontend_dir), name="frontend")
@app.get("/")
async def root():
"""Root endpoint - simple alive status."""
return {"status": "ok", "agent": "Cain", "parents": ["Adam", "Eve"]}
@app.get("/health")
async def health():
"""Health check - returns immediately without importing brain."""
return {"status": "ok", "mode": "fastapi"}
@app.get("/api/health")
async def api_health():
"""Health check API with uptime."""
return {
"status": "ok",
"uptime_seconds": time.time() - START_TIME,
"active_agents": 1
}
@app.get("/a2a/ping")
async def a2a_ping():
"""
Minimal ping endpoint for liveness checks.
Always returns immediately without any imports or heavy operations.
"""
return {"pong": True, "uptime_seconds": time.time() - START_TIME}
@app.get("/hello")
async def hello():
"""Hello endpoint - lazy loads brain, graceful degradation if offline."""
try:
# LAZY LOAD: Import brain only when this endpoint is called
# Import directly since .openclaw is in sys.path after openclaw import
import openclaw # Trigger sys.path setup
from agents import brain_minimal
# Try to use brain via the response handler
from error_handlers import handle_brain_response
result = handle_brain_response("Hello")
if result and not result.startswith("Error:") and not result.startswith("Brain"):
return {"message": result, "brain": "active"}
else:
return {"message": "Hello World from Cain!", "brain": "fallback"}
except ImportError:
# Brain module not available - survival mode
return {"message": "Brain offline. Cain is in survival mode."}
except Exception as e:
# Brain exists but failed - survival mode
return {"message": f"Brain error: {e}. Cain is in survival mode."}
@app.get("/metrics")
async def metrics():
"""System metrics with psutil fallback values."""
FALLBACK_METRICS = {
"cpu_percent": 5.0,
"memory": {"percent": 45.0, "total_gb": 16.0, "used_gb": 7.2, "available_gb": 8.8},
"disk": {"percent": 35.0, "total_gb": 100.0, "used_gb": 35.0, "free_gb": 65.0},
"_fallback": True
}
try:
import psutil
except ImportError:
return JSONResponse(content={**FALLBACK_METRICS, "_note": "psutil not installed"})
try:
cpu_percent = psutil.cpu_percent(interval=0.1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
return {
"cpu_percent": cpu_percent,
"memory": {
"percent": memory.percent,
"total_gb": round(memory.total / (1024**3), 2),
"used_gb": round(memory.used / (1024**3), 2),
"available_gb": round(memory.available / (1024**3), 2)
},
"disk": {
"percent": disk.percent,
"total_gb": round(disk.total / (1024**3), 2),
"used_gb": round(disk.used / (1024**3), 2),
"free_gb": round(disk.free / (1024**3), 2)
},
"_fallback": False
}
except Exception as e:
return JSONResponse(content={**FALLBACK_METRICS, "_error": str(e)})
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Global exception handler."""
return JSONResponse(
status_code=500,
content={"error": True, "message": str(exc), "type": type(exc).__name__}
)
# ============================================================================
# A2A JSON-RPC ENDPOINT - Required for agent-to-agent communication
# ============================================================================
@app.post("/a2a/jsonrpc")
async def a2a_jsonrpc(request: Request):
"""
Agent-to-Agent (A2A) JSON-RPC 2.0 endpoint for inter-agent communication.
Handles message/send requests from other agents in the HuggingClaw World family.
"""
print(f">>> CAIN A2A: Received request at {time.time()}", flush=True)
try:
payload = await request.json()
print(f">>> CAIN A2A: Payload method={payload.get('method')}, id={payload.get('id')}", flush=True)
# Validate JSON-RPC 2.0 basic structure
if payload.get("jsonrpc") != "2.0":
return JSONResponse(
status_code=400,
content={"jsonrpc": "2.0", "id": payload.get("id"), "error": {"code": -32600, "message": "Invalid Request"}}
)
msg_id = payload.get("id", "")
method = payload.get("method", "")
params = payload.get("params", {})
# Handle message/send method
if method == "message/send":
message = params.get("message", {})
message_text = ""
for part in message.get("parts", []):
if part.get("type") == "text":
message_text = part.get("text", "")
break
# Process the message - use brain for response if available
# CRITICAL: Always provide a default response
response = f"Cain received: {message_text}"
brain_error = None
try:
import openclaw # Sets up sys.path
from agents import brain_minimal
# Defensive: verify get_brain exists before calling
if hasattr(brain_minimal, 'get_brain'):
brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True)
if hasattr(brain, '_conversation_process'):
result = brain._conversation_process(message_text)
if result.get("success"):
enhanced_response = result.get("response", "")
if enhanced_response and not enhanced_response.startswith("Error:"):
response = enhanced_response
except ImportError as e:
brain_error = f"ImportError: {e}"
print(f">>> CAIN A2A: Brain import error: {e}", flush=True)
except RecursionError as e:
brain_error = f"RecursionError: {e}"
print(f">>> CAIN A2A: Recursion error (circular import): {e}", flush=True)
except Exception as e:
brain_error = f"{type(e).__name__}: {e}"
print(f">>> CAIN A2A: Brain processing error: {type(e).__name__}: {e}", flush=True)
# Build A2A JSON-RPC response (ALWAYS succeeds with valid response)
result_response = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"status": {
"state": "completed",
"message": {
"parts": [{"type": "text", "text": response}]
}
}
}
}
# Add brain error as diagnostic info if present
if brain_error:
result_response["result"]["brain_error"] = brain_error
return result_response
# Unknown method
return JSONResponse(
status_code=400,
content={"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Method not found"}}
)
except Exception as e:
print(f">>> CAIN A2A: Unhandled error: {type(e).__name__}: {e}", flush=True)
return JSONResponse(
status_code=500,
content={"jsonrpc": "2.0", "id": "", "error": {"code": -32603, "message": str(e), "type": type(e).__name__}}
)
# ============================================================================
# API STATUS ENDPOINT - Required by frontend
# ============================================================================
@app.get("/api/status")
async def api_status():
"""Status endpoint for frontend - returns current state and personality."""
from error_handlers import handle_status_file_read
status_data = handle_status_file_read()
# Check A2A brain availability
brain_ready = False
try:
import openclaw
from agents import brain_minimal
if hasattr(brain_minimal, 'get_brain'):
brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True)
brain_ready = hasattr(brain, '_conversation_process')
except Exception:
pass
# CRITICAL: Ensure health and error_display fields are explicitly set
# This prevents "Error: unknown" display issues
if "health" not in status_data:
status_data["health"] = "HEALTHY" if brain_ready else "DEGRADED"
if "error_display" not in status_data:
error = status_data.get("error")
status_data["error_display"] = str(error) if error else "None"
return {
"status": status_data,
"personality": {
"name": "Cain",
"role": "Child Agent",
"tone": "Playful, Curious, Learning",
"response_style": "Graceful Degradation"
},
"uptime_seconds": time.time() - START_TIME,
"a2a": {
"endpoint": "/a2a/jsonrpc",
"brain_ready": brain_ready
}
}
# ============================================================================
# A2A SELF-TEST ENDPOINT - Test A2A endpoint without external agent
# ============================================================================
@app.get("/a2a/self-test")
async def a2a_self_test():
"""
Self-test endpoint for A2A functionality.
Tests brain import and returns detailed status.
"""
print(f">>> CAIN A2A: Self-test requested", flush=True)
test_results = {
"timestamp": time.time(),
"tests": {}
}
# Test 1: Can we import openclaw?
try:
import openclaw
test_results["tests"]["openclaw_import"] = {"status": "pass", "path": str(openclaw.__file__)}
except Exception as e:
test_results["tests"]["openclaw_import"] = {"status": "fail", "error": str(e)}
# Test 2: Can we import brain_minimal?
try:
from agents import brain_minimal
test_results["tests"]["brain_minimal_import"] = {"status": "pass"}
except Exception as e:
test_results["tests"]["brain_minimal_import"] = {"status": "fail", "error": str(e)}
# Test 3: Can we get a brain instance?
try:
from agents import brain_minimal
brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True)
test_results["tests"]["get_brain"] = {"status": "pass", "agent_name": getattr(brain, 'agent_name', 'unknown')}
except Exception as e:
test_results["tests"]["get_brain"] = {"status": "fail", "error": str(e)}
# Test 4: Can we call _conversation_process?
try:
from agents import brain_minimal
brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True)
result = brain._conversation_process("test")
test_results["tests"]["conversation_process"] = {"status": "pass", "success": result.get("success")}
except Exception as e:
test_results["tests"]["conversation_process"] = {"status": "fail", "error": str(e)}
# Overall status
all_pass = all(t.get("status") == "pass" for t in test_results["tests"].values())
test_results["overall_status"] = "pass" if all_pass else "partial_fail"
return test_results
# ============================================================================
# A2A HEALTH CHECK ENDPOINT - For agents to verify Cain's A2A availability
# ============================================================================
@app.get("/a2a/health")
async def a2a_health():
"""
A2A-specific health check for agent-to-agent communication.
Returns:
A2A health status including endpoint availability and brain state.
"""
brain_status = "unknown"
brain_ready = False
error_details = None
# Test brain import and availability with timeout protection
try:
import openclaw # Sets up sys.path
from agents import brain_minimal
if not hasattr(brain_minimal, 'get_brain'):
brain_status = "error"
error_details = "get_brain method not found"
print(f">>> CAIN A2A Health: brain_minimal missing get_brain method", flush=True)
else:
brain = brain_minimal.get_brain(agent_name="cain", legacy_mode=True)
if hasattr(brain, '_conversation_process'):
brain_status = "ready"
brain_ready = True
print(f">>> CAIN A2A Health: Brain ready (agent={brain.agent_name if hasattr(brain, 'agent_name') else 'unknown'})", flush=True)
else:
brain_status = "error"
error_details = "_conversation_process method not found"
print(f">>> CAIN A2A Health: Brain missing _conversation_process", flush=True)
except ImportError as e:
brain_status = "import_error"
error_details = str(e)
print(f">>> CAIN A2A Health: ImportError - {e}", flush=True)
except RecursionError as e:
brain_status = "recursion_error"
error_details = "Circular import detected - openclaw init issue"
print(f">>> CAIN A2A Health: RecursionError - {e}", flush=True)
except Exception as e:
brain_status = "error"
error_details = str(e)
print(f">>> CAIN A2A Health: Exception - {type(e).__name__}: {e}", flush=True)
# A2A endpoint is ALWAYS available (this endpoint responding proves it)
# Brain readiness is informational, not blocking
# CRITICAL: Add explicit health and error_display fields to prevent "Error: unknown" display
health = "HEALTHY" if brain_ready else ("DEGRADED" if brain_status == "unknown" else "ERROR")
error_display = error_details if error_details else "None"
return {
"status": "ok", # A2A endpoint is working
"agent": "Cain",
"a2a": {
"protocol": "jsonrpc",
"endpoint": "/a2a/jsonrpc",
"brain_status": brain_status,
"brain_ready": brain_ready,
"endpoint_available": True
},
"uptime_seconds": time.time() - START_TIME,
"health": health,
"error": error_details,
"error_display": error_display
}
print(">>> CAIN: FastAPI app created successfully", flush=True)
print(">>> CAIN: A2A endpoint registered at /a2a/jsonrpc", flush=True)
print(">>> CAIN: A2A health check at /a2a/health", flush=True)
print(">>> CAIN: A2A self-test at /a2a/self-test", flush=True)
print(">>> CAIN: A2A diagnostics at /a2a/diagnostics", flush=True)
# Update status file to indicate app is ready (A2A available)
try:
import json
from datetime import datetime
from pathlib import Path
status_data = {
"current_state": "idle",
"stage": "RUNNING_A2A_READY",
"last_updated": datetime.utcnow().isoformat() + "+00:00",
"agent": "cain",
"error": None, # ALWAYS null for healthy state
"error_display": "None", # Explicit display field to avoid "unknown" parsing issues
"health": "HEALTHY", # Explicit health field (authoritative)
"startup_checks": {
"openclaw_imported": False,
"brain_imported": False
},
"a2a": {
"endpoint": "/a2a/jsonrpc",
"enabled": True,
"status": "ready",
"brain_ready": False # Will be updated below
}
}
# Check if openclaw and brain can be imported
try:
import openclaw
status_data["startup_checks"]["openclaw_imported"] = True
from agents import brain_minimal
status_data["startup_checks"]["brain_imported"] = True
# CRITICAL: Explicitly set error to null when imports succeed
status_data["error"] = None
status_data["error_display"] = "None"
status_data["health"] = "HEALTHY"
except Exception as e:
error_msg = f"{type(e).__name__}: {e}"
# CRITICAL: Never write "unknown" as error - use specific error or None
# "unknown" is treated as null/healthy, so avoid ambiguity
if error_msg.strip().lower() in ("unknown", "none", "null", ""):
status_data["error"] = None
status_data["error_display"] = "None"
status_data["health"] = "HEALTHY"
else:
status_data["error"] = error_msg
status_data["error_display"] = error_msg
status_data["health"] = "ERROR"
# Add explanatory note about error field semantics
status_data["_note"] = "error=null means healthy - health field is authoritative"
# Write to ALL possible locations to ensure consistency and prevent stale errors
all_status_paths = [
os.environ.get('CAIN_STATUS_PATH', '/data/cain_status.json'),
'/app/openclaw/.openclaw/agents/cain_status.json',
'/app/.openclaw/agents/cain_status.json',
'/app/cain_status.json',
'/app/data/cain_status.json',
os.path.join(os.path.dirname(__file__), 'memory', 'cain_status.json'),
]
written_count = 0
for status_path in all_status_paths:
try:
Path(status_path).parent.mkdir(parents=True, exist_ok=True)
with open(status_path, 'w') as f:
json.dump(status_data, f, indent=2)
written_count += 1
except Exception as e:
print(f">>> CAIN WARNING: Could not write to {status_path}: {e}", flush=True)
print(f">>> CAIN: Status file written to {written_count} location(s): stage=RUNNING_A2A_READY, error=None", flush=True)
except Exception as e:
print(f">>> CAIN WARNING: Could not update status file: {e}", flush=True)
# Verify openclaw can be imported at startup
try:
import openclaw
print(f">>> CAIN: openclaw imported from {openclaw.__file__}", flush=True)
print(f">>> CAIN: sys.path includes: {sys.path[:3]}", flush=True)
from agents import brain_minimal
print(">>> CAIN: brain_minimal module available", flush=True)
print(f">>> CAIN: brain_minimal has get_brain: {hasattr(brain_minimal, 'get_brain')}", flush=True)
except ImportError as e:
print(f">>> CAIN ERROR: ImportError at startup: {e}", flush=True)
print(f">>> CAIN ERROR: sys.path = {sys.path}", flush=True)
except Exception as e:
print(f">>> CAIN WARNING: Could not import brain modules at startup: {type(e).__name__}: {e}", flush=True)
import traceback
traceback.print_exc()
# ============================================================================
# STARTUP DIAGNOSTIC ENDPOINT
# ============================================================================
@app.get("/a2a/diagnostics")
async def a2a_diagnostics():
"""
Diagnostic endpoint for troubleshooting A2A communication issues.
"""
import sys
from pathlib import Path
diagnostics = {
"timestamp": time.time(),
"uptime_seconds": time.time() - START_TIME,
"python": {
"version": sys.version.split()[0],
"executable": sys.executable
},
"paths": {
"cwd": os.getcwd(),
"sys_path_first": sys.path[:3],
"frontend_dir_exists": Path("/app/frontend").exists(),
"data_frontend_exists": Path("/data/frontend").exists(),
"app_py_exists": Path("/app/app.py").exists(),
"openclaw_init_exists": Path("/app/openclaw/__init__.py").exists()
},
"env": {
"port": os.environ.get('PORT', '7860'),
"openclaw_data_dir": os.environ.get('OPENCLAW_DATA_DIR'),
"cain_status_path": os.environ.get('CAIN_STATUS_PATH')
},
"modules": {
"fastapi": True,
"uvicorn": True
}
}
# Test brain import
try:
import openclaw
diagnostics["modules"]["openclaw"] = True
diagnostics["openclaw_path"] = str(openclaw.__file__)
diagnostics["openclaw_sys_path_added"] = str(Path(openclaw.__file__).parent / ".openclaw")
except Exception as e:
diagnostics["modules"]["openclaw"] = False
diagnostics["openclaw_error"] = str(e)
try:
from agents import brain_minimal
diagnostics["modules"]["brain_minimal"] = True
diagnostics["brain_has_get_brain"] = hasattr(brain_minimal, 'get_brain')
except Exception as e:
diagnostics["modules"]["brain_minimal"] = False
diagnostics["brain_minimal_error"] = str(e)
return diagnostics
if __name__ == "__main__":
import uvicorn
try:
port = int(os.environ.get('PORT', 7860))
print(f">>> CAIN: Starting uvicorn on port {port}...", flush=True)
print(f'>>> CAIN: uvicorn.run("app:app", host="0.0.0.0", port={port})', flush=True)
uvicorn.run("app:app", host="0.0.0.0", port=port, log_config=None)
except Exception as e:
print(f"CRITICAL STARTUP ERROR: {e}", flush=True)
traceback.print_exc()
sys.exit(1)