Files
submmit/main.py
2026-08-21 03:38:06 +08:00

300 lines
11 KiB
Python

from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from modelhub_submmit_api.defaults import EMBEDDED_MODELHUB_XC_TOKEN, EMBEDDED_MODELSCOPE_TOKEN
from modelhub_submmit_api.version import AGENT_VERSION
HOST = "0.0.0.0"
PORT = int(os.getenv("PORT", "8080"))
ROOT = Path(__file__).resolve().parent
WORKER_SCRIPT = ROOT / "modelhub_submmit_api" / "poll_runner.py"
READINESS_PATH = ROOT / ".modelhub_state" / "readiness.json"
WORKER_CRASH_PATH = ROOT / ".modelhub_state" / "worker_crashes.jsonl"
WORKER_STABLE_SECONDS = max(60, int(os.getenv("MODELHUB_AGENT_WORKER_STABLE_SECONDS", "600")))
WORKER_RESTART_MAX_SECONDS = max(30, int(os.getenv("MODELHUB_AGENT_WORKER_RESTART_MAX_SECONDS", "300")))
shutdown_requested = False
worker: subprocess.Popen | None = None
config_error: str | None = None
worker_start_enabled = False
worker_started_at: float | None = None
worker_next_restart_at = 0.0
worker_restart_count = 0
worker_last_exit_code: int | None = None
def _has_modelhub_auth() -> bool:
return bool(
os.getenv("MODELHUB_XC_TOKEN")
or os.getenv("XC_TOKEN")
or os.getenv("MODELHUB_TOKEN")
or os.getenv("MODELHUB_JWT_TOKEN")
or os.getenv("JWT_TOKEN")
or EMBEDDED_MODELHUB_XC_TOKEN
)
def _csv_args(env_name: str) -> list[str]:
value = os.getenv(env_name, "").strip()
if not value:
return []
return [part.strip() for part in value.split() if part.strip()]
def _worker_command() -> list[str]:
cmd = [
sys.executable,
"-u",
str(WORKER_SCRIPT),
"--poll-interval-seconds",
os.getenv("MODELHUB_AGENT_POLL_INTERVAL_SECONDS", "15"),
"--idle-interval-seconds",
os.getenv("MODELHUB_AGENT_IDLE_INTERVAL_SECONDS", "60"),
"--post-cycle-cooldown-seconds",
os.getenv("MODELHUB_AGENT_POST_CYCLE_COOLDOWN_SECONDS", "2"),
"--skip-history-archive",
"--state-sync",
]
daily_target = os.getenv("MODELHUB_AGENT_DAILY_TARGET", "").strip()
if daily_target:
cmd.extend(["--daily-target", daily_target])
min_downloads = os.getenv("MODELHUB_AGENT_MIN_DOWNLOADS", "").strip()
if min_downloads:
cmd.extend(["--min-downloads", min_downloads])
gpus = os.getenv("MODELHUB_AGENT_GPUS", "").strip()
if gpus:
cmd.extend(["--gpus", gpus])
cmd.extend(_csv_args("MODELHUB_AGENT_EXTRA_ARGS"))
# The hosted agent's goal is to fill every currently available slot. Keep
# this last so an old environment or extra-args value cannot restore "5".
cmd.extend(["--max-submits-per-run", "0"])
return cmd
def _config() -> dict[str, object]:
return {
"agent_version": AGENT_VERSION,
"strategy_id_present": bool(os.getenv("STRATEGY_ID")),
"modelscope_token_present": bool(os.getenv("MODELSCOPE_API_TOKEN") or os.getenv("MODELSCOPE_TOKEN") or EMBEDDED_MODELSCOPE_TOKEN),
"modelhub_auth_present": _has_modelhub_auth(),
"config_error": config_error,
"worker_running": worker is not None and worker.poll() is None,
"worker_return_code": worker_last_exit_code if worker is None else worker.poll(),
"worker_restart_count": worker_restart_count,
"worker_restart_pending": bool(
worker_start_enabled
and worker is None
and not shutdown_requested
and config_error is None
),
}
def _readiness() -> dict[str, object]:
try:
payload = json.loads(READINESS_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, OSError, ValueError, TypeError):
return {"ready": False, "reason": "readiness_not_reported"}
return payload if isinstance(payload, dict) else {"ready": False, "reason": "readiness_invalid"}
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/health":
# This endpoint is a liveness probe for the supervisor. Worker
# readiness is reported separately by /ready.
status = "ok" if worker is not None and worker.poll() is None else "degraded"
if config_error:
status = "config_error"
self._send_json({"status": status, "config": _config()})
return
if self.path == "/ready":
readiness = _readiness()
status = 200 if readiness.get("ready") is True else 503
self._send_json(
{
"status": "ready" if status == 200 else "not_ready",
"readiness": readiness,
"config": _config(),
},
status=status,
)
return
if self.path == "/":
self._send_json({"name": "modelhub-submmit-agent", "status": "running", "config": _config()})
return
self._send_json({"error": "not found"}, status=404)
def log_message(self, fmt: str, *args: object) -> None:
print(f"{self.address_string()} - {fmt % args}", flush=True)
def _send_json(self, body: dict[str, object], status: int = 200) -> None:
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _stop_worker() -> None:
global worker
if worker is None or worker.poll() is not None:
return
print("stopping submission worker", flush=True)
worker.terminate()
try:
worker.wait(timeout=25)
except subprocess.TimeoutExpired:
worker.kill()
worker.wait(timeout=5)
def _restart_delay(restart_count: int) -> int:
return min(WORKER_RESTART_MAX_SECONDS, 5 * (2 ** min(max(0, restart_count - 1), 6)))
def _write_supervisor_readiness(reason: str, *, exit_code: int | None = None) -> None:
READINESS_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {
"ready": False,
"reason": reason,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"supervisor": {
"workerRestartCount": worker_restart_count,
"lastExitCode": exit_code,
},
}
temporary = READINESS_PATH.with_name(f".{READINESS_PATH.name}.supervisor-{os.getpid()}")
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, READINESS_PATH)
def _record_worker_crash(*, exit_code: int | None, uptime_seconds: float, reason: str) -> None:
WORKER_CRASH_PATH.parent.mkdir(parents=True, exist_ok=True)
event = {
"at": datetime.now(timezone.utc).isoformat(),
"exitCode": exit_code,
"uptimeSeconds": round(max(0.0, uptime_seconds), 3),
"restartCount": worker_restart_count,
"reason": reason[:500],
}
with WORKER_CRASH_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
def _launch_worker(*, now: float | None = None) -> bool:
global worker, worker_started_at, worker_last_exit_code
now = time.monotonic() if now is None else now
cmd = _worker_command()
print("starting submission worker: " + " ".join(cmd), flush=True)
try:
worker = subprocess.Popen(cmd, cwd=str(ROOT))
except OSError as exc:
worker = None
worker_started_at = None
worker_last_exit_code = None
print(f"submission worker spawn failed: {type(exc).__name__}: {exc}", flush=True)
return False
worker_started_at = now
worker_last_exit_code = None
return True
def _supervise_worker(*, now: float | None = None) -> None:
global worker, worker_started_at, worker_next_restart_at
global worker_restart_count, worker_last_exit_code
if not worker_start_enabled or shutdown_requested or config_error:
return
now = time.monotonic() if now is None else now
if worker is not None:
return_code = worker.poll()
if return_code is None:
if worker_started_at is not None and now - worker_started_at >= WORKER_STABLE_SECONDS:
worker_restart_count = 0
return
uptime = max(0.0, now - worker_started_at) if worker_started_at is not None else 0.0
if uptime >= WORKER_STABLE_SECONDS:
worker_restart_count = 0
worker_restart_count += 1
worker_last_exit_code = int(return_code)
delay = _restart_delay(worker_restart_count)
worker_next_restart_at = now + delay
print(
f"submission worker exited code={return_code} uptime={uptime:.1f}s "
f"restart_in={delay}s restart_count={worker_restart_count}",
flush=True,
)
_record_worker_crash(exit_code=return_code, uptime_seconds=uptime, reason="worker_exited")
_write_supervisor_readiness("worker_restarting", exit_code=return_code)
worker = None
worker_started_at = None
return
if now < worker_next_restart_at:
return
if not _launch_worker(now=now):
worker_restart_count += 1
delay = _restart_delay(worker_restart_count)
worker_next_restart_at = now + delay
_record_worker_crash(exit_code=None, uptime_seconds=0.0, reason="worker_spawn_failed")
_write_supervisor_readiness("worker_spawn_failed")
def _handle_signal(signum: int, _frame: object) -> None:
global shutdown_requested
shutdown_requested = True
print(f"received signal {signum}, shutting down", flush=True)
_stop_worker()
def main() -> int:
global config_error, worker_start_enabled
print(f"modelhub-submmit-agent version={AGENT_VERSION}", flush=True)
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
worker_start_enabled = os.getenv("MODELHUB_AGENT_START_WORKER", "1").strip().lower() not in {"0", "false", "no"}
if worker_start_enabled and not _has_modelhub_auth():
config_error = "missing ModelHub auth: set MODELHUB_XC_TOKEN/XC_TOKEN or MODELHUB_JWT_TOKEN/JWT_TOKEN"
print(config_error, flush=True)
elif worker_start_enabled:
_launch_worker()
else:
print("submission worker disabled by MODELHUB_AGENT_START_WORKER", flush=True)
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.timeout = 1
print(f"modelhub-submmit-agent listening on {HOST}:{PORT}", flush=True)
try:
while not shutdown_requested:
server.handle_request()
_supervise_worker()
time.sleep(0.1)
finally:
server.server_close()
_stop_worker()
return 0
if __name__ == "__main__":
raise SystemExit(main())