import csv import io import json import os import subprocess import sys import tarfile import threading import time import uuid from pathlib import Path from fastapi import FastAPI, HTTPException, Query, Request from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response, StreamingResponse from releaseops_arena.tool_env import ReleaseOpsToolEnv from releaseops_arena.space_paths import get_outputs_root SUPPORTS_CONCURRENT_SESSIONS: bool = True MAX_CONCURRENT_ENVS = int(os.getenv("MAX_CONCURRENT_ENVS", "64")) app = FastAPI(title="ReleaseOps Arena Env") from releaseops_arena.eval_api import router as eval_router app.include_router(eval_router, prefix="/api") _env_sessions: dict[str, ReleaseOpsToolEnv] = {} _env_lock = threading.Lock() _training_lock = threading.Lock() _training_process: subprocess.Popen | None = None _training_started_at: float | None = None _training_command: list[str] | None = None _training_mode: str | None = None OUTPUTS_ROOT = get_outputs_root() _DEMO_DIR = Path(__file__).resolve().parent.parent / "demo" _FLOW_HTML = _DEMO_DIR / "releaseops_episode_flow.html" TRAINING_LOG_PATH = OUTPUTS_ROOT / "space_training.log" SMOKE_METRICS_PATH = OUTPUTS_ROOT / "grpo_env_smoke_metrics.json" PILOT_METRICS_PATH = OUTPUTS_ROOT / "grpo_env_metrics_pilot.json" @app.get("/") def root(): return { "name": "ReleaseOps Arena Env", "ok": True, "message": "Use /health for status, /reset and /step for env rollouts, or /train/smoke to start a GRPO smoke run.", "docs": "/docs", "ui": { "episode_flow": "/ui/flow", }, "notebook": { "on_hub": "https://huggingface.co/spaces/hiitsesh/New_gpu_space/blob/main/notebooks/ReleaseOps_final_walkthrough.ipynb", "raw": "https://huggingface.co/spaces/hiitsesh/New_gpu_space/resolve/main/notebooks/ReleaseOps_final_walkthrough.ipynb", }, "pitch_video": "https://www.youtube.com/shorts/OxfBH7jDOwg", "training": { "start_smoke": "/train/smoke", "start_pilot": "/train/pilot?max_steps=100", "status": "/train/status", "kill": "/train/kill", "logs": "/train/logs", "metrics": "/train/metrics", "summary": "/train/summary", "live_metrics": "/train/live", "live_plot_png": "/train/plot.png", "live_plot_html": "/train/plot", "push_to_hub": "/train/push_to_hub?repo_id=your-user/releaseops-grpo-artifacts", }, "artifacts": { "list": "/outputs/ls", "download_file": "/outputs/file?path=grpo_env_metrics_pilot.json", "download_archive": "/outputs/archive?path=releaseops-grpo-pilot", "write_test_csv": "/outputs/write_test", }, "persistence": { "outputs_root": str(OUTPUTS_ROOT), "warning": ( "Without Space Storage, local disk is ephemeral on restart. If you mount `/data` " f"(Storage Buckets), this server writes under `{OUTPUTS_ROOT}` so logs and " "checkpoints survive sleep. Still recommend Hub push for backup." ), "push_to_hub": "/train/push_to_hub?repo_id=YOUR_USER/dataset-repo (set HF_TOKEN secret)", "sleep": "Space Settings → Sleep time: longer idle window reduces surprise restarts.", }, } @app.get("/ui/flow") def episode_flow_explanation(): """ Mermaid flowchart (episode + n8n-style CI). Needs outbound HTTPS for cdn.jsdelivr.net / fonts. """ if not _FLOW_HTML.is_file(): raise HTTPException(status_code=404, detail="demo/releaseops_episode_flow.html not in image") return FileResponse( str(_FLOW_HTML), media_type="text/html; charset=utf-8", ) @app.get("/health") def health(): return { "ok": True, "active_sessions": len(_env_sessions), "max_concurrent_envs": MAX_CONCURRENT_ENVS, } def _tail_text(path: Path, max_lines: int = 80) -> str: if not path.exists(): return "" with open(path, "r", encoding="utf-8", errors="replace") as handle: lines = handle.readlines() return "".join(lines[-max_lines:]) def _read_metrics(path: Path): if not path.exists(): return None with open(path, "r", encoding="utf-8") as handle: return json.load(handle) def _summarize_metrics(rows): if not rows: return None reward_rows = [row for row in rows if "reward" in row] if not reward_rows: return {"reward_points": 0} first = reward_rows[0] last = reward_rows[-1] return { "reward_points": len(reward_rows), "first_step": first.get("step"), "last_step": last.get("step"), "reward_first": first.get("reward"), "reward_last": last.get("reward"), "reward_delta": ( last.get("reward") - first.get("reward") if isinstance(first.get("reward"), (int, float)) and isinstance(last.get("reward"), (int, float)) else None ), "tool_call_frequency_first": first.get("tools/call_frequency"), "tool_call_frequency_last": last.get("tools/call_frequency"), "tool_failure_frequency_first": first.get("tools/failure_frequency"), "tool_failure_frequency_last": last.get("tools/failure_frequency"), "loss_last": last.get("loss"), "train_runtime": rows[-1].get("train_runtime"), } def _training_state(): global _training_process running = _training_process is not None and _training_process.poll() is None exit_code = None if _training_process is None else _training_process.poll() elapsed_seconds = None if _training_started_at is not None: elapsed_seconds = round(time.time() - _training_started_at, 1) return { "running": running, "exit_code": exit_code, "mode": _training_mode, "command": _training_command, "elapsed_seconds": elapsed_seconds, "log_path": str(TRAINING_LOG_PATH), "recent_log": _tail_text(TRAINING_LOG_PATH), } def _start_training(command: list[str], mode: str): global _training_process, _training_started_at, _training_command, _training_mode with _training_lock: if _training_process is not None and _training_process.poll() is None: return { "started": False, "message": "Training is already running.", **_training_state(), } TRAINING_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) log_handle = open(TRAINING_LOG_PATH, "w", encoding="utf-8", buffering=1) log_handle.write(f"Starting {mode} training at {time.ctime()}\n") log_handle.write("Command: " + " ".join(command) + "\n\n") log_handle.flush() # Line-buffer log file + unbuffered Python child so /train/logs updates live (not stuck until process exit). train_env = os.environ.copy() train_env["PYTHONUNBUFFERED"] = "1" # Align with train_grpo: avoid torch._dynamo mega-cache duplicate registration # on some Space images, and keep compile off for GRPO. train_env.setdefault("TORCH_COMPILE_DISABLE", "1") # Inductor calls getpass.getuser() for default cache path; uids in Space # often have no /etc/passwd entry — fixed dirs under OUTPUTS avoid KeyError. train_env.setdefault( "TORCHINDUCTOR_CACHE_DIR", str(OUTPUTS_ROOT / ".torch_inductor_cache"), ) train_env.setdefault("TRITON_CACHE_DIR", str(OUTPUTS_ROOT / "triton_cache")) # HuggingFace datasets / hub: default ~/.cache can become /.cache when HOME is "/". _hf = str(OUTPUTS_ROOT / ".hf") train_env.setdefault("HF_HOME", _hf) train_env.setdefault("HUGGINGFACE_HUB_CACHE", f"{_hf}/hub") train_env.setdefault("HF_DATASETS_CACHE", f"{_hf}/datasets") train_env.setdefault("TRANSFORMERS_CACHE", f"{_hf}/transformers") train_env.setdefault("XDG_CACHE_HOME", f"{_hf}/xdg") if (train_env.get("HOME") or "").strip() in ("", "/"): train_env["HOME"] = f"{_hf}/user_home" _training_process = subprocess.Popen( command, stdout=log_handle, stderr=subprocess.STDOUT, text=True, env=train_env, ) _training_started_at = time.time() _training_command = command _training_mode = mode return { "started": True, "message": "Training started. Poll /train/status or /train/logs for progress.", **_training_state(), } @app.api_route("/train/smoke", methods=["GET", "POST"]) def train_smoke(): command = [ sys.executable, "training/train_grpo.py", "--smoke", "--max-steps", "8", "--num-generations", "2", "--output-dir", str(OUTPUTS_ROOT / "releaseops-grpo"), "--metrics-json", str(SMOKE_METRICS_PATH), ] return _start_training(command, "smoke") @app.api_route("/train/pilot", methods=["GET", "POST"]) def train_pilot( max_steps: int = 100, num_generations: int = 4, gradient_accumulation_steps: int = 4, max_completion_length: int = 512, learning_rate: float = 1e-5, logging_steps: int = 5, model_name: str = "Qwen/Qwen3-0.6B", bf16: bool = False, best_loss_dir: str = "", hub_model_repo: str = "", hub_upload_include: str = "best", ): bld = (best_loss_dir or "").strip() or str(OUTPUTS_ROOT / "best_by_loss") max_steps = max(1, min(max_steps, 500)) num_generations = max(2, min(num_generations, 16)) gradient_accumulation_steps = max(1, min(gradient_accumulation_steps, 32)) max_completion_length = max(128, min(max_completion_length, 2048)) logging_steps = max(1, min(logging_steps, 50)) command = [ sys.executable, "training/train_grpo.py", "--model-name", model_name, "--max-steps", str(max_steps), "--num-generations", str(num_generations), "--gradient-accumulation-steps", str(gradient_accumulation_steps), "--max-completion-length", str(max_completion_length), "--learning-rate", str(learning_rate), "--logging-steps", str(logging_steps), "--output-dir", str(OUTPUTS_ROOT / "releaseops-grpo-pilot"), "--metrics-json", str(PILOT_METRICS_PATH), "--best-loss-dir", bld, ] if bf16: command.append("--bf16") if hub_model_repo.strip(): command.extend(["--hub-model-repo", hub_model_repo.strip()]) if hub_upload_include in ("best", "final", "both"): command.extend(["--hub-upload-include", hub_upload_include]) return _start_training(command, "pilot") @app.get("/train/status") def train_status(): return _training_state() @app.api_route("/train/kill", methods=["GET", "POST"]) def train_kill(): """Terminate the currently running training process, if any.""" global _training_process with _training_lock: if _training_process is None or _training_process.poll() is not None: return {"killed": False, "message": "No active training process.", **_training_state()} try: _training_process.terminate() try: _training_process.wait(timeout=10) except subprocess.TimeoutExpired: _training_process.kill() _training_process.wait(timeout=5) except Exception as exc: return {"killed": False, "message": f"Failed to kill: {exc}", **_training_state()} return {"killed": True, "message": "Training process terminated.", **_training_state()} @app.get("/train/logs", response_class=PlainTextResponse) def train_logs(lines: int = 200): lines = max(20, min(lines, 2000)) body = _tail_text(TRAINING_LOG_PATH, max_lines=lines) or "No training log yet. Start /train/smoke first." return PlainTextResponse(content=body, media_type="text/plain; charset=utf-8") @app.get("/train/metrics") def train_metrics(): return { "smoke": _read_metrics(SMOKE_METRICS_PATH), "pilot": _read_metrics(PILOT_METRICS_PATH), } @app.get("/train/summary") def train_summary(): smoke = _read_metrics(SMOKE_METRICS_PATH) pilot = _read_metrics(PILOT_METRICS_PATH) return { "status": _training_state(), "smoke": _summarize_metrics(smoke), "pilot": _summarize_metrics(pilot), } def _latest_metrics_path() -> Path | None: """Pick whichever metrics file was most recently updated, for live plotting.""" candidates = [p for p in (PILOT_METRICS_PATH, SMOKE_METRICS_PATH) if p.exists()] if not candidates: return None return max(candidates, key=lambda p: p.stat().st_mtime) def _extract_series(rows): """Extract reward, loss, tool-call, tool-failure series from a TRL log_history.""" steps, rewards, losses, tcalls, tfails, completions = [], [], [], [], [], [] for row in rows or []: step = row.get("step") if step is None: continue if "reward" in row: steps.append(step) rewards.append(row.get("reward")) losses.append(row.get("loss")) tcalls.append(row.get("tools/call_frequency")) tfails.append(row.get("tools/failure_frequency")) completions.append(row.get("completions/mean_length")) return { "steps": steps, "reward": rewards, "loss": losses, "tool_call_frequency": tcalls, "tool_failure_frequency": tfails, "completion_mean_length": completions, } @app.get("/train/live") def train_live(): """Return the current live log history series (available after every logging step).""" latest = _latest_metrics_path() if latest is None: return {"available": False, "message": "No metrics yet. Start /train/smoke or /train/pilot."} rows = _read_metrics(latest) or [] series = _extract_series(rows) return { "available": True, "metrics_source": str(latest), "num_points": len(series["steps"]), "series": series, "status": _training_state(), } @app.get("/train/plot.png") def train_plot_png(): """Render the current training curves as a PNG image.""" try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt except ImportError as exc: raise HTTPException(status_code=500, detail="matplotlib not installed in Space.") from exc latest = _latest_metrics_path() if latest is None: raise HTTPException(status_code=404, detail="No metrics yet.") rows = _read_metrics(latest) or [] series = _extract_series(rows) if not series["steps"]: raise HTTPException(status_code=404, detail="No reward points logged yet.") fig, axes = plt.subplots(3, 1, figsize=(9, 9), sharex=True) steps = series["steps"] axes[0].plot(steps, series["reward"], marker="o", color="#2563eb", label="reward") axes[0].axhline(0, color="#999", linewidth=0.5, linestyle="--") axes[0].set_ylabel("reward (mean)") axes[0].legend(loc="upper left") axes[0].grid(alpha=0.3) loss_vals = [v for v in series["loss"] if v is not None] if loss_vals: axes[1].plot(steps, series["loss"], marker="o", color="#dc2626", label="loss") axes[1].set_ylabel("loss") axes[1].legend(loc="upper left") axes[1].grid(alpha=0.3) else: axes[1].text(0.5, 0.5, "loss not yet logged", ha="center", va="center", transform=axes[1].transAxes) axes[2].plot(steps, series["tool_call_frequency"], marker="o", color="#16a34a", label="tool_call_freq") axes[2].plot(steps, series["tool_failure_frequency"], marker="o", color="#ea580c", label="tool_failure_freq") axes[2].set_ylabel("tool usage") axes[2].set_xlabel("step") axes[2].legend(loc="upper left") axes[2].grid(alpha=0.3) mode = _training_mode or "idle" elapsed = _training_state().get("elapsed_seconds") fig.suptitle( f"ReleaseOps GRPO training | mode={mode} | elapsed={elapsed}s | points={len(steps)}", fontsize=11, ) fig.tight_layout(rect=[0, 0, 1, 0.97]) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=110) plt.close(fig) buf.seek(0) return Response(content=buf.getvalue(), media_type="image/png") @app.get("/train/plot", response_class=HTMLResponse) def train_plot_html(refresh: int = 20): """Auto-refreshing HTML page that shows /train/plot.png and current summary.""" refresh = max(5, min(refresh, 300)) html = f"""
)})
outputs/grpo_env_metrics_pilot.json (updated every logging step).