From 5d0d849c287309deb13516a45c20408ce2eb3a62 Mon Sep 17 00:00:00 2001 From: yuyr Date: Sun, 4 Jan 2026 15:28:41 +0800 Subject: [PATCH] =?UTF-8?q?V3.5=20=E6=94=AF=E6=8C=81Advanced=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E7=94=A8=E6=88=B7=E6=8F=90=E4=BE=9Bpython?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/mvp/v3.5/README.md | 7 + specs/mvp/v3.5/note.md | 3 + specs/mvp/v3.5/requirement.md | 40 ++ specs/mvp/v3.5/roadmap_v3.5.png | Bin 0 -> 100850 bytes specs/mvp/v3.5/v3.5_design.md | 366 +++++++++++++++++++ specs/mvp/v3.5/v3.5_dev_plan.md | 200 ++++++++++ src/mvp/py/argus/ray/builders.py | 9 + src/mvp/py/argus/ray/driver_entrypoint.py | 2 + src/mvp/py/argus/ray/models.py | 55 +++ src/mvp/py/argus/ray/ray_job_tool.py | 102 +++++- src/mvp/py/argus/service/advanced_command.py | 75 ++++ src/mvp/py/argus/service/app.py | 64 +++- src/mvp/py/argus/service/scheduler.py | 25 +- src/mvp/py/argus/service/ui.py | 61 +++- src/mvp/py/tests/test_advanced_command.py | 74 ++++ src/mvp/py/tests/test_app.py | 116 ++++++ src/mvp/py/tests/test_builders.py | 7 + src/mvp/py/tests/test_driver_entrypoint.py | 4 +- src/mvp/py/tests/test_models.py | 48 +++ src/mvp/py/tests/test_ray_job_tool.py | 55 +++ src/mvp/py/tests/test_scheduler.py | 61 ++++ src/mvp/py/tests/test_ui.py | 15 + 22 files changed, 1355 insertions(+), 34 deletions(-) create mode 100644 specs/mvp/v3.5/README.md create mode 100644 specs/mvp/v3.5/note.md create mode 100644 specs/mvp/v3.5/requirement.md create mode 100644 specs/mvp/v3.5/roadmap_v3.5.png create mode 100644 specs/mvp/v3.5/v3.5_design.md create mode 100644 specs/mvp/v3.5/v3.5_dev_plan.md create mode 100644 src/mvp/py/argus/service/advanced_command.py create mode 100644 src/mvp/py/tests/test_advanced_command.py diff --git a/specs/mvp/v3.5/README.md b/specs/mvp/v3.5/README.md new file mode 100644 index 0000000..c6df997 --- /dev/null +++ b/specs/mvp/v3.5/README.md @@ -0,0 +1,7 @@ +# MVP v3.5 + +本目录包含 v3.5 的需求与设计(精简版): + +- `requirement.md`:需求补充说明(来源于讨论) +- `roadmap_v3.5.png`:架构草图(Advanced Task + Resume + IB + Serving) +- `v3.5_design.md`:详细设计方案(基于 v3.0;当前迭代仅聚焦 Advanced TaskSpec + Custom Reward,Serving/IB/Resume/多版本 verl 暂缓) diff --git a/specs/mvp/v3.5/note.md b/specs/mvp/v3.5/note.md new file mode 100644 index 0000000..c68bdfd --- /dev/null +++ b/specs/mvp/v3.5/note.md @@ -0,0 +1,3 @@ + +1. node management(v3.5 引入的接口骨架:通过 SSH/平台能力管理 head/worker 节点生命周期;先做最小可用 --- 这个是干嘛的? +2. \ No newline at end of file diff --git a/specs/mvp/v3.5/requirement.md b/specs/mvp/v3.5/requirement.md new file mode 100644 index 0000000..56520f6 --- /dev/null +++ b/specs/mvp/v3.5/requirement.md @@ -0,0 +1,40 @@ + +v3.5 版本是在v3.0的基础上进行功能扩展: +1. 支持自定义命令,不走固定的TaskSpec模板,用户直接提供调用verl 的python命令,如下,这个灵活度更高,需要用户自己把握文件路径,用户使用 $HOME,服务层替换为用户自己的/private/users//路径,使用$COMMON 则替换为/private/ + +``` +PYTHONUNBUFFERED=1 python3 -m verl.trainer.main_ppo \ + data.train_files=$HOME/data/gsm8k/train.parquet \ + data.val_files=$HOME/data/gsm8k/test.parquet \ + data.train_batch_size=256 \ + data.max_prompt_length=512 \ + data.max_response_length=512 \ + actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \ + actor_rollout_ref.actor.optim.lr=1e-6 \ + actor_rollout_ref.actor.ppo_mini_batch_size=64 \ + actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=4 \ + actor_rollout_ref.rollout.name=vllm \ + actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=8 \ + actor_rollout_ref.rollout.tensor_model_parallel_size=1 \ + actor_rollout_ref.rollout.gpu_memory_utilization=0.4 \ + actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=4 \ + critic.optim.lr=1e-5 \ + critic.model.path=Qwen/Qwen2.5-0.5B-Instruct \ + critic.ppo_micro_batch_size_per_gpu=4 \ + algorithm.kl_ctrl.kl_coef=0.001 \ + trainer.logger=console \ + trainer.val_before_train=False \ + trainer.n_gpus_per_node=1 \ + trainer.nnodes=1 \ + trainer.save_freq=10 \ + trainer.test_freq=10 \ + trainer.total_epochs=15 +``` + +2. 支持自定义的奖励函数方法,你参考 verl 项目 [text](../../../verl) 里的示例,设计方案 + +3. 支持codepath指定用户上传到自己user路径下的 verl版本代码 + +4. 断点续训:支持某个已经complete(成功或者fail或者stopped)的任务task,从最后一个保存的checkpoint 继续训练,参数应该保持不变,你确认一下是不是对应一个新的ray job,或者分析一下verl 是否已经有类似的功能支持。 + +5. 支持训练走NCCL,使用RoCEv2和Infiband网络,调研一些verl怎样支持,需要哪些配置。 \ No newline at end of file diff --git a/specs/mvp/v3.5/roadmap_v3.5.png b/specs/mvp/v3.5/roadmap_v3.5.png new file mode 100644 index 0000000000000000000000000000000000000000..779e05beae8b88d26270a59774811196f9d7ab15 GIT binary patch literal 100850 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%0AX3 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

+|UR`N%7i(#0 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 背景:v3.0 已具备 WebUI + API server + 用户/任务隔离 + SFTPGo 数据管理 + Stateless Ray cluster(head + worker node pool)。 +> +> v3.5 本轮 **只做 2 件事**: +> 1) Advanced Task:支持用户提交自定义训练命令(command) +> 2) Custom Reward:支持用户通过 VERL 原生 `custom_reward_function.*` 方式注入 reward(仅方式 A:用户自己写命令) +> +> 明确不做(从上一版设计中移除):(3) 自定义 verl 版本/代码路径、(4) 断点续训、(5) IB/RoCEv2 网络支持、(6) Model Serving。 + +--- + +## 0. 继承 v3.0 的不变点(重要约束) + +1) **Node management 不变** +- v3.5 不新增/不修改 node management 机制;仍按 v3.0 现状运行(head 写 discovery、worker watchdog 自动 join、自愈)。 + +2) **Head 不跑训练** +- 所有训练/Serving driver 通过 Ray entrypoint placement 强制落在 worker(例如 `entrypoint_resources={"worker_node": 1}`)。 + +3) **SFTPGo 的 “common” 目录约定变更** +- 不再使用 `$COMMON` 宏。 +- 在 SFTPGo 中,把共享只读资源映射到用户 home 下的固定目录(用户在 SFTP/WebClient 看到的是 `$HOME/common/...`): + - `$HOME/common/datasets` → 容器内真实路径 `/private/datasets`(只读) + - `$HOME/common/hf` → 容器内真实路径 `/private/hf`(只读) + +> 这里的 `$HOME` 指:`/private/users/`(容器内路径)。 + +--- + +## 1. v3.5 需求范围(精简后) + +### 1.1 In scope + +**A. Advanced TaskSpec(自定义命令)** +- 用户提交 `command`(多行 shell 或单行) +- 平台做 `$HOME` 宏替换 +- 平台做 best-effort 安全检查(路径/关键参数),然后提交为 Ray job + +**B. Custom Reward(仅方式 A)** +- 用户在 `command` 里显式写 hydra overrides: + - `custom_reward_function.path=...` + - `custom_reward_function.name=...` + - `custom_reward_function.reward_kwargs.*=...`(可选) +- 平台不提供结构化 reward 字段(不做方式 B),只做检查(校验 path 合法) + +### 1.2 Out of scope(本轮不做) +- 自定义 verl 版本/代码路径(仍使用平台内置/公共 verl 代码快照) +- 断点续训(resume from checkpoint) +- IB/RoCEv2 网络专门支持(NCCL/RDMA env 先不引入平台) +- Model Serving(暂缓,后续单独设计迭代) + +--- + +## 2. Advanced TaskSpec 设计 + +### 2.1 为什么需要 Advanced Task + +v3.0 的 Basic TaskSpec(ppo/grpo/sft)通过平台模板生成固定 overrides,适合“快速跑通”。 +但科研/调参场景需要更高自由度:用户希望直接写 `python3 -m verl.trainer.main_ppo ...` 并自行控制每个 override。 + +### 2.2 Advanced TaskSpec(建议 schema) + +建议新增一种 TaskSpec 类型,通过 `kind: advanced` 区分: + +```yaml +kind: advanced + +# 资源(平台调度与预检查用;仍需要) +nnodes: 2 +n_gpus_per_node: 4 + +# 自定义命令(用户负责写对 VERL 的参数/路径) +# 平台会对 $HOME 做宏替换;其余保持原样 +command: | + PYTHONUNBUFFERED=1 python3 -m verl.trainer.main_ppo \ + data.train_files=$HOME/datasets/gsm8k/train.parquet \ + data.val_files=$HOME/datasets/gsm8k/test.parquet \ + actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \ + trainer.nnodes=2 \ + trainer.n_gpus_per_node=4 \ + trainer.total_epochs=1 \ + trainer.save_freq=10 \ + +ray_kwargs.ray_init.address=auto +``` + +### 2.3 `$HOME` 宏替换规则 + +仅支持 `$HOME`(v3.5 移除 `$COMMON`): +- `$HOME` → `/private/users/` + +用户如果要用共享数据/缓存: +- 共享数据:`$HOME/common/datasets/...` +- 共享 HF 缓存:`$HOME/common/hf/...`(通常不需要写进 command,但可用于 debug) + +#### 2.3.1 重要说明:SFTPGo “virtual folder” 与训练进程看到的“真实路径” + +在 SFTPGo 中,`$HOME/common/datasets` / `$HOME/common/hf` 是 **SFTP 虚拟目录映射**(virtual folder),它们映射到容器内真实路径: +- `$HOME/common/datasets` ↔ `/private/datasets` +- `$HOME/common/hf` ↔ `/private/hf` + +训练进程(Ray worker 上的 python 进程)看到的是 **容器内真实文件系统**,它并不会理解 SFTPGo 的 virtual folder。 + +因此,为了让用户能沿用 WebClient 里看到的路径语义(写 `$HOME/common/...`),服务层在提交 Advanced command 前需要做 **路径宏映射**: + +- `"$HOME/common/datasets"` → `"/private/datasets"` +- `"$HOME/common/hf"` → `"/private/hf"` +- 其余 `"$HOME"` → `"/private/users/"` + +这样用户写的 command 能在训练进程里正确读到文件。 + +### 2.4 服务层检查(best-effort,强约束 + 弱约束) + +> 目标:在不“解析完整 shell”的前提下,尽可能避免跨用户读文件与明显错误的任务。 + +**强约束(必须通过,否则 400)** +1) `nnodes`、`n_gpus_per_node` 必须存在(用于队列/资源预检查/placement) +2) `command` 必须包含一个明确的 python entry: + - 建议最低要求:包含 `python3` 且包含 `-m verl.trainer.`(防止随意执行系统命令) +3) 路径隔离校验(字符串/正则级别): + - 展开 `$HOME`(含 `$HOME/common/*` 映射到 `/private/*`)后: + - 禁止出现 `/private/users/` 下 “非当前用户”的路径(例如 `/private/users/bob/...`) + - 对 `data.train_files=...`、`data.val_files=...`(若出现)做 allowlist: + - 允许(用户目录):`/private/users//datasets/...` + - 允许(共享目录):`/private/datasets/...` + - 对 `custom_reward_function.path=...`(若出现)做 allowlist: + - 允许:`/private/users//code/...`(用户自行上传) + +**弱约束(warning,不阻塞)** +- 未检测到 `data.train_files=`/`data.val_files=`(可能是用户写成了别的 key 或使用了 config file) +- 未检测到 `+ray_kwargs.ray_init.address=auto`(v3.0/v3.5 推荐加,但用户可自行负责) + +> 说明:Advanced command 本质上属于“内部可信用户”能力,v3.5 不做强沙箱;安全检查以 best-effort 为主。 + +--- + +## 3. Custom Reward(仅方式 A:用户自己写) + +### 3.1 VERL 原生机制(本仓库 `verl/` 已调研) + +VERL PPO trainer 配置里支持: +- `custom_reward_function.path` +- `custom_reward_function.name` +- `custom_reward_function.reward_kwargs` + +对应实现位置: +- 配置模板:`verl/verl/trainer/config/ppo_trainer.yaml` +- 加载逻辑:`verl/verl/trainer/ppo/reward.py:get_custom_reward_fn` +- 典型 reward manager:`verl/verl/workers/reward_manager/naive.py` 会调用 `compute_score(...)` + +### 3.2 用户写法(示例) + +用户上传 `$HOME/code/reward.py`,在 command 里加: + +```bash +custom_reward_function.path=$HOME/code/reward.py \ +custom_reward_function.name=compute_score +``` + +函数签名建议(与 `naive` reward manager 参数对齐): + +```python +def compute_score(*, data_source: str, solution_str: str, ground_truth: str, extra_info=None, **kwargs): + ... +``` + +### 3.3 平台侧只做检查(不做字段扩展) + +v3.5 限定 reward 注入方式为 “用户写 command”,平台只做: +- 展开 `$HOME` +- 若检测到 `custom_reward_function.path=`,校验 path 在 `$HOME/code/` 下 +- 不尝试解析/合并 reward_kwargs(用户自己写) + +--- + +## 4. 服务层与 SFTPGo 的映射修改(你提出的关键点) + +v3.0 时代平台允许用户引用: +- `/private/common/datasets/...` +- `/private/common/hf/...` + +但现在 common 以 **SFTPGo virtual folder** 的形式呈现给用户(用户看到 `$HOME/common/...`,真实路径是 `/private/...`),因此 v3.5 的服务层需要做两件事: + +1) **用户侧语义(写 TaskSpec/command)** +- 共享 datasets(只读):`$HOME/common/datasets/...` +- 共享 hf cache(只读):`$HOME/common/hf/...` + +2) **运行时真实路径(提交到 Ray 前展开)** +- `$HOME/common/datasets/...` → `/private/datasets/...` +- `$HOME/common/hf/...` → `/private/hf/...` + +同时保留用户自有目录: +- 用户 datasets:`$HOME/datasets/...` +- 用户 models:`$HOME/models/...` +- 用户 code(reward):`$HOME/code/...` + +> 这部分主要影响: +> - Advanced command 检查(allowlist) +> - WebUI/Data 页面文案(告诉用户共享数据在哪里) + +> 兼容性建议:为了不影响 v3.0 期间已经习惯使用 `/private/common/datasets/...` 的用户/历史任务, +> v3.5 实现阶段建议 **同时接受**: +> - `/private/common/datasets/...`(旧路径语义,仍可读) +> - `/private/datasets/...`(真实路径语义,推荐) +> - Advanced command 里写的 `$HOME/common/datasets/...` 会先映射到 `/private/datasets/...` + +--- + +## 5. 验收标准(精简版) + +### 5.1 Advanced command +- 提交一个 Advanced PPO command(train/val 使用 `$HOME/common/datasets/...` 或 `$HOME/datasets/...`) +- 确认: + - 任务从 QUEUED → SUBMITTED/RUNNING + - driver 在 worker 上(head 不跑训练) + - 训练能正常跑至少若干 step + +### 5.2 Custom reward(方式 A) +- 用户上传 `$HOME/code/reward.py` +- 在 command 中设置 `custom_reward_function.path=$HOME/code/reward.py` +- 确认训练日志出现 `using customized reward function ...` + +--- + +## 6. 待确认问题(需要你拍板/补充) + +1) Advanced command 的“强约束”是否需要更严格? + - 目前建议要求包含 `python3 -m verl.trainer.`,否则拒绝。 + - 你是否允许用户跑非 verl 的命令(例如自定义评估脚本)? + +2) `$HOME/common/datasets` 与 `$HOME/common/hf` 两个映射目录在平台侧是否需要“强制只读”语义? + - 例如:TaskSpec 校验允许读取但禁止写入(目前设计是 best-effort 字符串级校验)。 + +--- + +## 7. 基于现有源码的改动点分析(实现清单) + +本节按当前 v3.0 已上线的源码结构(`src/mvp/py/argus/...`)逐文件列出 v3.5 需要的具体改动点,并评估对现有能力的影响面。 + +### 7.1 TaskSpec/模型层(解析与兼容) + +**现状** +- Basic TaskSpec 由 `argus.ray.models.JobSpec.from_dict()` 解析:`src/mvp/py/argus/ray/models.py` +- API `/api/v2/tasks` 直接 `JobSpec.from_dict(obj)`,并基于字段做路径校验:`src/mvp/py/argus/service/app.py` +- Scheduler 同样假定 jobspec_yaml 能解析为 `JobSpec`:`src/mvp/py/argus/service/scheduler.py` + +**v3.5 需要新增** +1) 新增 `AdvancedTaskSpec` 数据结构(建议放在 `src/mvp/py/argus/ray/models.py`): + - 必填:`kind: advanced`、`workload`(建议仍要求 ppo/grpo/sft,用于 task_id 命名与 UI 分类)、`nnodes`、`n_gpus_per_node`、`command` + - 可选:`submission_id`(由服务层 override) +2) 新增 “union 解析”: + - 新增 `parse_taskspec(obj: dict) -> Basic(JobSpec) | Advanced(AdvancedTaskSpec)` + - 兼容策略:如果没有 `kind` 字段,则 **默认按 v3.0 Basic JobSpec 解析**(保证老客户端无感)。 + +### 7.2 Builder 层(把 TaskSpec 转为可执行 argv) + +**现状** +- `src/mvp/py/argus/ray/builders.py:build_training_argv(spec: JobSpec, ...)` 只支持模板化 PPO/GRPO/SFT。 + +**v3.5 需要新增** +1) 新增 `build_advanced_argv(command: str) -> list[str]` + - 推荐实现:返回 `["bash", "-lc", ""]` + - 原因:用户 command 允许 `ENV=... python3 ... \` 多行以及 shell 语法,`bash -lc` 兼容性最好。 +2) Driver entrypoint 复用: + - 仍通过 `argus.ray.driver_entrypoint` 执行(统一 job_dir、日志与退出码)。 + +### 7.3 RayJobTool 层(runtime_env 与提交) + +**现状** +- `src/mvp/py/argus/ray/ray_job_tool.py:RayJobTool.submit(spec: JobSpec, ...)`: + - runtime_env 的 `PYTHONPATH` 由 `spec.code_path` 决定 + - entrypoint 固定为 driver_entrypoint + builder 生成 argv + +**v3.5 需要新增** +1) 扩展 submit 支持 AdvancedTaskSpec: + - 方案 A(最小侵入):新增 `submit_advanced(...)` 方法,参数为 `command` + `job_dir` + `submission_id` + `nnodes/n_gpus...` + - 方案 B(统一接口):新增内部抽象 `SubmitPlan`(包含 `runtime_env` + `entrypoint` + `artifacts`),Basic/Advanced 都生成 plan,再走同一 submit 逻辑。 +2) runtime_env 的 code path: + - 因 v3.5 本轮不做“自定义 verl code_path”,建议仍固定使用公共快照(例如 `/private/common/code/verl/verl_repo`)。 + - 为减少散落常量,建议在 config 增加 `ray.verl_code_path`(或 `service.verl_code_path`),RayJobTool 统一读取。 +3) runtime_env 的用户代码目录(可选增强): + - VERL 的自定义 reward 函数是通过 `custom_reward_function.path` 以“文件路径”动态 import 的,理论上不依赖 `PYTHONPATH`。 + - 但用户的 `reward.py` 可能会 `import` 自己目录下的其他模块;为了提升易用性,可将 + `/private/users//code` 追加到 job 的 `PYTHONPATH`。 + - 这需要 RayJobTool.submit/submit_advanced 能感知 `user_id`(由 Scheduler 传入),属于小改动但要注意兼容性。 + +### 7.4 API Server(提交校验、宏替换、spec 展示) + +**现状** +- `POST /api/v2/tasks`:只支持 Basic JobSpec 且强校验 `code_path/train_file/val_file/model_id` 前缀:`src/mvp/py/argus/service/app.py` +- `/api/v2/tasks/{task_id}/spec`:返回 resolved 的 Basic JobSpec(补默认值/补 submission_id):`src/mvp/py/argus/service/app.py` + +**v3.5 需要新增/修改** +1) `POST /api/v2/tasks` 分流: + - `kind != advanced`:走原 Basic 流程(兼容 v3.0) + - `kind == advanced`:走 Advanced 解析 + 校验 +2) Advanced command 宏替换与映射(核心): + - 实现 `expand_command(user_id, command)`: + - 先把 `$HOME/common/datasets` → `/private/datasets` + - 再把 `$HOME/common/hf` → `/private/hf` + - 再把其余 `$HOME` → `/private/users/` + - 校验使用 “展开后的 command” +3) reward 注入检查(仅方式 A): + - 若发现 `custom_reward_function.path=...`: + - 校验展开后的 path 前缀必须是 `/private/users//code/` +4) `/api/v2/tasks/{task_id}/spec`: + - 需要支持返回 AdvancedTaskSpec 的 resolved 版本: + - 展示时可选择“原始 command”(含 `$HOME`)或“展开后的 command”(建议都展示:raw + expanded) + +### 7.5 Scheduler(队列与提交) + +**现状** +- `src/mvp/py/argus/service/scheduler.py` 假定 jobspec_yaml 一定是 Basic JobSpec,并调用 `tool.submit(spec2, ...)`。 + +**v3.5 需要新增** +1) Scheduler 的 `_parse_jobspec` 替换为 `parse_taskspec`(支持 Basic/Advanced)。 +2) `_submit_one` 根据 spec 类型调用: + - Basic:保持现状 `tool.submit(JobSpec, ...)` + - Advanced:调用 `tool.submit_advanced(...)`(或统一 SubmitPlan) + +### 7.6 WebUI(最小改动) + +**现状** +- `src/mvp/py/argus/service/ui.py` 的 New Task 页面只提供 Basic YAML 模板。 + +**v3.5 需要新增** +- 增加 “Advanced Task” 模板按钮: + - `kind: advanced` + - `workload: ppo|grpo|sft`(用于 UI 分类与 task_id) + - `nnodes/n_gpus_per_node` + - `command: | ...`(带中文注释) +- Data 页面文案更新: + - 明确共享目录在 `$HOME/common/datasets`、`$HOME/common/hf`(并解释会映射到 `/private/datasets`、`/private/hf`) + +--- + +## 8. 对现有功能的兼容性影响评估 + +### 8.1 API/TaskSpec 兼容 +- 兼容策略:**没有 `kind` 字段的 YAML 一律按 v3.0 Basic JobSpec 解析**。 + - 现有脚本/客户端(提交 ppo/grpo/sft 的 YAML)无需修改。 +- AdvancedTaskSpec 是新增能力,不影响既有任务状态机/DB。 + +### 8.2 路径策略变更的影响 +风险点:v3.0 的 Basic 任务/模板大量使用 `/private/common/datasets/...`。 + +建议: +- v3.5 实现阶段先保持 “双栈兼容”: + - Basic 继续接受 `/private/common/datasets/...`(旧) + - 同时接受 `/private/datasets/...`(新/真实路径) +- Advanced command 允许用户写 `$HOME/common/datasets/...`,服务层展开为 `/private/datasets/...`(避免虚拟目录不可见问题)。 + +### 8.3 任务执行/调度兼容 +- Scheduler 队列/并发控制(`max_running_tasks`)保持不变。 +- 资源预检查仍只依赖 `nnodes/n_gpus_per_node`,AdvancedTaskSpec 不改变资源模型。 + +### 8.4 安全边界变化 +- Advanced command 引入后,平台从“结构化参数”变成“执行用户命令”,安全边界变宽。 +- 缓解措施(best-effort): + - 强约束要求命令包含 `python3 -m verl.trainer.` + - 基础路径隔离校验(禁止跨用户路径) + - reward 文件路径限制在 `$HOME/code` + +### 8.5 数据库兼容 +- DB schema 不强制变更:仍复用 `tasks.jobspec_yaml` 存储原始 YAML。 +- 若后续需要更强查询/过滤,再考虑增加 `tasks.kind` 字段(可选增量迁移)。 diff --git a/specs/mvp/v3.5/v3.5_dev_plan.md b/specs/mvp/v3.5/v3.5_dev_plan.md new file mode 100644 index 0000000..b05682c --- /dev/null +++ b/specs/mvp/v3.5/v3.5_dev_plan.md @@ -0,0 +1,200 @@ +# MVP v3.5(精简版)开发计划(TDD) + +> 目标:在 v3.0 已有能力基础上,仅新增两项能力: +> 1) **Advanced TaskSpec(自定义 command)** +> 2) **Custom Reward(方式 A:用户自己在 command 里写 `custom_reward_function.*`)** +> +> 设计依据:`specs/mvp/v3.5/v3.5_design.md`(本计划不再扩展 scope)。 + +--- + +## 0. 范围与约束 + +### 0.1 In scope +- 新增 `kind: advanced` 的 TaskSpec:用户提供 `command`,平台做 `$HOME` 宏替换与 best-effort 校验,再提交 Ray Job。 +- Custom Reward:平台仅做 **reward path 校验**(方式 A),不新增结构化字段。 +- `$HOME/common/*` 路径语义支持(关键):用户在 SFTPGo/WebClient 看到的路径能被训练进程正确读取。 + +### 0.2 Out of scope(本轮不做) +- 自定义 verl 版本/代码路径(多版本共存) +- 断点续训(resume from checkpoint) +- IB/RoCEv2/NCCL 专项支持 +- Model Serving +- Node management 改造(v3.0 的 stateless head/worker/watchdog/supervisor 机制保持不变) + +### 0.3 关键路径映射(必须保持一致) +> 说明:SFTPGo 的 `$HOME/common/...` 是 **virtual folder**,训练进程看不到该虚拟路径。 + +提交 Advanced command 前必须展开/映射: +- `$HOME/common/datasets` → `/private/datasets`(只读语义) +- `$HOME/common/hf` → `/private/hf`(只读语义) +- 其余 `$HOME` → `/private/users/` + +并且为兼容历史用法(v3.0): +- Basic TaskSpec 仍接受 `/private/common/datasets/...`、`/private/common/hf/...`(不强制迁移)。 + +--- + +## 1. 测试策略(TDD) + +### 1.1 单元测试优先级 +1) **解析与兼容**:`kind: advanced` 能解析;无 `kind` 仍按 Basic 解析,旧用法不破坏。 +2) **宏替换正确性**:`$HOME` / `$HOME/common/*` 映射严格按约定展开。 +3) **best-effort 校验**:拒绝明显危险/跨用户路径;对 reward path 做 allowlist。 +4) **提交链路**:Scheduler 能识别 Advanced spec 并调用对应的提交方法,确保 submission_id/目录规范不变。 +5) **WebUI/API**:New Task 模板与 `/spec` 展示完整 resolved spec(包含展开后的 command)。 + +### 1.2 本地运行方式 +- 复用已有 `.venv`,执行:`.venv/bin/python -m pytest` +- 若环境没有 pip,使用 uv 的方式参考 v3.0 约定(不在本计划重复)。 + +--- + +## 2. 里程碑划分(每个里程碑可独立验证) + +> 约定:每个里程碑先写测试(失败),再实现代码使测试通过;里程碑结束跑一遍 `pytest`。 + +### M1 — TaskSpec 模型与解析(兼容优先) +**目标** +- 引入 AdvancedTaskSpec 数据结构与 union parser,同时保证 v3.0 Basic 行为不变。 + +**新增/修改(建议位置)** +- `src/mvp/py/argus/ray/models.py` + - 新增 `AdvancedTaskSpec` + - 新增 `parse_taskspec(obj: dict) -> JobSpec | AdvancedTaskSpec` + - 兼容策略:缺省 `kind` → 走 `JobSpec.from_dict` + +**测试(先写)** +- `src/mvp/py/tests/test_models.py` + - `test_parse_taskspec_basic_no_kind_compat()` + - `test_parse_taskspec_advanced_smoke()` + - `test_parse_taskspec_advanced_requires_command_nnodes_gpus()` + +**验收** +- `pytest -q` 通过;旧测试不修改或仅做最小必要更新。 + +--- + +### M2 — Advanced command 展开与校验(核心能力) +**目标** +- 实现 command 展开(含 `$HOME/common/*` 映射)与 best-effort 强约束校验。 + +**实现点(建议新增模块)** +- `src/mvp/py/argus/service/command_expand.py`(或放在 `argus/service/validation.py`) + - `expand_advanced_command(user_id: str, command: str) -> str` + - `validate_advanced_command(user_id: str, expanded_command: str) -> None`(失败抛 `ValueError`) + +**强约束(与设计文档一致)** +- 必须包含 `python3` 且包含 `-m verl.trainer.`(否则 400) +- 禁止出现 `/private/users//...`(跨用户路径) +- 若检测到 `data.train_files=`/`data.val_files=`: + - 只允许 `/private/users//datasets/...` 或 `/private/datasets/...` + - (兼容)允许 `/private/common/datasets/...`(旧路径) +- 若检测到 `custom_reward_function.path=`: + - 只允许 `/private/users//code/...`(展开后校验) + +**测试(先写)** +- 新增:`src/mvp/py/tests/test_advanced_command.py` + - `test_expand_maps_home_common_datasets_to_private_datasets()` + - `test_expand_maps_home_common_hf_to_private_hf()` + - `test_expand_maps_home_to_private_users()` + - `test_validate_rejects_cross_user_paths()` + - `test_validate_requires_verl_trainer_entry()` + - `test_validate_allows_reward_path_under_user_code()` + - `test_validate_rejects_reward_path_outside_user_code()` + +**验收** +- 单测覆盖映射/校验的正反例;错误信息可读(用于 API 400 detail)。 + +--- + +### M3 — Ray 提交链路支持 Advanced(Builder/Tool/Scheduler) +**目标** +- Advanced spec 能进入 scheduler 队列并提交为 Ray job(driver 仍落 worker)。 + +**代码改动点(建议)** +- `src/mvp/py/argus/ray/builders.py` + - 新增 `build_advanced_argv(command: str)`:返回 `["bash","-lc", expanded_command]` +- `src/mvp/py/argus/ray/ray_job_tool.py` + - 新增 `submit_advanced(...)`(或统一成内部 submit plan) + - runtime_env:继续注入公共 verl code path(本轮不支持用户自定义 verl 代码) + - 可选:把 `/private/users//code` 加入 `PYTHONPATH`,提升 reward 代码 `import` 体验 +- `src/mvp/py/argus/service/scheduler.py` + - 使用 `parse_taskspec` 分流 Basic/Advanced + - Advanced 调用 `tool.submit_advanced(...)` + +**测试(先写)** +- `src/mvp/py/tests/test_builders.py` + - `test_build_advanced_argv_uses_bash_lc()` +- `src/mvp/py/tests/test_scheduler.py` + - 新增一个 `kind: advanced` 的任务,断言 scheduler 调用了 `submit_advanced` + - 断言 job_dir/submission_id 规则不变(仍按 `/private/users//jobs/`) +- `src/mvp/py/tests/test_ray_job_tool.py` + - 断言 advanced 提交时 entrypoint 是 driver_entrypoint + `bash -lc ...` + +**验收** +- 单测跑通;Scheduler tick 能完成 Advanced 任务从 QUEUED → SUBMITTED(mock Ray)。 + +--- + +### M4 — API & WebUI(最小功能闭环) +**目标** +- WebUI/HTTP API 能提交 Advanced Task,并在详情页看到 resolved spec(含完整 command)。 + +**API 改动点** +- `src/mvp/py/argus/service/app.py` + - `POST /api/v2/tasks`:支持 `kind: advanced` + - 保存 raw YAML(保持与 Basic 一致) + - 对 Advanced:展开 command + 校验(失败返回 400) + - `GET /api/v2/tasks/{task_id}/spec`: + - 返回 resolved spec(建议同时返回 raw + expanded,或 YAML 中直接给 expanded) + +**WebUI 改动点** +- `src/mvp/py/argus/service/ui.py` + - New Task 页面新增 Advanced 模板(含中文注释) + - 文案强调共享目录:`$HOME/common/datasets`、`$HOME/common/hf` + +**测试(先写)** +- `src/mvp/py/tests/test_app.py` + - `test_create_task_advanced_ok()`(最小 valid command) + - `test_create_task_advanced_rejects_invalid_command()` + - `test_task_spec_endpoint_includes_expanded_command()` +- `src/mvp/py/tests/test_ui.py` + - 断言页面包含 Advanced 示例块 + +**验收** +- `pytest` 通过;浏览器可提交 Advanced YAML 并看到 expanded command。 + +--- + +### M5 — 端到端验证(远端 argus@h1) +**目标** +- 在真实 Ray cluster + VERL 环境下验证 Advanced 与 Custom Reward(方式 A)。 + +**步骤(手工验收脚本化可选)** +1) 启动 v3.0/v3.5 统一的 compose + API(沿用现有 `run_all` 脚本体系) +2) 用户(如 `alice`)通过 SFTP 上传 reward 代码到: + - `$HOME/code/reward.py`(真实路径 `/private/users/alice/code/reward.py`) +3) 通过 WebUI 或 curl 提交 Advanced task: + - `command` 中包含: + - `custom_reward_function.path=$HOME/code/reward.py` + - `custom_reward_function.name=compute_score` + - `data.train_files=$HOME/common/datasets/gsm8k/train.parquet` + - `data.val_files=$HOME/common/datasets/gsm8k/test.parquet` +4) 检查: + - 任务状态从 QUEUED → RUNNING → SUCCEEDED/FAILED(有日志) + - driver 不在 head 上跑(dashboard 验证) + - 日志出现 “custom reward” 生效的提示(按 VERL 实际日志关键字确认) +5) 回归:提交 Basic ppo/grpo/sft 任务仍可运行(确保兼容性) + +**验收** +- Advanced task 能跑至少若干 step,且 reward 注入生效。 +- Basic 任务兼容不回退。 + +--- + +## 3. 风险点与边界(明确写进 PR/变更说明) +- Advanced command 只做 best-effort 校验,不做完整 shell AST 解析;复杂命令可能存在漏检/误判(后续可扩展)。 +- `$HOME/common/*` 是“用户侧语义”,服务层必须映射到真实路径,否则训练必然 FileNotFound。 +- 校验策略(强约束)如果后续要允许非 VERL 命令,需要调整规则并补测试(本轮默认拒绝)。 + diff --git a/src/mvp/py/argus/ray/builders.py b/src/mvp/py/argus/ray/builders.py index 6bb7dfe..9f03dd9 100644 --- a/src/mvp/py/argus/ray/builders.py +++ b/src/mvp/py/argus/ray/builders.py @@ -97,3 +97,12 @@ def build_training_argv(spec: JobSpec, submission_id: str, job_dir: str) -> Buil return BuiltCommand(argv=argv) raise ValueError(f"unsupported workload: {spec.workload}") + + +def build_advanced_argv(command: str) -> BuiltCommand: + """ + Execute an arbitrary user-provided command under bash for maximum shell compatibility + (multiline, env vars, line continuations, etc.). + """ + + return BuiltCommand(argv=["bash", "-lc", command]) diff --git a/src/mvp/py/argus/ray/driver_entrypoint.py b/src/mvp/py/argus/ray/driver_entrypoint.py index 9f99c58..5088405 100644 --- a/src/mvp/py/argus/ray/driver_entrypoint.py +++ b/src/mvp/py/argus/ray/driver_entrypoint.py @@ -35,6 +35,8 @@ def main() -> int: job_dir = Path(args.job_dir) job_dir.mkdir(parents=True, exist_ok=True) + os.environ.setdefault("MVP_JOB_DIR", str(job_dir)) + os.chdir(job_dir) _preflight() diff --git a/src/mvp/py/argus/ray/models.py b/src/mvp/py/argus/ray/models.py index 4a4e74e..cd08d24 100644 --- a/src/mvp/py/argus/ray/models.py +++ b/src/mvp/py/argus/ray/models.py @@ -18,6 +18,7 @@ class RayConfig: entrypoint_resources: dict[str, float] runtime_env_env_vars: dict[str, str] user_code_path: str + verl_code_path: str @staticmethod def from_dict(d: dict[str, Any]) -> "RayConfig": @@ -42,6 +43,7 @@ class RayConfig: entrypoint_resources={str(k): float(v) for k, v in entrypoint_resources.items()}, runtime_env_env_vars={str(k): str(v) for k, v in env_vars.items()}, user_code_path=str(d.get("user_code_path", f"{_require(d, 'shared_root')}/user/code")), + verl_code_path=str(d.get("verl_code_path", f"{_require(d, 'shared_root')}/common/code/verl/verl_repo")), ) def to_public_dict(self) -> dict[str, Any]: @@ -52,6 +54,7 @@ class RayConfig: "entrypoint_resources": self.entrypoint_resources, "runtime_env": {"env_vars": self.runtime_env_env_vars}, "user_code_path": self.user_code_path, + "verl_code_path": self.verl_code_path, } @@ -126,3 +129,55 @@ class JobSpec: if self.workload == "sft": out["trainer_device"] = self.trainer_device or "cpu" return out + + +@dataclass(frozen=True) +class AdvancedTaskSpec: + kind: str # advanced + workload: str # always "advanced" for classification/ID prefix + submission_id: str | None + nnodes: int + n_gpus_per_node: int + command: str + + @staticmethod + def from_dict(d: dict[str, Any]) -> "AdvancedTaskSpec": + kind = str(_require(d, "kind")) + if kind != "advanced": + raise ValueError(f"unsupported taskspec kind: {kind}") + + command = str(_require(d, "command")) + + return AdvancedTaskSpec( + kind=kind, + workload="advanced", + submission_id=(str(d["submission_id"]) if d.get("submission_id") else None), + nnodes=int(_require(d, "nnodes")), + n_gpus_per_node=int(_require(d, "n_gpus_per_node")), + command=command, + ) + + def to_public_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "workload": self.workload, + "submission_id": self.submission_id or "", + "nnodes": self.nnodes, + "n_gpus_per_node": self.n_gpus_per_node, + "command": self.command, + } + + +def parse_taskspec(d: dict[str, Any]) -> JobSpec | AdvancedTaskSpec: + """ + v3.x compatibility rule: + - If no "kind": treat as legacy/basic JobSpec. + - If kind == "advanced": parse AdvancedTaskSpec. + """ + + kind = d.get("kind") + if kind in (None, ""): + return JobSpec.from_dict(d) + if kind == "advanced": + return AdvancedTaskSpec.from_dict(d) + raise ValueError(f"unsupported taskspec kind: {kind}") diff --git a/src/mvp/py/argus/ray/ray_job_tool.py b/src/mvp/py/argus/ray/ray_job_tool.py index bff2f3e..a0077a2 100644 --- a/src/mvp/py/argus/ray/ray_job_tool.py +++ b/src/mvp/py/argus/ray/ray_job_tool.py @@ -10,8 +10,8 @@ from typing import Any import ray from ray.job_submission import JobSubmissionClient -from .builders import build_training_argv -from .models import JobSpec, RayConfig +from .builders import build_advanced_argv, build_training_argv +from .models import AdvancedTaskSpec, JobSpec, RayConfig from .yaml_io import dump_yaml @@ -45,6 +45,9 @@ class RayJobTool: return f"{self.cfg.shared_root}/jobs/{submission_id}" def _runtime_env(self, spec: JobSpec) -> dict[str, Any]: + return self._runtime_env_for(code_path=spec.code_path, workload=spec.workload) + + def _runtime_env_for(self, *, code_path: str, workload: str, extra_pythonpath: list[str] | None = None) -> dict[str, Any]: env_vars = dict(self.cfg.runtime_env_env_vars) # Default HF cache @@ -57,18 +60,18 @@ class RayJobTool: # Place it before verl code to avoid interfering with verl import priority. tool_code_path = os.environ.get("MVP_TOOL_CODE_PATH", "/workspace/mvp/py") - user_code_path = self.cfg.user_code_path - code_path = spec.code_path - existing = env_vars.get("PYTHONPATH", "") - prefix = f"{tool_code_path}:{code_path}:{user_code_path}" + base_parts = [tool_code_path, code_path, self.cfg.user_code_path] + if extra_pythonpath: + base_parts.extend(extra_pythonpath) + prefix = ":".join(base_parts) env_vars["PYTHONPATH"] = f"{prefix}:{existing}" if existing else prefix # For debugging / log visibility env_vars["MVP_CODE_PATH"] = code_path # SFT: ensure ray.init() connects to the cluster - if spec.workload == "sft": + if workload == "sft": env_vars.setdefault("RAY_ADDRESS", "auto") return {"env_vars": env_vars} @@ -146,6 +149,91 @@ class RayJobTool: return submitted + def submit_advanced( + self, + spec: AdvancedTaskSpec, + no_wait: bool, + job_dir: str | None = None, + user_id: str | None = None, + ) -> str: + submission_id = spec.submission_id or f"mvp11_{spec.workload}_{_ts()}_{os.getpid()}" + job_dir = job_dir or self._job_dir(submission_id) + + built = build_advanced_argv(spec.command) + entrypoint_argv = [ + "python3", + "-m", + "argus.ray.driver_entrypoint", + "--job-dir", + job_dir, + *built.argv, + ] + entrypoint = " ".join(shlex.quote(x) for x in entrypoint_argv) + + extra_pythonpath: list[str] = [] + if user_id: + extra_pythonpath.append(f"{self.cfg.shared_root}/users/{user_id}/code") + + runtime_env = self._runtime_env_for(code_path=self.cfg.verl_code_path, workload=spec.workload, extra_pythonpath=extra_pythonpath) + + # Prepare job artifacts directory + job_root = Path(job_dir) + _mkdir(job_root / "config") + _mkdir(job_root / "logs") + _mkdir(job_root / "debug") + _mkdir(job_root / "checkpoints") + + _write_text(job_root / "config" / "ray_config.yaml", dump_yaml(self.cfg.to_public_dict())) + _write_text(job_root / "config" / "taskspec.yaml", dump_yaml(spec.to_public_dict())) + _write_json( + job_root / "config" / "submit_payload.json", + { + "submission_id": submission_id, + "address": self.cfg.address, + "entrypoint": entrypoint, + "entrypoint_num_cpus": self.cfg.entrypoint_num_cpus, + "entrypoint_resources": self.cfg.entrypoint_resources, + "runtime_env": runtime_env, + }, + ) + + # Pre-submit debug snapshot (ray cluster resources via ray.init) + try: + ray.init(address="auto", ignore_reinit_error=True, log_to_driver=False) + _write_json(job_root / "debug" / "ray_cluster_resources_pre.json", ray.cluster_resources()) + _write_json(job_root / "debug" / "ray_available_resources_pre.json", ray.available_resources()) + except Exception as e: + _write_text(job_root / "debug" / "ray_resources_pre.error.txt", repr(e) + "\n") + + try: + submitted = self.client.submit_job( + entrypoint=entrypoint, + submission_id=submission_id, + runtime_env=runtime_env, + entrypoint_num_cpus=self.cfg.entrypoint_num_cpus, + entrypoint_resources=self.cfg.entrypoint_resources, + ) + except Exception as e: + _write_text(job_root / "logs" / "submit.error.txt", repr(e) + "\n") + raise + + _write_text(job_root / "config" / "ray_submission_id.txt", submitted + "\n") + + # Post-submit debug snapshot via SDK + try: + jobs = self.client.list_jobs() + _write_text( + job_root / "debug" / "ray_job_list_post.json", + json.dumps([_job_details_to_dict(j) for j in jobs], indent=2) + "\n", + ) + except Exception as e: + _write_text(job_root / "debug" / "ray_job_list_post.error.txt", repr(e) + "\n") + + if not no_wait: + pass + + return submitted + def status(self, submission_id: str) -> str: return str(self.client.get_job_status(submission_id)) diff --git a/src/mvp/py/argus/service/advanced_command.py b/src/mvp/py/argus/service/advanced_command.py new file mode 100644 index 0000000..4ee699a --- /dev/null +++ b/src/mvp/py/argus/service/advanced_command.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import re + + +_RE_PRIVATE_USER = re.compile(r"/private/users/([^/\s\"']+)") +_RE_TRAIN_FILES = re.compile(r"data\.train_files=([^\s\\]+)") +_RE_VAL_FILES = re.compile(r"data\.val_files=([^\s\\]+)") +_RE_REWARD_PATH = re.compile(r"custom_reward_function\.path=([^\s\\]+)") + + +def _strip_quotes(s: str) -> str: + s = s.strip() + if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'): + return s[1:-1] + return s + + +def expand_advanced_command(*, user_id: str, command: str) -> str: + """ + Expand user-facing macros into real container paths. + + Important: `$HOME/common/...` is a SFTPGo virtual folder; the training process + cannot see it. We map those paths to real shared mounts. + """ + + home = f"/private/users/{user_id}" + expanded = command + expanded = expanded.replace("$HOME/common/datasets", "/private/datasets") + expanded = expanded.replace("$HOME/common/hf", "/private/hf") + expanded = expanded.replace("$HOME", home) + return expanded + + +def validate_advanced_command(*, user_id: str, expanded_command: str) -> None: + """ + Best-effort validation for advanced commands. + + Strong constraints (raise ValueError on failure): + - Must include a python3 VERL trainer entrypoint + - Must not reference other users' /private/users//... paths + - If train/val files are present, must be under allowed roots + - If reward path is present, must be under /private/users//code/... + """ + + if "python3" not in expanded_command or "-m verl.trainer." not in expanded_command: + raise ValueError("command must include 'python3' and '-m verl.trainer.'") + + for other in _RE_PRIVATE_USER.findall(expanded_command): + if other != user_id: + raise ValueError(f"command references another user's path: /private/users/{other}/...") + + allowed_dataset_prefixes = ( + f"/private/users/{user_id}/datasets/", + "/private/datasets/", + # Backward compatible with v3.0 paths used by existing templates/users. + "/private/common/datasets/", + ) + + for m in _RE_TRAIN_FILES.finditer(expanded_command): + path = _strip_quotes(m.group(1)) + if not path.startswith(allowed_dataset_prefixes): + raise ValueError(f"train_file must be under allowed roots, got: {path}") + + for m in _RE_VAL_FILES.finditer(expanded_command): + path = _strip_quotes(m.group(1)) + if not path.startswith(allowed_dataset_prefixes): + raise ValueError(f"val_file must be under allowed roots, got: {path}") + + for m in _RE_REWARD_PATH.finditer(expanded_command): + path = _strip_quotes(m.group(1)) + allow = f"/private/users/{user_id}/code/" + if not path.startswith(allow): + raise ValueError(f"reward path must be under {allow}, got: {path}") + diff --git a/src/mvp/py/argus/service/app.py b/src/mvp/py/argus/service/app.py index 6d9c0f9..7bcb2f0 100644 --- a/src/mvp/py/argus/service/app.py +++ b/src/mvp/py/argus/service/app.py @@ -9,8 +9,9 @@ import yaml from fastapi import FastAPI, HTTPException, Request, Response from argus.core.ids import new_task_id -from argus.ray.models import JobSpec, RayConfig +from argus.ray.models import AdvancedTaskSpec, JobSpec, RayConfig, parse_taskspec +from .advanced_command import expand_advanced_command, validate_advanced_command from .config import V2Config from .db import Db from .janitor import JobsJanitor @@ -316,23 +317,52 @@ def create_app(config_path: str) -> FastAPI: async def submit_task(req: Request) -> dict[str, Any]: subject = _auth(req) body = (await req.body()).decode("utf-8") - obj = yaml.safe_load(body) or {} + try: + obj = yaml.safe_load(body) or {} + except Exception as e: + # yaml.YAMLError and similar should be a user-facing 400, not a 500. + raise HTTPException(status_code=400, detail=f"invalid YAML: {e!r}") if not isinstance(obj, dict): raise HTTPException(status_code=400, detail="jobspec must be a YAML mapping") try: - spec = JobSpec.from_dict(obj) + spec = parse_taskspec(obj) except Exception as e: raise HTTPException(status_code=400, detail=f"invalid jobspec: {e!r}") - # v3.0 path policy: + root = ray_cfg.shared_root.rstrip("/") + user_id = str(subject["user_id"]).strip() + task_id = new_task_id(str(spec.workload), user_id=user_id) + + # v3.5: Advanced TaskSpec (custom command) + if isinstance(spec, AdvancedTaskSpec): + expanded = expand_advanced_command(user_id=user_id, command=spec.command) + try: + validate_advanced_command(user_id=user_id, expanded_command=expanded) + except Exception as e: + raise HTTPException(status_code=400, detail=f"invalid command: {e!r}") + + stored = spec.to_public_dict() + stored["command"] = expanded + stored_yaml = yaml.safe_dump(stored, sort_keys=False) + + db.create_task_v25( + task_id=task_id, + user_id=user_id, + workload=spec.workload, + jobspec_yaml=stored_yaml, + nnodes=spec.nnodes, + n_gpus_per_node=spec.n_gpus_per_node, + ) + return {"task_id": task_id, "state": "QUEUED"} + + # v3.0 path policy (Basic JobSpec): # - code_path: only allow /private/common/... # - train/val: allow /private/common/datasets/... OR /private/users//datasets/... # - model_id: if it looks like a local path (/private/...), allow only models dirs: # /private/common/models/... OR /private/users//models/... - root = ray_cfg.shared_root.rstrip("/") common_prefix = f"{root}/common/" - user_prefix = f"{root}/users/{str(subject['user_id']).strip()}/" + user_prefix = f"{root}/users/{user_id}/" common_datasets_prefix = f"{common_prefix}datasets/" user_datasets_prefix = f"{user_prefix}datasets/" @@ -360,15 +390,17 @@ def create_app(config_path: str) -> FastAPI: detail=f"model_id local path must start with {common_models_prefix} or {user_models_prefix}", ) - task_id = new_task_id(spec.workload, user_id=str(subject["user_id"])) - db.create_task_v25( - task_id=task_id, - user_id=str(subject["user_id"]), - workload=spec.workload, - jobspec_yaml=body, - nnodes=spec.nnodes, - n_gpus_per_node=spec.n_gpus_per_node, - ) + try: + db.create_task_v25( + task_id=task_id, + user_id=user_id, + workload=spec.workload, + jobspec_yaml=body, + nnodes=spec.nnodes, + n_gpus_per_node=spec.n_gpus_per_node, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"db create task failed: {e!r}") return {"task_id": task_id, "state": "QUEUED"} @app.get("/api/v2/tasks/{task_id}") @@ -428,7 +460,7 @@ def create_app(config_path: str) -> FastAPI: obj = yaml.safe_load(raw) or {} if not isinstance(obj, dict): raise ValueError("jobspec must be a YAML mapping") - spec = JobSpec.from_dict(obj) + spec = parse_taskspec(obj) resolved = spec.to_public_dict() attempts = db.list_attempts(task_id) if attempts: diff --git a/src/mvp/py/argus/service/scheduler.py b/src/mvp/py/argus/service/scheduler.py index 0e57253..c9b975e 100644 --- a/src/mvp/py/argus/service/scheduler.py +++ b/src/mvp/py/argus/service/scheduler.py @@ -9,7 +9,7 @@ from typing import Any import yaml from argus.core.ids import attempt_submission_id -from argus.ray.models import JobSpec, RayConfig +from argus.ray.models import AdvancedTaskSpec, JobSpec, RayConfig, parse_taskspec from argus.ray.ray_job_tool import RayJobTool from .config import V2Config @@ -48,11 +48,11 @@ class Scheduler: required = float(nnodes * n_gpus_per_node) return avail.total_available_gpus >= required - def _parse_jobspec(self, jobspec_yaml: str) -> JobSpec: + def _parse_taskspec(self, jobspec_yaml: str) -> JobSpec | AdvancedTaskSpec: obj = yaml.safe_load(jobspec_yaml) or {} if not isinstance(obj, dict): raise ValueError("jobspec must be a YAML mapping") - return JobSpec.from_dict(obj) + return parse_taskspec(obj) def _submit_one(self, task_row: dict[str, Any]) -> None: task_id = str(task_row["task_id"]) @@ -60,7 +60,7 @@ class Scheduler: user_id = task_row.get("user_id") user_id_s = str(user_id) if user_id not in (None, "") else None - spec = self._parse_jobspec(jobspec_yaml) + spec = self._parse_taskspec(jobspec_yaml) attempt_no = int(task_row.get("latest_attempt_no", 0)) + 1 ray_sid = attempt_submission_id(task_id, attempt_no) job_dir = self._job_dir_for_task(user_id=user_id_s, ray_submission_id=ray_sid) @@ -69,13 +69,20 @@ class Scheduler: self.db.create_attempt(task_id=task_id, attempt_no=attempt_no, ray_submission_id=ray_sid) self.db.set_task_state(task_id=task_id, state="SUBMITTING", latest_attempt_no=attempt_no) - # Override submission_id in jobspec (v1.1 compatible) - d = spec.to_public_dict() - d["submission_id"] = ray_sid - spec2 = JobSpec.from_dict(d) + # Override submission_id in taskspec (v1.1 compatible) + if isinstance(spec, JobSpec): + d = spec.to_public_dict() + d["submission_id"] = ray_sid + spec2 = JobSpec.from_dict(d) + submit = lambda: self.tool.submit(spec2, no_wait=True, job_dir=job_dir) + else: + d = spec.to_public_dict() + d["submission_id"] = ray_sid + spec2 = AdvancedTaskSpec.from_dict(d) + submit = lambda: self.tool.submit_advanced(spec2, no_wait=True, job_dir=job_dir, user_id=user_id_s) try: - submitted = self.tool.submit(spec2, no_wait=True, job_dir=job_dir) + submitted = submit() # submitted should equal ray_sid; keep as source of truth. self.db.update_attempt(task_id=task_id, attempt_no=attempt_no, ray_status="SUBMITTED") self.db.set_task_state(task_id=task_id, state="SUBMITTED") diff --git a/src/mvp/py/argus/service/ui.py b/src/mvp/py/argus/service/ui.py index f8172f2..133a3a8 100644 --- a/src/mvp/py/argus/service/ui.py +++ b/src/mvp/py/argus/service/ui.py @@ -329,16 +329,71 @@ val_file: /private/common/datasets/gsm8k_sft/test.parquet # 验证数据(必 # trainer_device: cpu # 仅 SFT 生效:driver 侧 device(可选,默认:cpu) # submission_id: "" # Ray submission_id(可选,默认空;通常由服务自动生成,无需填写) +""".strip() + adv = """# Advanced TaskSpec (YAML) - v3.5 +kind: advanced # 任务类型(必填):advanced(自定义 command) +# 说明:v3.5 中 Advanced 任务不会按 ppo/grpo/sft 分类;平台统一按 "advanced" 做任务分类与 task_id 命名。 + +nnodes: 2 # 训练节点数(必填):用于平台队列调度与资源预检查 +n_gpus_per_node: 4 # 每节点 GPU 数(必填):用于平台队列调度与资源预检查 + +# 自定义训练命令(必填):平台会做 $HOME 宏替换: +# - $HOME -> /private/users/ +# - $HOME/common/datasets -> /private/datasets(共享只读数据) +# - $HOME/common/hf -> /private/hf(共享只读 HF cache) +command: | + # 注意:PPO 需要一些关键参数,否则 VERL 会在启动前 fail-fast(例如 actor 的 micro batch)。 + PYTHONUNBUFFERED=1 \ + python3 -m verl.trainer.main_ppo \ + data.train_files=$HOME/common/datasets/gsm8k/train.parquet \ + data.val_files=$HOME/common/datasets/gsm8k/test.parquet \ + data.train_batch_size=256 \ + data.max_prompt_length=512 \ + data.max_response_length=512 \ + actor_rollout_ref.model.path=Qwen/Qwen2.5-0.5B-Instruct \ + actor_rollout_ref.actor.optim.lr=1e-6 \ + actor_rollout_ref.actor.ppo_mini_batch_size=64 \ + actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=4 \ + actor_rollout_ref.rollout.name=sglang \ + actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=8 \ + actor_rollout_ref.rollout.tensor_model_parallel_size=1 \ + actor_rollout_ref.rollout.gpu_memory_utilization=0.4 \ + actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=4 \ + critic.optim.lr=1e-5 \ + critic.model.path=Qwen/Qwen2.5-0.5B-Instruct \ + critic.ppo_micro_batch_size_per_gpu=4 \ + algorithm.kl_ctrl.kl_coef=0.001 \ + trainer.logger=console \ + trainer.val_before_train=False \ + trainer.nnodes=2 \ + trainer.n_gpus_per_node=4 \ + trainer.total_epochs=1 \ + trainer.total_training_steps=10 \ + trainer.save_freq=10 \ + trainer.test_freq=-1 \ + trainer.resume_mode=disable \ + trainer.default_local_dir=checkpoints \ + +ray_kwargs.ray_init.address=auto \ + hydra.run.dir=logs/hydra + +# 可选:自定义 reward(方式 A:直接写在 command 里) +# command 里增加如下 overrides: +# custom_reward_function.path=$HOME/code/reward.py +# custom_reward_function.name=compute_score """.strip() body = f"""

New Task

-
Paste TaskSpec YAML and submit to API server. Note: code_path is required (v3.0 does not execute user code; use the common snapshot).
+
+ Paste TaskSpec YAML and submit to API server. + Basic tasks require code_path; Advanced tasks use kind: advanced with a custom command. +
+
@@ -354,6 +409,7 @@ val_file: /private/common/datasets/gsm8k_sft/test.parquet # 验证数据(必 tpl_ppo = json.dumps(ppo) tpl_grpo = json.dumps(grpo) tpl_sft = json.dumps(sft) + tpl_adv = json.dumps(adv) script = ( """ const msg = document.getElementById("msg"); @@ -361,9 +417,11 @@ const yamlEl = document.getElementById("yaml"); const TPL_PPO = __TPL_PPO__; const TPL_GRPO = __TPL_GRPO__; const TPL_SFT = __TPL_SFT__; +const TPL_ADV = __TPL_ADV__; document.getElementById("tpl-ppo").onclick = () => { yamlEl.value = TPL_PPO; msg.textContent = ""; }; document.getElementById("tpl-grpo").onclick = () => { yamlEl.value = TPL_GRPO; msg.textContent = ""; }; document.getElementById("tpl-sft").onclick = () => { yamlEl.value = TPL_SFT; msg.textContent = ""; }; +document.getElementById("tpl-adv").onclick = () => { yamlEl.value = TPL_ADV; msg.textContent = ""; }; document.getElementById("submit").onclick = async () => { msg.textContent = "Submitting..."; const body = yamlEl.value; @@ -378,6 +436,7 @@ document.getElementById("submit").onclick = async () => { .replace("__TPL_PPO__", tpl_ppo) .replace("__TPL_GRPO__", tpl_grpo) .replace("__TPL_SFT__", tpl_sft) + .replace("__TPL_ADV__", tpl_adv) ) return HTMLResponse(content=_page("New Task", "new", body, script)) diff --git a/src/mvp/py/tests/test_advanced_command.py b/src/mvp/py/tests/test_advanced_command.py new file mode 100644 index 0000000..118e848 --- /dev/null +++ b/src/mvp/py/tests/test_advanced_command.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest + +from argus.service.advanced_command import expand_advanced_command, validate_advanced_command + + +def test_expand_maps_home_common_datasets_to_private_datasets(): + cmd = "python3 -m verl.trainer.main_ppo data.train_files=$HOME/common/datasets/gsm8k/train.parquet" + expanded = expand_advanced_command(user_id="alice", command=cmd) + assert "/private/datasets/gsm8k/train.parquet" in expanded + assert "$HOME/common/datasets" not in expanded + + +def test_expand_maps_home_common_hf_to_private_hf(): + cmd = "python3 -m verl.trainer.main_ppo HF_HOME=$HOME/common/hf" + expanded = expand_advanced_command(user_id="alice", command=cmd) + assert "HF_HOME=/private/hf" in expanded + + +def test_expand_maps_home_to_private_users(): + cmd = "python3 -m verl.trainer.main_ppo data.train_files=$HOME/datasets/x.parquet" + expanded = expand_advanced_command(user_id="alice", command=cmd) + assert "data.train_files=/private/users/alice/datasets/x.parquet" in expanded + + +def test_validate_requires_verl_trainer_entry(): + with pytest.raises(ValueError, match="verl\\.trainer"): + validate_advanced_command(user_id="alice", expanded_command="echo hi") + + +def test_validate_rejects_cross_user_paths(): + cmd = "python3 -m verl.trainer.main_ppo x=/private/users/bob/datasets/x" + with pytest.raises(ValueError, match="another user's path"): + validate_advanced_command(user_id="alice", expanded_command=cmd) + + +def test_validate_allows_train_val_under_allowed_roots(): + cmd = ( + "python3 -m verl.trainer.main_ppo " + "data.train_files=/private/datasets/gsm8k/train.parquet " + "data.val_files=/private/users/alice/datasets/gsm8k/test.parquet" + ) + validate_advanced_command(user_id="alice", expanded_command=cmd) + + +def test_validate_rejects_train_val_outside_allowed_roots(): + cmd = ( + "python3 -m verl.trainer.main_ppo " + "data.train_files=/etc/passwd " + "data.val_files=/private/datasets/gsm8k/test.parquet" + ) + with pytest.raises(ValueError, match="train_file must be under allowed roots"): + validate_advanced_command(user_id="alice", expanded_command=cmd) + + +def test_validate_allows_reward_path_under_user_code(): + cmd = ( + "python3 -m verl.trainer.main_ppo " + "custom_reward_function.path=/private/users/alice/code/reward.py " + "custom_reward_function.name=compute_score" + ) + validate_advanced_command(user_id="alice", expanded_command=cmd) + + +def test_validate_rejects_reward_path_outside_user_code(): + cmd = ( + "python3 -m verl.trainer.main_ppo " + "custom_reward_function.path=/private/datasets/reward.py " + "custom_reward_function.name=compute_score" + ) + with pytest.raises(ValueError, match="reward path must be under"): + validate_advanced_command(user_id="alice", expanded_command=cmd) + diff --git a/src/mvp/py/tests/test_app.py b/src/mvp/py/tests/test_app.py index 175a89b..fd97d19 100644 --- a/src/mvp/py/tests/test_app.py +++ b/src/mvp/py/tests/test_app.py @@ -185,6 +185,29 @@ def test_submit_rejects_non_mapping_jobspec(tmp_path: Path, monkeypatch): assert r.status_code == 400 +def test_submit_rejects_invalid_yaml_returns_400(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + with TestClient(app) as c: + # YAML parse error: unclosed quote + r = c.post("/api/v2/tasks", headers={"authorization": "Bearer token1"}, data="workload: \"ppo\n") + assert r.status_code == 400 + assert "invalid YAML" in r.text + + def test_submit_rejects_invalid_jobspec(tmp_path: Path, monkeypatch): from argus.service import app as app_mod @@ -206,6 +229,99 @@ def test_submit_rejects_invalid_jobspec(tmp_path: Path, monkeypatch): assert r.status_code == 400 +def test_submit_advanced_ok_and_command_expanded(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token") + monkeypatch.setattr(app_mod, "new_task_id", lambda workload, **kwargs: "tid_adv") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + # seed user + token + from argus.service.config import V2Config + from argus.service.db import Db + + root = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + v2_cfg = V2Config.from_root_dict(root) + db = Db(v2_cfg.sqlite.db_path) + db.create_user(user_id="u1", display_name=None) + token = db.issue_token(user_id="u1") + + spec = """ +kind: advanced +workload: ppo +nnodes: 2 +n_gpus_per_node: 4 +command: | + python3 -m verl.trainer.main_ppo \\ + data.train_files=$HOME/common/datasets/gsm8k/train.parquet \\ + data.val_files=$HOME/datasets/gsm8k/test.parquet \\ + +ray_kwargs.ray_init.address=auto +""".lstrip() + + with TestClient(app) as c: + r = c.post("/api/v2/tasks", headers={"authorization": f"Bearer {token}"}, data=spec) + assert r.status_code == 200 + assert r.json()["task_id"] == "tid_adv" + + r2 = c.get("/api/v2/tasks/tid_adv/spec", headers={"authorization": f"Bearer {token}"}) + assert r2.status_code == 200 + assert "kind: advanced" in r2.text + # Expanded mapping for SFTPGo "virtual common" folders: + assert "/private/datasets/gsm8k/train.parquet" in r2.text + assert "/private/users/u1/datasets/gsm8k/test.parquet" in r2.text + + +def test_submit_advanced_rejects_invalid_command(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + # seed user + token + from argus.service.config import V2Config + from argus.service.db import Db + + root = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + v2_cfg = V2Config.from_root_dict(root) + db = Db(v2_cfg.sqlite.db_path) + db.create_user(user_id="u1", display_name=None) + token = db.issue_token(user_id="u1") + + bad = """ +kind: advanced +workload: ppo +nnodes: 2 +n_gpus_per_node: 4 +command: | + echo hello +""".lstrip() + + with TestClient(app) as c: + r = c.post("/api/v2/tasks", headers={"authorization": f"Bearer {token}"}, data=bad) + assert r.status_code == 400 + assert "invalid command" in r.text + + def test_me_sftp_reset_password_disabled_returns_400(tmp_path: Path, monkeypatch): from argus.service import app as app_mod diff --git a/src/mvp/py/tests/test_builders.py b/src/mvp/py/tests/test_builders.py index 8704836..74f48d7 100644 --- a/src/mvp/py/tests/test_builders.py +++ b/src/mvp/py/tests/test_builders.py @@ -56,3 +56,10 @@ def test_build_training_argv_unsupported_raises(): spec = _base_spec("bad") with pytest.raises(ValueError, match="unsupported workload"): build_training_argv(spec, submission_id="sid", job_dir="/job") + + +def test_build_advanced_argv_uses_bash_lc(): + from argus.ray.builders import build_advanced_argv + + built = build_advanced_argv("echo hi") + assert built.argv[:2] == ["bash", "-lc"] diff --git a/src/mvp/py/tests/test_driver_entrypoint.py b/src/mvp/py/tests/test_driver_entrypoint.py index 34046c6..4902bbf 100644 --- a/src/mvp/py/tests/test_driver_entrypoint.py +++ b/src/mvp/py/tests/test_driver_entrypoint.py @@ -18,8 +18,10 @@ def test_driver_entrypoint_strips_double_dash_and_returns_code(monkeypatch, tmp_ returncode = 7 monkeypatch.setattr(mod.subprocess, "run", lambda cmd, check: _Proc()) + got = {} + monkeypatch.setattr(mod.os, "chdir", lambda p: got.update({"cwd": str(p)})) monkeypatch.setattr(sys, "argv", ["x", "--job-dir", str(tmp_path), "--", "echo", "hi"]) assert mod.main() == 7 assert (tmp_path).exists() - + assert got["cwd"] == str(tmp_path) diff --git a/src/mvp/py/tests/test_models.py b/src/mvp/py/tests/test_models.py index 68a4fd0..52406e9 100644 --- a/src/mvp/py/tests/test_models.py +++ b/src/mvp/py/tests/test_models.py @@ -31,6 +31,7 @@ def test_ray_config_from_dict_new_format_and_defaults(): assert cfg.entrypoint_resources["worker_node"] == 1.0 assert cfg.runtime_env_env_vars["HF_ENDPOINT"] == "x" assert cfg.user_code_path == "/private/user/code" + assert cfg.verl_code_path == "/private/common/code/verl/verl_repo" public = cfg.to_public_dict() assert public["runtime_env"]["env_vars"]["HF_ENDPOINT"] == "x" @@ -106,3 +107,50 @@ def test_jobspec_unsupported_workload(): JobSpec.from_dict( {"workload": "nope", "code_path": "x", "model_id": "m", "train_file": "t", "val_file": "v"} ) + + +def test_parse_taskspec_basic_no_kind_compat(): + from argus.ray.models import JobSpec, parse_taskspec + + got = parse_taskspec( + { + "workload": "ppo", + "code_path": "/code", + "model_id": "m", + "train_file": "train.jsonl", + "val_file": "val.jsonl", + } + ) + assert isinstance(got, JobSpec) + assert got.workload == "ppo" + + +def test_parse_taskspec_advanced_smoke(): + from argus.ray.models import AdvancedTaskSpec, parse_taskspec + + got = parse_taskspec( + { + "kind": "advanced", + "nnodes": 2, + "n_gpus_per_node": 4, + "command": "python3 -m verl.trainer.main_ppo +ray_kwargs.ray_init.address=auto", + } + ) + assert isinstance(got, AdvancedTaskSpec) + assert got.kind == "advanced" + assert got.workload == "advanced" + assert got.nnodes == 2 + assert got.n_gpus_per_node == 4 + + +def test_parse_taskspec_advanced_requires_command_nnodes_gpus(): + from argus.ray.models import parse_taskspec + + with pytest.raises(ValueError, match="missing required field: command"): + parse_taskspec({"kind": "advanced", "nnodes": 1, "n_gpus_per_node": 1}) + + with pytest.raises(ValueError, match="missing required field: nnodes"): + parse_taskspec({"kind": "advanced", "n_gpus_per_node": 1, "command": "python3 -m verl.trainer.main_ppo"}) + + with pytest.raises(ValueError, match="missing required field: n_gpus_per_node"): + parse_taskspec({"kind": "advanced", "nnodes": 1, "command": "python3 -m verl.trainer.main_ppo"}) diff --git a/src/mvp/py/tests/test_ray_job_tool.py b/src/mvp/py/tests/test_ray_job_tool.py index 15390cf..562392f 100644 --- a/src/mvp/py/tests/test_ray_job_tool.py +++ b/src/mvp/py/tests/test_ray_job_tool.py @@ -161,3 +161,58 @@ def test_submit_error_writes_file_then_reraises(tmp_path: Path, monkeypatch): err = tmp_path / "jobs" / "sid2" / "logs" / "submit.error.txt" assert err.exists() + + +def test_submit_advanced_writes_artifacts_and_returns_submission_id(tmp_path: Path, monkeypatch): + from argus.ray import ray_job_tool as mod + + class _FakeClient: + def __init__(self, address: str): + self.address = address + self.last_submit_kwargs = None + + def submit_job(self, **kwargs): + self.last_submit_kwargs = dict(kwargs) + return str(kwargs["submission_id"]) + + def list_jobs(self): + class X: + def dict(self): + return {"ok": True} + + return [X()] + + monkeypatch.setattr(mod, "JobSubmissionClient", _FakeClient) + monkeypatch.setattr(mod.ray, "init", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no ray"))) + + cfg = RayConfig.from_dict( + { + "address": "http://127.0.0.1:8265", + "shared_root": str(tmp_path), + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {}}, + } + ) + spec = mod.AdvancedTaskSpec.from_dict( + { + "kind": "advanced", + "submission_id": "sid3", + "nnodes": 2, + "n_gpus_per_node": 4, + "command": "echo hi", + } + ) + + tool = mod.RayJobTool(cfg) + submitted = tool.submit_advanced(spec, no_wait=True, user_id="alice") + assert submitted == "sid3" + + job_root = tmp_path / "jobs" / "sid3" + assert (job_root / "config" / "ray_config.yaml").exists() + assert (job_root / "config" / "taskspec.yaml").exists() + assert (job_root / "config" / "ray_submission_id.txt").read_text(encoding="utf-8").strip() == "sid3" + + payload = json.loads((job_root / "config" / "submit_payload.json").read_text(encoding="utf-8")) + assert payload["submission_id"] == "sid3" + assert "argus.ray.driver_entrypoint" in payload["entrypoint"] + assert (job_root / "debug" / "ray_resources_pre.error.txt").exists() diff --git a/src/mvp/py/tests/test_scheduler.py b/src/mvp/py/tests/test_scheduler.py index 59c016a..b6c929a 100644 --- a/src/mvp/py/tests/test_scheduler.py +++ b/src/mvp/py/tests/test_scheduler.py @@ -85,6 +85,67 @@ def test_tick_submits_one_task(monkeypatch, tmp_path: Path): assert s.tool.job_dirs[-1] == "/private/users/alice/jobs/t1--a01" +def test_tick_submits_one_task_advanced(monkeypatch, tmp_path: Path): + from argus.service import scheduler as sched_mod + + ray_cfg, v2_cfg = _mk_cfg(tmp_path) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_task_v25( + task_id="t1", + user_id="alice", + workload="advanced", + jobspec_yaml=yaml.safe_dump( + { + "kind": "advanced", + "nnodes": 2, + "n_gpus_per_node": 4, + "command": "python3 -m verl.trainer.main_ppo +ray_kwargs.ray_init.address=auto", + } + ), + nnodes=2, + n_gpus_per_node=4, + ) + + monkeypatch.setattr(sched_mod, "ensure_ray_connected", lambda: None) + monkeypatch.setattr( + sched_mod, + "get_cluster_available", + lambda: SimpleNamespace(total_available_gpus=999.0, total_available_npus=0.0), + ) + + class _Tool: + def __init__(self, cfg): + self.submitted = [] + self.job_dirs = [] + + def submit(self, spec, no_wait: bool, job_dir: str | None = None): + raise AssertionError("should not call submit() for advanced") + + def submit_advanced(self, spec, no_wait: bool, job_dir: str | None = None, user_id: str | None = None): + self.submitted.append(spec.submission_id) + self.job_dirs.append(job_dir) + return str(spec.submission_id) + + def status(self, submission_id: str): + return "RUNNING" + + def logs(self, submission_id: str): + return "" + + monkeypatch.setattr(sched_mod, "RayJobTool", _Tool) + + s = sched_mod.Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + s.tick() + + row = db.get_task("t1") + assert row and row["state"] == "SUBMITTED" + attempts = db.list_attempts("t1") + assert len(attempts) == 1 + assert attempts[0]["ray_submission_id"] == "t1--a01" + assert s.tool.job_dirs[-1] == "/private/users/alice/jobs/t1--a01" + + def test_tick_marks_pending_resources(monkeypatch, tmp_path: Path): from argus.service import scheduler as sched_mod diff --git a/src/mvp/py/tests/test_ui.py b/src/mvp/py/tests/test_ui.py index facf6b8..24d306d 100644 --- a/src/mvp/py/tests/test_ui.py +++ b/src/mvp/py/tests/test_ui.py @@ -78,3 +78,18 @@ def test_ui_task_detail_shows_ids(tmp_path, monkeypatch): assert f"/ui/tasks/{task_id}/logs" in r.text assert "TaskSpec (YAML)" in r.text assert "/api/v2/tasks/" in r.text + + +def test_ui_new_task_contains_advanced_example_snippet(tmp_path, monkeypatch): + cfg = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token") + app = create_app(str(cfg)) + c = TestClient(app) + + r = c.get("/ui/tasks/new") + assert r.status_code == 200 + # Ensure Advanced example includes required PPO micro batch override (common failure mode if omitted). + assert "kind: advanced" in r.text + # workload is not needed for advanced in v3.5. + assert "# workload:" not in r.text + assert "actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu" in r.text