"""Persistent reward tuning session orchestrator.""" from __future__ import annotations import json import os import re import shutil import subprocess import threading from contextlib import suppress from copy import deepcopy from pathlib import Path from typing import Any from .advisor import AdvisorUnavailable, DeepSeekAdvisor from .process import GpuLease, ResourceBusyError, terminate_process from .schema import ( BASE_REWARD_CONFIGURATION, merge_proposal, validate_proposal, ) from .scoring import DEFAULT_OBJECTIVE_WEIGHTS, score_evaluation, validate_objective_weights from .storage import TuningStorage, now_iso from .study import OptunaStudies from .tensorboard import ingest_scalars ACTIVE_SESSION_STATES = { "queued", "running", "evaluating", "awaiting_approval", "paused", "interrupted", } RUNNING_STATES = {"queued", "running", "evaluating"} RUN_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") class TuningError(RuntimeError): pass class TuningManager: def __init__( self, trainer_root: Path, python: str, data_root: Path, lease: GpuLease, advisor: Any | None = None, ): self.trainer_root = trainer_root.expanduser().resolve() self.python = python self.data_root = data_root.expanduser().resolve() self.data_root.mkdir(parents=True, exist_ok=True) self.storage = TuningStorage(self.data_root / "tuning.sqlite3") self.storage.recover_interrupted() self.studies = OptunaStudies(self.data_root) self.lease = lease self.advisor = advisor or DeepSeekAdvisor() self.lock = threading.RLock() self.condition = threading.Condition(self.lock) self.workers: dict[str, threading.Thread] = {} self.processes: dict[str, subprocess.Popen[str]] = {} self.cancel_events: dict[str, threading.Event] = {} def capability(self) -> dict[str, Any]: capability = self.advisor.capability() capability.update({"ready": (self.trainer_root / "scripts" / "evaluate.py").is_file()}) return capability @staticmethod def _integer(payload: dict, name: str, default: int, minimum: int, maximum: int) -> int: value = payload.get(name, default) if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum: raise TuningError(f"{name} 必须在 {minimum}–{maximum} 之间") return value def parse_create(self, payload: Any) -> tuple[str, dict, dict, bool]: if not isinstance(payload, dict): raise TuningError("请求体必须是 JSON 对象") mode = payload.get("mode", "automatic") if mode not in ("automatic", "approval"): raise TuningError("mode 必须是 automatic 或 approval") if payload.get("taskId", "Unitree-Go2-Flat") != "Unitree-Go2-Flat": raise TuningError("第一版只支持 Unitree-Go2-Flat") run_name = payload.get("runName", "auto-tune") if not isinstance(run_name, str) or not RUN_NAME.fullmatch(run_name): raise TuningError("runName 格式无效") gpu_ids = payload.get("gpuIds", [0]) if ( not isinstance(gpu_ids, list) or not gpu_ids or any( isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255 for value in gpu_ids ) ): raise TuningError("gpuIds 必须是非空非负整数数组") trial_count = self._integer(payload, "trialCount", 12, 4, 20) rung0 = self._integer(payload, "initialIterations", 300, 1, 1000000) rung1 = self._integer(payload, "middleIterations", 900, rung0, 1000000) rung2 = self._integer(payload, "finalIterations", 2000, rung1, 1000000) config = { "taskId": "Unitree-Go2-Flat", "numEnvs": self._integer(payload, "numEnvs", 4096, 1, 16384), "seed": self._integer(payload, "seed", 42, 0, 2147483647), "runName": run_name, "gpuIds": gpu_ids, "trialCount": trial_count, "rungs": [rung0, rung1, rung2], "promote": [trial_count, min(4, trial_count), min(2, trial_count)], "evalNumEnvs": self._integer(payload, "evalNumEnvs", 256, 1, 4096), "evalSteps": self._integer(payload, "evalSteps", 1000, 10, 100000), "earlyStopPatience": self._integer(payload, "earlyStopPatience", 4, 1, 20), } objective = validate_objective_weights( payload.get("objectiveWeights", DEFAULT_OBJECTIVE_WEIGHTS) ) fallback = payload.get("fallbackEnabled", False) if not isinstance(fallback, bool): raise TuningError("fallbackEnabled 必须是布尔值") return mode, config, objective, fallback def create(self, payload: Any) -> dict: mode, config, objective, fallback = self.parse_create(payload) capability = self.capability() if not capability["ready"]: raise TuningError("评估入口未就绪") if not capability["configured"] and not fallback: raise TuningError("DeepSeek Agent 未配置;设置 DEEPSEEK_API_KEY 或显式启用 fallback") with self.lock: active = [ session for session in self.storage.list_sessions() if session["state"] in ACTIVE_SESSION_STATES ] if active: raise ResourceBusyError("已有调参 session 未结束") session = self.storage.create_session(mode, config, objective, fallback) self._start_worker(session["id"], resume=False) return self.detail(session["id"]) def _start_worker(self, session_id: str, resume: bool) -> None: cancel = threading.Event() self.cancel_events[session_id] = cancel worker = threading.Thread( target=self._run_session, args=(session_id, resume, cancel), name=f"tuning-{session_id[:8]}", daemon=True, ) self.workers[session_id] = worker worker.start() def detail(self, session_id: str) -> dict: session = self.storage.get_session(session_id) session["trials"] = self.storage.list_trials(session_id) session["proposals"] = self.storage.list_proposals(session_id) session["audit"] = self.storage.audit_events(session_id) return session def list(self) -> list[dict]: return self.storage.list_sessions() def _session_root(self, session_id: str) -> Path: root = (self.data_root / "sessions" / session_id).resolve() if not root.is_relative_to(self.data_root): raise TuningError("非法 session 路径") root.mkdir(parents=True, exist_ok=True) return root def _run_command( self, session_id: str, command: list[str], cwd: Path, environment: dict[str, str], log_path: Path, ) -> int: owner = f"tuning:{session_id}" self.lease.acquire(owner) try: process = subprocess.Popen( command, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, start_new_session=True, ) with self.lock: self.processes[session_id] = process assert process.stdout is not None with log_path.open("a", encoding="utf-8") as log: for line in process.stdout: log.write(line) log.flush() if self.cancel_events[session_id].is_set(): terminate_process(process) break return process.wait() finally: with self.lock: self.processes.pop(session_id, None) self.lease.release(owner) @staticmethod def _latest_checkpoint(run_dir: Path) -> Path | None: values = [] for path in run_dir.glob("model_*.pt"): match = re.fullmatch(r"model_(\d+)\.pt", path.name) if match: values.append((int(match.group(1)), path)) return max(values, default=(0, None), key=lambda value: value[0])[1] def _execute_trial( self, session: dict, trial: dict, resume_checkpoint: Path | None = None ) -> dict: session_id, trial_id = session["id"], trial["id"] root = self._session_root(session_id) run_dir = (root / trial["runDir"]).resolve() if not run_dir.is_relative_to(root): raise TuningError("trial 目录越界") run_dir.mkdir(parents=True, exist_ok=True) reward_path = run_dir / "reward_config.json" reward_path.write_text( json.dumps(trial["rewardConfig"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) config = session["config"] command = [ self.python, "-u", "scripts/train.py", config["taskId"], f"--env.scene.num-envs={config['numEnvs']}", f"--agent.max-iterations={trial['targetIterations']}", f"--agent.seed={config['seed']}", f"--agent.run-name={config['runName']}-t{trial['number']}-r{trial['rung']}", "--agent.logger=tensorboard", "--agent.upload-model=False", "--gpu-ids", json.dumps(config["gpuIds"], separators=(",", ":")), "--output-dir", str(run_dir), "--reward-config", str(reward_path), ] if resume_checkpoint is not None: command.extend(("--resume-checkpoint", str(resume_checkpoint))) environment = os.environ.copy() environment["WANDB_MODE"] = "disabled" environment["WANDB_SILENT"] = "true" self.storage.update_trial( trial_id, state="training", started_at=now_iso(), message="正在训练" ) self.storage.update_session( session_id, state="running", message=f"正在训练 trial {trial['number']} / rung {trial['rung']}", ) return_code = self._run_command( session_id, command, self.trainer_root, environment, run_dir / "train.log" ) ingest_scalars(self.storage, trial_id, run_dir) if self.cancel_events[session_id].is_set(): raise TuningError("session 已取消") checkpoint = self._latest_checkpoint(run_dir) policy = run_dir / "policy.onnx" if return_code != 0 or checkpoint is None or not policy.is_file(): raise TuningError(f"训练失败(返回码 {return_code})或缺少 checkpoint/policy.onnx") self._wait_if_paused(session_id, self.cancel_events[session_id]) eval_output = run_dir / "evaluation.json" eval_command = [ self.python, "-u", "scripts/evaluate.py", config["taskId"], "--checkpoint", str(checkpoint), "--output", str(eval_output), "--reward-config", str(reward_path), f"--num-envs={config['evalNumEnvs']}", f"--steps-per-seed={config['evalSteps']}", "--gpu-ids", json.dumps(config["gpuIds"], separators=(",", ":")), ] self.storage.update_trial(trial_id, state="evaluating", message="正在固定协议评估") self.storage.update_session( session_id, state="evaluating", message=f"正在评估 trial {trial['number']}" ) return_code = self._run_command( session_id, eval_command, self.trainer_root, environment, run_dir / "evaluate.log" ) ingest_scalars(self.storage, trial_id, run_dir / "evaluation-events") if return_code != 0 or not eval_output.is_file(): raise TuningError(f"评估失败(返回码 {return_code})") evaluation = json.loads(eval_output.read_text(encoding="utf-8")) baseline_trial = self.storage.list_trials(session_id)[0] if baseline_trial["evaluation"] is None: scored = { "eligible": True, "score": 0.0, "components": {}, "metrics": evaluation["metrics"], } else: scored = score_evaluation( baseline_trial["evaluation"]["metrics"], evaluation["metrics"], session["objectiveWeights"], ) evaluation["score"] = scored rel_checkpoint = str(checkpoint.relative_to(root)) rel_policy = str(policy.relative_to(root)) self.storage.update_trial( trial_id, state="completed", ended_at=now_iso(), message="训练与评估完成", checkpoint_path=rel_checkpoint, policy_path=rel_policy, evaluation=evaluation, score=scored["score"], eligible=scored["eligible"], ) try: optuna_number = self.studies.record( session_id, trial["rewardConfig"], scored["score"], scored["eligible"], trial["rung"], ) self.storage.audit( session_id, "optuna_trial_recorded", {"trialId": trial_id, "number": optuna_number} ) except Exception as error: self.storage.audit( session_id, "optuna_record_failed", {"trialId": trial_id, "error": str(error)[:500]} ) return self.storage.get_trial(trial_id) def _best(self, session_id: str, rung: int | None = None) -> dict | None: trials = [ trial for trial in self.storage.list_trials(session_id) if trial["state"] == "completed" and trial["eligible"] ] if rung is not None: trials = [trial for trial in trials if trial["rung"] == rung] return max( trials, key=lambda trial: trial["score"] if trial["score"] is not None else -999, default=None, ) def _proposal_context(self, session: dict) -> dict: trials = self.storage.list_trials(session["id"])[-12:] rejected_feedback = [ proposal["feedback"] for proposal in self.storage.list_proposals(session["id"]) if proposal["state"] == "rejected" and proposal["feedback"] ][-4:] return { "task": session["config"]["taskId"], "objectiveWeights": session["objectiveWeights"], "allowlist": "服务端将验证固定 schema;最多四项修改", "rejectedFeedback": rejected_feedback, "trials": [ { "number": t["number"], "rung": t["rung"], "score": t["score"], "eligible": t["eligible"], "rewardConfig": t["rewardConfig"], "evaluation": t["evaluation"] and t["evaluation"].get("metrics"), } for t in trials ], } def _fallback_patch(self, previous: dict, index: int) -> dict: names = ("track_linear_velocity", "action_rate_l2", "body_orientation_l2", "foot_slip") name = names[index % len(names)] old = previous["weights"][name] factor = 1.1 if index % 2 == 0 else 0.9 return validate_proposal({"weights": {name: old * factor}}, previous) def _request_proposal( self, session: dict, previous: dict, base_trial_id: str, index: int ) -> dict: try: result = self.advisor.propose(self._proposal_context(session), previous) source = "agent" except Exception as error: if not session["fallbackEnabled"]: raise AdvisorUnavailable(str(error)) from error result = { "patch": self._fallback_patch(previous, index), "rationale": f"Agent 不可用,显式 fallback:{error}", "expectedImpact": {}, "confidence": 0.2, "promptHash": None, "usage": {}, "model": "optuna-fallback", } source = "fallback" proposal = self.storage.create_proposal( session["id"], base_trial_id, result["patch"], result["rationale"], result.get("expectedImpact", {}), result["confidence"], source, ) self.storage.audit( session["id"], "proposal_created", { "proposalId": proposal["id"], "source": source, "promptHash": result.get("promptHash"), "usage": result.get("usage", {}), "model": result.get("model"), }, ) return proposal def _wait_for_approval( self, session_id: str, proposal_id: str, cancel: threading.Event ) -> dict: with self.condition: while not cancel.is_set(): proposal = self.storage.get_proposal(proposal_id) if proposal["state"] != "pending": return proposal self.condition.wait(timeout=1.0) raise TuningError("session 已取消") def _run_session(self, session_id: str, resume: bool, cancel: threading.Event) -> None: try: session = self.storage.get_session(session_id) trials = self.storage.list_trials(session_id) if resume: root = self._session_root(session_id) for interrupted in [trial for trial in trials if trial["state"] == "interrupted"]: run_dir = (root / interrupted["runDir"]).resolve() if run_dir.is_relative_to(root): shutil.rmtree(run_dir, ignore_errors=True) self.storage.delete_trial(interrupted["id"]) self.storage.audit( session_id, "session_resumed", { "discardedInterruptedTrials": [ t["id"] for t in trials if t["state"] == "interrupted" ] }, ) trials = self.storage.list_trials(session_id) if not trials: baseline_dir = "trial-000-rung-0" trial = self.storage.create_trial( session_id, 0, 0, session["config"]["rungs"][0], deepcopy(BASE_REWARD_CONFIGURATION), None, baseline_dir, ) self._execute_trial(session, trial) session = self.storage.get_session(session_id) completed_rung0 = [ t for t in self.storage.list_trials(session_id) if t["rung"] == 0 and t["state"] == "completed" ] next_number = len({t["number"] for t in completed_rung0}) best_score = max((t["score"] or 0.0 for t in completed_rung0), default=0.0) no_improve = 0 while ( next_number < session["config"]["trialCount"] and no_improve < session["config"]["earlyStopPatience"] ): if cancel.is_set(): raise TuningError("session 已取消") self._wait_if_paused(session_id, cancel) base = self._best(session_id, rung=0) or completed_rung0[0] proposal = self._request_proposal( session, base["rewardConfig"], base["id"], next_number ) if session["mode"] == "approval": self.storage.update_session( session_id, state="awaiting_approval", message="等待批准 Agent 建议" ) proposal = self._wait_for_approval(session_id, proposal["id"], cancel) if proposal["state"] == "rejected": self.storage.audit( session_id, "proposal_rejected", {"proposalId": proposal["id"], "feedback": proposal["feedback"]}, ) continue else: self.storage.decide_proposal(proposal["id"], "approved", "自动模式") proposal = self.storage.get_proposal(proposal["id"]) self._wait_if_paused(session_id, cancel) reward_config = merge_proposal(base["rewardConfig"], proposal["patch"]) trial = self.storage.create_trial( session_id, next_number, 0, session["config"]["rungs"][0], reward_config, proposal["id"], f"trial-{next_number:03d}-rung-0", ) result = self._execute_trial(session, trial) if result["eligible"] and (result["score"] or -999) > best_score + 0.01: best_score = result["score"] no_improve = 0 else: no_improve += 1 self.storage.update_session(session_id, consecutive_no_improve=no_improve) next_number += 1 # Promote top configurations; each new rung resumes its own previous checkpoint. for rung in (1, 2): self._wait_if_paused(session_id, cancel) previous = [ t for t in self.storage.list_trials(session_id) if t["rung"] == rung - 1 and t["state"] == "completed" and t["eligible"] ] previous.sort(key=lambda t: t["score"] or -999, reverse=True) promoted_numbers = { trial["number"] for trial in self.storage.list_trials(session_id) if trial["rung"] == rung and trial["state"] == "completed" } for parent in previous[: session["config"]["promote"][rung]]: if parent["number"] in promoted_numbers: continue if cancel.is_set(): raise TuningError("session 已取消") root = self._session_root(session_id) checkpoint = root / parent["checkpointPath"] trial = self.storage.create_trial( session_id, parent["number"], rung, session["config"]["rungs"][rung], parent["rewardConfig"], parent["proposalId"], f"trial-{parent['number']:03d}-rung-{rung}", ) self._execute_trial(session, trial, checkpoint) best = ( self._best(session_id, rung=2) or self._best(session_id, rung=1) or self._best(session_id, rung=0) ) if best is None: raise TuningError("没有通过安全门槛的 trial") preset_name = f"{session['config']['runName']}-{session_id[:8]}" self.storage.save_preset(preset_name, session_id, best["id"], best["rewardConfig"]) self.storage.update_session( session_id, state="succeeded", best_trial_id=best["id"], current_trial_id=None, message="调参完成", ) self.storage.audit( session_id, "session_completed", {"bestTrialId": best["id"], "preset": preset_name} ) except Exception as error: state = self.storage.get_session(session_id)["state"] if cancel.is_set() or state == "cancelled": self.storage.update_session( session_id, state="cancelled", message="调参已取消", current_trial_id=None ) else: self.storage.update_session( session_id, state="failed", message=str(error), current_trial_id=None ) self.storage.audit(session_id, "session_failed", {"error": str(error)[:1000]}) finally: with self.lock: self.workers.pop(session_id, None) self.processes.pop(session_id, None) def _wait_if_paused(self, session_id: str, cancel: threading.Event) -> None: with self.condition: while self.storage.get_session(session_id)["state"] == "paused" and not cancel.is_set(): self.condition.wait(timeout=1.0) def approve(self, session_id: str, proposal_id: str, payload: Any) -> dict: proposal = self.storage.get_proposal(proposal_id) if proposal["sessionId"] != session_id: raise TuningError("proposal 不属于该 session") patch = proposal["patch"] feedback = None if isinstance(payload, dict): feedback = payload.get("feedback") if "patch" in payload: base = self.storage.get_trial(proposal["baseTrialId"]) patch = validate_proposal(payload["patch"], base["rewardConfig"]) if not self.storage.decide_proposal(proposal_id, "approved", feedback, patch): raise TuningError("proposal 已处理") self.storage.audit( session_id, "proposal_approved", {"proposalId": proposal_id, "modified": patch != proposal["patch"]}, ) with self.condition: self.condition.notify_all() return self.detail(session_id) def reject(self, session_id: str, proposal_id: str, payload: Any) -> dict: feedback = payload.get("feedback", "") if isinstance(payload, dict) else "" if not isinstance(feedback, str) or len(feedback) > 2000: raise TuningError("feedback 无效") proposal = self.storage.get_proposal(proposal_id) if proposal["sessionId"] != session_id: raise TuningError("proposal 不属于该 session") if not self.storage.decide_proposal(proposal_id, "rejected", feedback): raise TuningError("proposal 已处理") with self.condition: self.condition.notify_all() return self.detail(session_id) def pause(self, session_id: str) -> dict: session = self.storage.get_session(session_id) if session["state"] not in RUNNING_STATES | {"awaiting_approval"}: raise TuningError("当前状态不能暂停") self.storage.update_session( session_id, state="paused", message="已暂停后续调度;当前子进程将完成" ) return self.detail(session_id) def resume(self, session_id: str) -> dict: session = self.storage.get_session(session_id) if session["state"] == "paused": self.storage.update_session(session_id, state="running", message="继续调参") with self.condition: self.condition.notify_all() elif session["state"] == "interrupted": self.storage.update_session(session_id, state="queued", message="从最近完整结果恢复") self._start_worker(session_id, resume=True) else: raise TuningError("当前状态不能恢复") return self.detail(session_id) def cancel(self, session_id: str) -> dict: self.storage.get_session(session_id) self.storage.update_session(session_id, state="cancelled", message="正在取消") event = self.cancel_events.get(session_id) if event: event.set() process = self.processes.get(session_id) if process: terminate_process(process) with self.condition: self.condition.notify_all() return self.detail(session_id) def metrics( self, session_id: str, trial_id: str, tags: list[str] | None, max_points: int ) -> dict: trial = self.storage.get_trial(trial_id) if trial["sessionId"] != session_id: raise TuningError("trial 不属于该 session") return {"trialId": trial_id, "series": self.storage.metrics(trial_id, tags, max_points)} def best_artifact(self, session_id: str) -> Path: session = self.storage.get_session(session_id) if not session["bestTrialId"]: raise TuningError("尚无最佳策略") trial = self.storage.get_trial(session["bestTrialId"]) if not trial["policyPath"]: raise TuningError("最佳策略文件不存在") root = self._session_root(session_id) path = (root / trial["policyPath"]).resolve() if not path.is_relative_to(root) or not path.is_file(): raise TuningError("最佳策略文件不存在") return path def preset_config(self, preset_id: str) -> dict: return self.storage.get_preset(preset_id)["rewardConfig"] def test_agent(self) -> dict: try: return self.advisor.test_connection() except Exception as error: raise TuningError(f"Agent 连接测试失败:{error}") from error def shutdown(self) -> None: for session_id in list(self.workers): with suppress(KeyError, TuningError): self.cancel(session_id) for worker in list(self.workers.values()): worker.join(timeout=7)