mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 00:52:07 +00:00
457 lines
16 KiB
Python
457 lines
16 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import asyncio
|
|
import atexit
|
|
import os
|
|
import pathlib
|
|
import signal
|
|
import sys
|
|
import threading
|
|
|
|
# Add project root to Python path to import shared utils
|
|
_project_root = pathlib.Path(__file__).parent.parent
|
|
if str(_project_root) not in sys.path:
|
|
sys.path.insert(0, str(_project_root))
|
|
|
|
import logging
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
|
|
# Disable verbose CAMEL logs
|
|
logging.getLogger("camel").setLevel(logging.WARNING)
|
|
logging.getLogger("camel.base_model").setLevel(logging.WARNING)
|
|
logging.getLogger("camel.agents").setLevel(logging.WARNING)
|
|
logging.getLogger("camel.societies").setLevel(logging.WARNING)
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
|
|
|
|
def _enable_system_trust_store() -> None:
|
|
try:
|
|
import truststore
|
|
|
|
truststore.inject_into_ssl()
|
|
logging.getLogger("main").info("System trust store enabled")
|
|
except Exception:
|
|
logging.getLogger("main").warning(
|
|
"Failed to enable system trust store; falling back to default CA bundle",
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
_enable_system_trust_store()
|
|
|
|
from app.auth.local_control import capture_local_control_capability
|
|
|
|
# Electron passes this once at process creation. Consume it before routers,
|
|
# toolkits, or model-controlled subprocesses can inspect the Brain environment.
|
|
capture_local_control_capability()
|
|
|
|
from app import api
|
|
from app.component.environment import env
|
|
from app.router import register_routers
|
|
from app.run_sync.middleware import cloud_sync_configuration_middleware
|
|
from app.utils.event_loop_utils import set_main_event_loop
|
|
|
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
_fallback_camel_log_dir = (
|
|
pathlib.Path.home() / ".eigent" / "fallback" / "camel_logs"
|
|
)
|
|
_fallback_camel_log_dir.mkdir(parents=True, exist_ok=True)
|
|
os.environ.setdefault("CAMEL_LOG_DIR", str(_fallback_camel_log_dir))
|
|
|
|
app_logger = logging.getLogger("main")
|
|
|
|
api.middleware("http")(cloud_sync_configuration_middleware)
|
|
|
|
# Log application startup
|
|
app_logger.info("Starting Eigent Multi-Agent System API")
|
|
app_logger.info(f"Python encoding: {os.environ.get('PYTHONIOENCODING')}")
|
|
app_logger.info(f"Environment: {os.environ.get('ENVIRONMENT', 'development')}")
|
|
|
|
prefix = env("url_prefix", "")
|
|
app_logger.info(f"Loading routers with prefix: '{prefix}'")
|
|
app_logger.info(
|
|
f"MCP will be at: {prefix}/mcp/list, health at: {prefix}/health"
|
|
)
|
|
register_routers(api, prefix)
|
|
app_logger.info("All routers loaded successfully")
|
|
|
|
# Check if debug mode is enabled via environment variable
|
|
if os.environ.get("ENABLE_PYTHON_DEBUG") == "true":
|
|
try:
|
|
import debugpy
|
|
|
|
DEBUG_PORT = int(os.environ.get("DEBUG_PORT", "5678"))
|
|
app_logger.info(
|
|
f"Debug mode enabled - Starting debugpy server on port {DEBUG_PORT}"
|
|
)
|
|
debugpy.listen(("localhost", DEBUG_PORT))
|
|
app_logger.info(
|
|
f"Debugger ready for attachment on localhost:{DEBUG_PORT}"
|
|
)
|
|
# 📝 In VS Code: Run 'Debug Python Backend (Attach)' configuration
|
|
# Don't wait for client automatically - let it attach when ready
|
|
except ImportError:
|
|
app_logger.warning(
|
|
"debugpy not available, install with: uv add debugpy"
|
|
)
|
|
except Exception as e:
|
|
app_logger.error(f"Failed to start debugpy: {e}")
|
|
|
|
dir = pathlib.Path(__file__).parent / "runtime"
|
|
dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# Write PID file asynchronously
|
|
async def write_pid_file():
|
|
r"""Write PID file asynchronously"""
|
|
import aiofiles
|
|
|
|
async with aiofiles.open(dir / "run.pid", "w") as f:
|
|
await f.write(str(os.getpid()))
|
|
app_logger.info(f"PID file written: {os.getpid()}")
|
|
|
|
|
|
# PID task will be created on startup
|
|
pid_task = None
|
|
|
|
|
|
@api.on_event("startup")
|
|
async def startup_event():
|
|
global pid_task
|
|
set_main_event_loop(asyncio.get_running_loop())
|
|
pid_task = asyncio.create_task(write_pid_file())
|
|
app_logger.info("PID write task created")
|
|
|
|
# Reconcile durable execution facts before accepting new Run admission.
|
|
# No Python coroutine or external Tool call is restarted implicitly.
|
|
from app.artifacts import finalize_recoverable_run_artifacts
|
|
from app.run_journal.runtime import get_default_run_journal
|
|
|
|
journal = get_default_run_journal()
|
|
artifact_recovery = await asyncio.to_thread(
|
|
finalize_recoverable_run_artifacts,
|
|
journal,
|
|
)
|
|
|
|
reconciliation = await asyncio.to_thread(journal.reconcile_startup)
|
|
from app.workspace_git import (
|
|
WorkspaceGitObserver,
|
|
get_default_workforce_git_service,
|
|
get_default_workspace_git_lifecycle,
|
|
get_default_workspace_mutation_service,
|
|
)
|
|
|
|
workforce_reconciliation = await asyncio.to_thread(
|
|
get_default_workforce_git_service().reconcile_startup
|
|
)
|
|
workspace_reconciliation = await asyncio.to_thread(
|
|
get_default_workspace_mutation_service().reconcile_startup
|
|
)
|
|
git_terminal_reconciliation = await asyncio.to_thread(
|
|
get_default_workspace_git_lifecycle().finalize_terminal_runs
|
|
)
|
|
git_observation = await asyncio.to_thread(
|
|
WorkspaceGitObserver(get_default_run_journal()).inspect_all
|
|
)
|
|
from app.lightweight_memory import migrate_legacy_memory_v1_on_startup
|
|
from app.workspace_config.legacy_migration import (
|
|
migrate_legacy_workspace_bundle_on_startup,
|
|
)
|
|
|
|
legacy_bundle_migration = await asyncio.to_thread(
|
|
migrate_legacy_workspace_bundle_on_startup,
|
|
journal,
|
|
)
|
|
if legacy_bundle_migration.status == "degraded":
|
|
app_logger.warning(
|
|
"Legacy Workspace Bundle migration degraded without blocking "
|
|
"startup: %s",
|
|
legacy_bundle_migration.error,
|
|
)
|
|
legacy_memory_migration = await asyncio.to_thread(
|
|
migrate_legacy_memory_v1_on_startup
|
|
)
|
|
if legacy_memory_migration.status == "degraded":
|
|
app_logger.warning(
|
|
"Legacy Memory V1 migration degraded without blocking startup: "
|
|
"%s files",
|
|
legacy_memory_migration.degraded_files,
|
|
)
|
|
app_logger.info(
|
|
"RunJournal startup reconciliation complete",
|
|
extra={
|
|
"recovered_artifact_manifests": len(artifact_recovery),
|
|
"interrupted_runs": len(reconciliation.interrupted_run_ids),
|
|
"completed_cancels": len(reconciliation.completed_cancel_run_ids),
|
|
"deadline_runs": len(reconciliation.deadline_run_ids),
|
|
"detached_attempts": len(reconciliation.detached_attempt_ids),
|
|
"outcome_unknown_tools": len(
|
|
reconciliation.outcome_unknown_tool_call_ids
|
|
),
|
|
"outcome_unknown_model_invocations": len(
|
|
reconciliation.outcome_unknown_model_invocation_ids
|
|
),
|
|
"pending_approvals": len(reconciliation.pending_approval_ids),
|
|
"recovered_agent_workspaces": len(
|
|
workforce_reconciliation.recovered_workspace_ids
|
|
),
|
|
"agent_workspaces_needing_attention": len(
|
|
workforce_reconciliation.needs_attention_workspace_ids
|
|
),
|
|
"reconcilable_commands": len(
|
|
reconciliation.reconcilable_command_ids
|
|
),
|
|
"recovered_git_change_sets": len(
|
|
workspace_reconciliation.recovered_change_set_ids
|
|
),
|
|
"git_change_sets_needing_attention": len(
|
|
workspace_reconciliation.needs_attention_change_set_ids
|
|
),
|
|
"finalized_terminal_git_runs": len(
|
|
git_terminal_reconciliation.finalizations
|
|
),
|
|
"terminal_git_finalization_failures": len(
|
|
git_terminal_reconciliation.failed_run_ids
|
|
),
|
|
"external_git_changes": len(git_observation.changes),
|
|
"git_observation_failures": len(
|
|
git_observation.failed_repository_ids
|
|
),
|
|
"legacy_memory_imported": (legacy_memory_migration.imported_count),
|
|
"legacy_memory_skipped": legacy_memory_migration.skipped_count,
|
|
},
|
|
)
|
|
|
|
# Initialize EnvironmentHands from Brain deployment (full on local/cloud_vm, sandbox in Docker)
|
|
from app.router_layer.hands_resolver import init_environment_hands
|
|
|
|
hands = init_environment_hands()
|
|
app_logger.info(f"EnvironmentHands initialized: mode={hands.mode}")
|
|
|
|
# Initialize telemetry tracer provider
|
|
from app.utils.telemetry.workforce_metrics import (
|
|
initialize_tracer_provider,
|
|
)
|
|
|
|
initialize_tracer_provider()
|
|
app_logger.info("Telemetry tracer provider initialized")
|
|
|
|
|
|
@api.on_event("shutdown")
|
|
async def shutdown_event_handler():
|
|
r"""Run cleanup when uvicorn receives SIGINT/SIGTERM and shuts down."""
|
|
await cleanup_resources()
|
|
|
|
|
|
async def cleanup_resources():
|
|
r"""Cleanup all resources on shutdown"""
|
|
app_logger.info("Starting graceful shutdown process")
|
|
|
|
# Stop detached execution consumers before cleaning their compatibility
|
|
# TaskLocks. RunJournal remains open until all producers have stopped.
|
|
try:
|
|
from app.run_runtime import close_default_run_coordinator
|
|
|
|
await close_default_run_coordinator()
|
|
except Exception as e:
|
|
app_logger.warning(f"RunCoordinator shutdown failed: {e}")
|
|
|
|
# Stop cloud outbox drain before closing its shared SQLite journal.
|
|
try:
|
|
from app.run_sync.runtime import close_default_cloud_sync_worker
|
|
|
|
await close_default_cloud_sync_worker()
|
|
except Exception as e:
|
|
app_logger.warning(f"CloudSyncWorker shutdown failed: {e}")
|
|
|
|
from app.service.task import _cleanup_task, task_locks
|
|
|
|
if _cleanup_task and not _cleanup_task.done():
|
|
_cleanup_task.cancel()
|
|
try:
|
|
await _cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Cleanup all task locks
|
|
for task_id in list(task_locks.keys()):
|
|
try:
|
|
task_lock = task_locks[task_id]
|
|
await task_lock.cleanup()
|
|
except Exception as e:
|
|
app_logger.error(f"Error cleaning up task {task_id}: {e}")
|
|
|
|
# Close the process-owned SQLite RunJournal after producers have stopped.
|
|
try:
|
|
from app.run_journal.runtime import close_default_run_journal
|
|
|
|
close_default_run_journal()
|
|
except Exception as e:
|
|
app_logger.warning(f"RunJournal shutdown failed: {e}")
|
|
|
|
# Remove PID file
|
|
pid_file = dir / "run.pid"
|
|
if pid_file.exists():
|
|
pid_file.unlink()
|
|
|
|
# Shutdown OpenTelemetry tracer (releases BatchSpanProcessor worker threads)
|
|
try:
|
|
from app.utils.telemetry.workforce_metrics import (
|
|
shutdown_tracer_provider,
|
|
)
|
|
|
|
shutdown_tracer_provider()
|
|
except Exception as e:
|
|
app_logger.warning(f"Telemetry shutdown failed: {e}")
|
|
|
|
# Shutdown TerminalToolkit thread pool (prevents non-daemon threads blocking exit)
|
|
try:
|
|
from app.agent.toolkit.terminal_toolkit import TerminalToolkit
|
|
|
|
if TerminalToolkit._thread_pool is not None:
|
|
TerminalToolkit._thread_pool.shutdown(wait=False)
|
|
TerminalToolkit._thread_pool = None
|
|
except Exception as e:
|
|
app_logger.warning(f"TerminalToolkit shutdown failed: {e}")
|
|
|
|
# Best-effort close Browser toolkit WebSocket/Node connections.
|
|
# Use a timeout so shutdown stays responsive even if a wrapper is stuck.
|
|
try:
|
|
from app.agent.toolkit.hybrid_browser_toolkit import (
|
|
websocket_connection_pool,
|
|
)
|
|
|
|
await asyncio.wait_for(
|
|
websocket_connection_pool.close_all(), timeout=3.0
|
|
)
|
|
except TimeoutError:
|
|
app_logger.warning("Browser WebSocket pool shutdown timed out")
|
|
except Exception as e:
|
|
app_logger.warning(f"Browser WebSocket pool shutdown failed: {e}")
|
|
|
|
set_main_event_loop(None)
|
|
app_logger.info("All resources cleaned up successfully")
|
|
|
|
|
|
# Register cleanup on exit with safe synchronous wrapper
|
|
def sync_cleanup():
|
|
"""Synchronous cleanup for atexit - handles PID file removal"""
|
|
try:
|
|
# Only perform synchronous cleanup tasks
|
|
pid_file = dir / "run.pid"
|
|
if pid_file.exists():
|
|
pid_file.unlink()
|
|
app_logger.info("PID file removed during shutdown")
|
|
except Exception as e:
|
|
app_logger.error(f"Error during atexit cleanup: {e}")
|
|
|
|
|
|
atexit.register(sync_cleanup)
|
|
|
|
# Log successful initialization
|
|
app_logger.info("Application initialization completed successfully")
|
|
|
|
DEFAULT_BRAIN_HOST = "127.0.0.1"
|
|
|
|
|
|
def run_standalone():
|
|
"""Run Brain in standalone mode (no Electron dependency)."""
|
|
import uvicorn
|
|
|
|
port = int(env("EIGENT_BRAIN_PORT", "5001"))
|
|
# Exposing Brain is an explicit deployment choice. Desktop and local dev
|
|
# default to loopback so LAN peers cannot reach mutable Chat/Run APIs.
|
|
host = env("EIGENT_BRAIN_HOST", DEFAULT_BRAIN_HOST)
|
|
reload = os.environ.get("EIGENT_DEBUG", "").lower() in ("1", "true", "yes")
|
|
|
|
app_logger.info(
|
|
f"Starting Brain in standalone mode: {host}:{port} (reload={reload})"
|
|
)
|
|
if reload:
|
|
uvicorn.run(
|
|
"main:api",
|
|
host=host,
|
|
port=port,
|
|
reload=reload,
|
|
timeout_graceful_shutdown=5,
|
|
)
|
|
return
|
|
|
|
config = uvicorn.Config(
|
|
"main:api",
|
|
host=host,
|
|
port=port,
|
|
reload=False,
|
|
timeout_graceful_shutdown=5,
|
|
)
|
|
server = uvicorn.Server(config)
|
|
server.install_signal_handlers = lambda: None
|
|
|
|
force_exit_timer = None
|
|
signal_count = {"count": 0}
|
|
old_sigint = signal.getsignal(signal.SIGINT)
|
|
old_sigterm = signal.getsignal(signal.SIGTERM)
|
|
|
|
def _force_exit(signum: int):
|
|
signame = signal.Signals(signum).name
|
|
app_logger.error(
|
|
"Force exiting Brain after %s because graceful shutdown did not finish",
|
|
signame,
|
|
)
|
|
os._exit(128 + signum)
|
|
|
|
def _handle_signal(signum, _frame):
|
|
nonlocal force_exit_timer
|
|
signame = signal.Signals(signum).name
|
|
signal_count["count"] += 1
|
|
|
|
if signal_count["count"] == 1:
|
|
app_logger.warning(
|
|
"%s received, requesting graceful shutdown. Press Ctrl+C again to force exit.",
|
|
signame,
|
|
)
|
|
server.should_exit = True
|
|
if force_exit_timer is None:
|
|
force_exit_timer = threading.Timer(
|
|
5.0, _force_exit, args=(signum,)
|
|
)
|
|
force_exit_timer.daemon = True
|
|
force_exit_timer.start()
|
|
return
|
|
|
|
app_logger.error(
|
|
"%s received again, force exiting Brain immediately", signame
|
|
)
|
|
_force_exit(signum)
|
|
|
|
signal.signal(signal.SIGINT, _handle_signal)
|
|
signal.signal(signal.SIGTERM, _handle_signal)
|
|
try:
|
|
server.run()
|
|
finally:
|
|
if force_exit_timer is not None:
|
|
force_exit_timer.cancel()
|
|
signal.signal(signal.SIGINT, old_sigint)
|
|
signal.signal(signal.SIGTERM, old_sigterm)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_standalone()
|