- 更新 backend 平台 API、platform_store、compute_gateway sync - 更新 compute agent/engine/adapter 及 API - 更新 Docker 部署配置(app/compute) - 新增 frontend/src/utils/ 工具模块 - 新增 scripts/ops_diagnostics.py 运维诊断脚本 - 新增 docs/2026-07-23-development-summary.md 开发总结 - 重构 frontend/dist 构建产物(新 hash) - 更新前端多个视图组件及 API 模块 Co-Authored-By: Claude <noreply@anthropic.com>
282 lines
10 KiB
Python
282 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import json
|
|
import contextlib
|
|
import hashlib
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
TERMINAL_STATUSES = {"completed", "failed", "stopped"}
|
|
|
|
|
|
@dataclass
|
|
class ManagedProcess:
|
|
id: str
|
|
name: str
|
|
command: list[str]
|
|
work_dir: str
|
|
log_path: Path
|
|
output_dir: str
|
|
gpus: list[int]
|
|
process: subprocess.Popen[Any] | None
|
|
created_at: float
|
|
pid: int | None = None
|
|
status: str = "running"
|
|
progress: int = 5
|
|
artifacts: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
|
|
class ProcessManager:
|
|
def __init__(self, log_root: str) -> None:
|
|
self.log_root = Path(log_root)
|
|
self.log_root.mkdir(parents=True, exist_ok=True)
|
|
self.registry_path = self.log_root / "compute-jobs.json"
|
|
self.jobs: dict[str, ManagedProcess] = {}
|
|
self._load_registry()
|
|
|
|
def create_job(self, payload: dict[str, Any], command: list[str], work_dir: str) -> dict[str, Any]:
|
|
job_id = str(payload.get("id") or f"job_{int(time.time() * 1000)}")
|
|
if job_id in self.jobs and self.jobs[job_id].status not in TERMINAL_STATUSES:
|
|
raise ValueError(f"job {job_id} is already running")
|
|
|
|
output_dir = str(payload.get("output_dir") or f"/data/yg-ft/outputs/{payload.get('name', job_id)}")
|
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
log_path = self.log_root / f"{job_id}.log"
|
|
env = os.environ.copy()
|
|
gpus = [int(item) for item in payload.get("gpus") or []]
|
|
locked = self.locked_gpus()
|
|
conflict = sorted(set(gpus).intersection(locked))
|
|
if conflict:
|
|
raise ValueError(f"gpu already locked: {conflict}")
|
|
if gpus:
|
|
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(item) for item in gpus)
|
|
env.update({str(k): str(v) for k, v in payload.get("env", {}).items()})
|
|
|
|
cwd = work_dir if Path(work_dir).exists() else None
|
|
with log_path.open("ab") as log_file:
|
|
log_file.write(f"[INFO] starting job_id={job_id} command={' '.join(command)}\n".encode("utf-8"))
|
|
process = subprocess.Popen(
|
|
command,
|
|
cwd=cwd,
|
|
env=env,
|
|
stdout=log_file,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
managed = ManagedProcess(
|
|
id=job_id,
|
|
name=str(payload.get("name") or job_id),
|
|
command=command,
|
|
work_dir=work_dir,
|
|
log_path=log_path,
|
|
output_dir=output_dir,
|
|
gpus=gpus,
|
|
process=process,
|
|
created_at=time.time(),
|
|
pid=process.pid,
|
|
progress=10,
|
|
)
|
|
self.jobs[job_id] = managed
|
|
data = self.serialize(managed)
|
|
self._save_registry()
|
|
return data
|
|
|
|
def get_job(self, job_id: str) -> dict[str, Any] | None:
|
|
job = self.jobs.get(job_id)
|
|
if not job:
|
|
return None
|
|
return self.serialize(job)
|
|
|
|
def list_jobs(self) -> list[dict[str, Any]]:
|
|
return [self.serialize(job) for job in self.jobs.values()]
|
|
|
|
def stop_job(self, job_id: str) -> dict[str, Any] | None:
|
|
job = self.jobs.get(job_id)
|
|
if not job:
|
|
return None
|
|
if job.status not in TERMINAL_STATUSES:
|
|
try:
|
|
if job.process is not None and os.name == "nt":
|
|
job.process.terminate()
|
|
elif job.pid is not None:
|
|
os.kill(job.pid, signal.SIGTERM)
|
|
if job.process is not None:
|
|
job.process.wait(timeout=10)
|
|
except Exception:
|
|
if job.process is not None:
|
|
job.process.kill()
|
|
elif job.pid is not None:
|
|
with contextlib.suppress(Exception):
|
|
os.kill(job.pid, signal.SIGKILL)
|
|
job.status = "stopped"
|
|
job.progress = min(job.progress, 99)
|
|
data = self.serialize(job)
|
|
self._save_registry()
|
|
return data
|
|
|
|
def logs(self, job_id: str) -> str:
|
|
job = self.jobs.get(job_id)
|
|
if not job or not job.log_path.exists():
|
|
return ""
|
|
return job.log_path.read_text(encoding="utf-8", errors="replace")
|
|
|
|
def serialize(self, job: ManagedProcess) -> dict[str, Any]:
|
|
code = job.process.poll() if job.process is not None else None
|
|
checkpoints = self._collect_checkpoints(job.output_dir)
|
|
if job.status not in TERMINAL_STATUSES:
|
|
if job.process is None and job.pid is not None and not self._pid_alive(job.pid):
|
|
job.status = "failed"
|
|
job.progress = min(job.progress, 99)
|
|
code = -1
|
|
elif code is None:
|
|
job.status = "running"
|
|
elapsed = max(0, int(time.time() - job.created_at))
|
|
job.progress = min(95, max(job.progress, 10 + elapsed // 6))
|
|
elif code == 0:
|
|
job.status = "completed"
|
|
job.progress = 100
|
|
job.artifacts = self._collect_artifacts(job.output_dir)
|
|
else:
|
|
job.status = "failed"
|
|
job.progress = min(job.progress, 99)
|
|
self._save_registry()
|
|
return {
|
|
"id": job.id,
|
|
"name": job.name,
|
|
"status": job.status,
|
|
"progress": job.progress,
|
|
"pid": job.pid,
|
|
"gpus": job.gpus,
|
|
"created_at": job.created_at,
|
|
"command": job.command,
|
|
"work_dir": job.work_dir,
|
|
"output_dir": job.output_dir,
|
|
"log_file": str(job.log_path),
|
|
"artifacts": job.artifacts,
|
|
"checkpoints": checkpoints,
|
|
"return_code": code,
|
|
}
|
|
|
|
def locked_gpus(self) -> set[int]:
|
|
locked: set[int] = set()
|
|
for job in self.jobs.values():
|
|
status = self.serialize(job)["status"]
|
|
if status in {"queued", "running"}:
|
|
locked.update(job.gpus)
|
|
return locked
|
|
|
|
def _collect_artifacts(self, output_dir: str) -> list[dict[str, Any]]:
|
|
root = Path(output_dir)
|
|
if not root.exists():
|
|
return []
|
|
artifacts: list[dict[str, Any]] = []
|
|
for path in root.rglob("*"):
|
|
if path.is_file():
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
size = path.stat().st_size
|
|
artifacts.append(
|
|
{
|
|
"path": str(path),
|
|
"name": path.name,
|
|
"size": size,
|
|
"size_bytes": size,
|
|
"checksum_sha256": digest.hexdigest(),
|
|
}
|
|
)
|
|
return artifacts[:200]
|
|
|
|
def _collect_checkpoints(self, output_dir: str) -> list[dict[str, Any]]:
|
|
root = Path(output_dir)
|
|
if not root.exists():
|
|
return []
|
|
checkpoints: list[dict[str, Any]] = []
|
|
for path in root.glob("checkpoint-*"):
|
|
if not path.is_dir():
|
|
continue
|
|
step = 0
|
|
try:
|
|
step = int(path.name.rsplit("-", 1)[-1])
|
|
except ValueError:
|
|
step = 0
|
|
size_bytes = sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
|
|
checkpoints.append(
|
|
{
|
|
"step": step,
|
|
"name": path.name,
|
|
"path": str(path),
|
|
"size_bytes": size_bytes,
|
|
"create_time": path.stat().st_mtime,
|
|
}
|
|
)
|
|
return sorted(checkpoints, key=lambda item: (int(item.get("step") or 0), str(item.get("name") or "")))
|
|
|
|
def _save_registry(self) -> None:
|
|
items = []
|
|
for job in self.jobs.values():
|
|
items.append(
|
|
{
|
|
"id": job.id,
|
|
"name": job.name,
|
|
"command": job.command,
|
|
"work_dir": job.work_dir,
|
|
"log_path": str(job.log_path),
|
|
"output_dir": job.output_dir,
|
|
"gpus": job.gpus,
|
|
"pid": job.pid,
|
|
"created_at": job.created_at,
|
|
"status": job.status,
|
|
"progress": job.progress,
|
|
"artifacts": job.artifacts,
|
|
}
|
|
)
|
|
self.registry_path.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
def _load_registry(self) -> None:
|
|
if not self.registry_path.exists():
|
|
return
|
|
try:
|
|
items = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return
|
|
for item in items if isinstance(items, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
pid = item.get("pid")
|
|
status = item.get("status", "failed")
|
|
if status not in TERMINAL_STATUSES and pid and not self._pid_alive(int(pid)):
|
|
status = "failed"
|
|
job = ManagedProcess(
|
|
id=str(item["id"]),
|
|
name=str(item.get("name") or item["id"]),
|
|
command=[str(part) for part in item.get("command") or []],
|
|
work_dir=str(item.get("work_dir") or ""),
|
|
log_path=Path(item.get("log_path") or self.log_root / f"{item['id']}.log"),
|
|
output_dir=str(item.get("output_dir") or ""),
|
|
gpus=[int(gpu) for gpu in item.get("gpus") or []],
|
|
process=None,
|
|
pid=int(pid) if pid else None,
|
|
created_at=float(item.get("created_at") or time.time()),
|
|
status=status,
|
|
progress=int(item.get("progress") or 0),
|
|
artifacts=item.get("artifacts") or [],
|
|
)
|
|
self.jobs[job.id] = job
|
|
|
|
def _pid_alive(self, pid: int) -> bool:
|
|
if pid <= 0:
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except OSError:
|
|
return False
|