-
Notifications
You must be signed in to change notification settings - Fork 459
feat(tools/mcp): add dry_run flag to submit_job #1718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -419,19 +419,41 @@ def submit_job_impl( | |||||||||||||||||||||
| job_name: str | None, | ||||||||||||||||||||||
| extra_overrides: dict[str, str] | None, | ||||||||||||||||||||||
| skip_verify: bool, | ||||||||||||||||||||||
| dry_run: bool = False, | ||||||||||||||||||||||
| ) -> dict: | ||||||||||||||||||||||
| """Submit a launcher YAML. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Mode is determined by mutually-exclusive args: | ||||||||||||||||||||||
| * ``hf_local`` set → Docker (local GPU) | ||||||||||||||||||||||
| * ``cluster_host`` set → Slurm (remote SSH) | ||||||||||||||||||||||
| * Neither set → error | ||||||||||||||||||||||
| * Neither set → error (unless ``dry_run=True``) | ||||||||||||||||||||||
| * Both set → error | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| When ``dry_run=True``, the launcher is invoked with ``--dryrun`` — | ||||||||||||||||||||||
| the YAML is parsed and validated but no cluster contact / no | ||||||||||||||||||||||
| container spawn / no sbatch happens. ``hf_local`` and | ||||||||||||||||||||||
| ``cluster_host`` are optional in dry-run mode (pass one to validate | ||||||||||||||||||||||
| that the YAML's executor-specific config compiles for the intended | ||||||||||||||||||||||
| target; omit both to validate just the YAML shape). ``verify_setup`` | ||||||||||||||||||||||
| is skipped automatically — there's nothing to talk to. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The actual orchestration is delegated to the launcher's | ||||||||||||||||||||||
| ``core.run_jobs``. We don't re-implement nemo_run integration here — | ||||||||||||||||||||||
| that lives upstream. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| # ---- Dry-run branch (no cluster contact) ----------------------- | ||||||||||||||||||||||
| if dry_run: | ||||||||||||||||||||||
| return _submit_job_dry_run( | ||||||||||||||||||||||
| yaml_path=yaml_path, | ||||||||||||||||||||||
| hf_local=hf_local, | ||||||||||||||||||||||
| cluster_host=cluster_host, | ||||||||||||||||||||||
| cluster_user=cluster_user, | ||||||||||||||||||||||
| identity=identity, | ||||||||||||||||||||||
| job_dir=job_dir, | ||||||||||||||||||||||
| job_name=job_name, | ||||||||||||||||||||||
| extra_overrides=extra_overrides, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # ---- Mode resolution ------------------------------------------- | ||||||||||||||||||||||
| if hf_local and cluster_host: | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
|
|
@@ -681,6 +703,164 @@ def submit_job_impl( | |||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def _submit_job_dry_run( | ||||||||||||||||||||||
| *, | ||||||||||||||||||||||
| yaml_path: str, | ||||||||||||||||||||||
| hf_local: str | None, | ||||||||||||||||||||||
| cluster_host: str | None, | ||||||||||||||||||||||
| cluster_user: str | None, | ||||||||||||||||||||||
| identity: str | None, | ||||||||||||||||||||||
| job_dir: str | None, | ||||||||||||||||||||||
| job_name: str | None, | ||||||||||||||||||||||
| extra_overrides: dict[str, str] | None, | ||||||||||||||||||||||
| ) -> dict: | ||||||||||||||||||||||
| """Validate a launcher YAML by running ``launch.py --dryrun``. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| No cluster contact, no container spawn, no sbatch. Used by | ||||||||||||||||||||||
| verify-task workflow stages (deployment_support, | ||||||||||||||||||||||
| hidden_state_dump_support, mlm_eval, ...) that just need to confirm | ||||||||||||||||||||||
| a YAML compiles before declaring support is ready. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Returns ``{ok, dry_run: True, validated: bool, diagnostic?: str, | ||||||||||||||||||||||
| exit_code: int|None, stdout_tail: str, stderr_tail: str, | ||||||||||||||||||||||
| argv: list[str]}``. Never returns ``experiment_id`` or ``pid`` — | ||||||||||||||||||||||
| there's nothing to track. ``diagnostic`` is present only on the | ||||||||||||||||||||||
| failure / timeout branches (the validated-success branch omits | ||||||||||||||||||||||
| it since there's nothing to diagnose). | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| # Same path resolution as the live submit, so dry-run and live use | ||||||||||||||||||||||
| # exactly the same YAML. | ||||||||||||||||||||||
| abs_yaml = _normalize_yaml_path(yaml_path) | ||||||||||||||||||||||
| if not abs_yaml.exists(): | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| "ok": False, | ||||||||||||||||||||||
| "dry_run": True, | ||||||||||||||||||||||
| "reason": "yaml_not_found", | ||||||||||||||||||||||
| "yaml_path": yaml_path, | ||||||||||||||||||||||
| "resolved_path": str(abs_yaml), | ||||||||||||||||||||||
| "diagnostic": ( | ||||||||||||||||||||||
| f"YAML not found at {abs_yaml}. Pass a path under " | ||||||||||||||||||||||
| f"tools/launcher/examples/ (relative), an absolute path, " | ||||||||||||||||||||||
| f"or one of the examples returned by list_examples." | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Build argv — launch.py supports --dryrun as a flag that prevents | ||||||||||||||||||||||
| # actual submission while still exercising the YAML loader, factory | ||||||||||||||||||||||
| # resolution, and arg parser. Same argv shape as live submit minus | ||||||||||||||||||||||
| # `--yes` pairs with `--dryrun` in every launcher CLI example (see | ||||||||||||||||||||||
| # `tools/launcher/CLAUDE.md:28` and `:93`, plus `tools/launcher/docs/ | ||||||||||||||||||||||
| # contributing.md:24`). Without it, nemo_run's `run.cli.entrypoint` | ||||||||||||||||||||||
| # blocks on its confirmation prompt — and since we're capturing | ||||||||||||||||||||||
| # stdout (no TTY), the prompt would hang until the 60-second | ||||||||||||||||||||||
| # timeout fires. | ||||||||||||||||||||||
| argv = ["uv", "run", "launch.py", "--yaml", str(abs_yaml), "--dryrun", "--yes"] | ||||||||||||||||||||||
| if hf_local: | ||||||||||||||||||||||
| argv.append(f"hf_local={hf_local}") | ||||||||||||||||||||||
| if cluster_user: | ||||||||||||||||||||||
| argv.append(f"user={cluster_user}") | ||||||||||||||||||||||
| if identity: | ||||||||||||||||||||||
| argv.append(f"identity={identity}") | ||||||||||||||||||||||
| if job_dir: | ||||||||||||||||||||||
| argv.append(f"job_dir={job_dir}") | ||||||||||||||||||||||
| if job_name: | ||||||||||||||||||||||
| argv.append(f"job_name={job_name}") | ||||||||||||||||||||||
| for k, v in (extra_overrides or {}).items(): | ||||||||||||||||||||||
| argv.append(f"{k}={v}") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| launcher_dir = _THIS_DIR.parent.parent / "launcher" | ||||||||||||||||||||||
| if not launcher_dir.exists(): | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| "ok": False, | ||||||||||||||||||||||
| "dry_run": True, | ||||||||||||||||||||||
| "reason": "launcher_dir_not_found", | ||||||||||||||||||||||
| "diagnostic": ( | ||||||||||||||||||||||
| f"Expected tools/launcher/ at {launcher_dir} but it " | ||||||||||||||||||||||
| f"doesn't exist. modelopt-mcp must be installed from a " | ||||||||||||||||||||||
| f"Model-Optimizer clone or via uvx-from-git." | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Propagate env so the launcher's factory resolution matches what | ||||||||||||||||||||||
| # the live submit would see (mainly: SLURM_HOST for slurm-factory | ||||||||||||||||||||||
| # default when cluster_host is set). | ||||||||||||||||||||||
| child_env = os.environ.copy() | ||||||||||||||||||||||
| child_env.setdefault("NEMORUN_HOME", os.getcwd()) | ||||||||||||||||||||||
| if cluster_host: | ||||||||||||||||||||||
| child_env["SLURM_HOST"] = cluster_host | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Dry-run is fast (no network, no container) — 60s timeout is | ||||||||||||||||||||||
| # generous. Same subprocess invocation shape as the live-submit | ||||||||||||||||||||||
| # branch above (line 590): list-form argv, no shell, inherited | ||||||||||||||||||||||
| # env. ``argv`` members are string-literal constants | ||||||||||||||||||||||
| # ("uv", "run", "launch.py", "--yaml", "--dryrun"), validated | ||||||||||||||||||||||
| # filesystem paths (``str(abs_yaml)``, ``str(launcher_dir)``), or | ||||||||||||||||||||||
| # key=value override strings sourced from typed MCP-tool args. | ||||||||||||||||||||||
| # B603 false-positive matches the precedent in this module's | ||||||||||||||||||||||
| # `submit_job_impl` (Popen at line 563 + run at line 590), the | ||||||||||||||||||||||
| # verify probes (line 197 + 251), and the SSH probe (line 326). | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| proc = subprocess.run( # nosec B603 | ||||||||||||||||||||||
| argv, | ||||||||||||||||||||||
| cwd=str(launcher_dir), | ||||||||||||||||||||||
| env=child_env, | ||||||||||||||||||||||
| capture_output=True, | ||||||||||||||||||||||
| text=True, | ||||||||||||||||||||||
| timeout=60, | ||||||||||||||||||||||
| check=False, | ||||||||||||||||||||||
|
Comment on lines
+803
to
+810
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the new inline Line 788 introduces As per coding guidelines, “Bandit security checks must pass without exceptions. 🧰 Tools🪛 ast-grep (0.43.0)[error] 787-795: Command coming from incoming request (subprocess-from-request) [error] 787-795: Use of unsanitized data to create processes (os-system-unsanitized-data) 🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| except subprocess.TimeoutExpired as e: | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| "ok": False, | ||||||||||||||||||||||
| "dry_run": True, | ||||||||||||||||||||||
| "reason": "dry_run_timeout", | ||||||||||||||||||||||
| "exit_code": None, | ||||||||||||||||||||||
| "stdout_tail": (e.stdout or b"").decode(errors="replace")[-2000:] if e.stdout else "", | ||||||||||||||||||||||
| "stderr_tail": (e.stderr or b"").decode(errors="replace")[-2000:] if e.stderr else "", | ||||||||||||||||||||||
| "diagnostic": ( | ||||||||||||||||||||||
| "launch.py --dryrun did not return within 60 seconds. " | ||||||||||||||||||||||
| "This usually means a YAML import / factory resolution " | ||||||||||||||||||||||
| "hung." | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| "argv": argv, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| stdout_tail = str(proc.stdout or "")[-2000:] | ||||||||||||||||||||||
| stderr_tail = str(proc.stderr or "")[-2000:] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if proc.returncode != 0: | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| "ok": True, # The tool itself ran cleanly | ||||||||||||||||||||||
| "dry_run": True, | ||||||||||||||||||||||
| "validated": False, # ...but the YAML failed validation | ||||||||||||||||||||||
| "exit_code": proc.returncode, | ||||||||||||||||||||||
| "stdout_tail": stdout_tail, | ||||||||||||||||||||||
| "stderr_tail": stderr_tail, | ||||||||||||||||||||||
| "diagnostic": ( | ||||||||||||||||||||||
| f"launch.py --dryrun rejected the YAML (exit code " | ||||||||||||||||||||||
| f"{proc.returncode}). Common reasons: invalid YAML " | ||||||||||||||||||||||
| f"syntax, missing required fields, factory function " | ||||||||||||||||||||||
| f"not registered, or a referenced file (HF model path, " | ||||||||||||||||||||||
| f"container tag) doesn't exist. See stderr_tail for the " | ||||||||||||||||||||||
| f"specific error." | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| "argv": argv, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Success branch returns the same field set as the failure branch | ||||||||||||||||||||||
| # (plus diagnostic-free since there's nothing to diagnose) so the | ||||||||||||||||||||||
| # caller can read stderr_tail / exit_code uniformly. | ||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| "ok": True, | ||||||||||||||||||||||
| "dry_run": True, | ||||||||||||||||||||||
| "validated": True, | ||||||||||||||||||||||
| "exit_code": 0, | ||||||||||||||||||||||
| "stdout_tail": stdout_tail, | ||||||||||||||||||||||
| "stderr_tail": stderr_tail, | ||||||||||||||||||||||
| "argv": argv, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+853
to
+861
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [IMPORTANT Compatibility] Return-shape drift between success / failure / timeout branches. The three terminal returns have inconsistent fields:
Callers parsing this response (the verify-task workflow stages mentioned in the PR description) have to guard every key with The bridge docstring at lines 724–726 also describes a Fix: make all three branches return the same keys (filling missing ones with def _dry_run_response(*, ok, validated, exit_code, stdout_tail, stderr_tail, argv, reason=None, diagnostic=None):
out = {"ok": ok, "dry_run": True, "validated": validated,
"exit_code": exit_code, "stdout_tail": stdout_tail,
"stderr_tail": stderr_tail, "argv": argv}
if reason is not None:
out["reason"] = reason
if diagnostic is not None:
out["diagnostic"] = diagnostic
return out…and update the docstring at L724-726 to match (drop |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||||||||||
| # job_status / job_logs — filesystem-based | ||||||||||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -207,6 +207,28 @@ def submit_job( | |
| ) | ||
| ), | ||
| ] = False, | ||
| dry_run: Annotated[ | ||
| bool, | ||
| Field( | ||
| description=( | ||
| "If True, run `launch.py --dryrun --yes` to validate that " | ||
| "the YAML parses, the factory resolves, and any " | ||
| "referenced files exist — without contacting the " | ||
| "cluster, spawning a container, or running sbatch. " | ||
| "Used by verify-task workflow stages (deployment_support, " | ||
| "hidden_state_dump_support, mlm_eval, ...) that only " | ||
| "need to confirm a YAML compiles. Returns " | ||
| "`{ok, dry_run: True, validated: bool, diagnostic?: str, " | ||
| "exit_code: int|None, stdout_tail: str, stderr_tail: str, " | ||
| "argv: list[str]}` with no `experiment_id`. Skips " | ||
|
Comment on lines
+221
to
+223
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documented return shape doesn't match bridge.py implementation for all dry-run branches. The Field description states the dry-run return will have
Client code expecting Either:
🤖 Prompt for AI Agents |
||
| "verify_setup automatically — " | ||
| "no cluster contact happens in dry-run. `hf_local` / " | ||
| "`cluster_host` are optional in this mode (pass one to " | ||
| "validate executor-specific config, omit both to validate " | ||
| "just the YAML shape)." | ||
| ) | ||
| ), | ||
| ] = False, | ||
| ) -> dict: | ||
| return bridge.submit_job_impl( | ||
| yaml_path=yaml_path, | ||
|
|
@@ -218,6 +240,7 @@ def submit_job( | |
| job_name=job_name, | ||
| extra_overrides=extra_overrides, | ||
| skip_verify=skip_verify, | ||
| dry_run=dry_run, | ||
| ) | ||
|
|
||
| @mcp.tool( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SUGGESTION] The docstring +
server.py:221Field description both advertisevalidated: boolandargv: list[str]as if always present in the return shape, but the pre-flightok=Falsebranches don't include them:yaml_not_found(line 735): novalidated, noargv, noexit_code/stdout_tail/stderr_taillauncher_dir_not_found(line 773): same omissionsdry_run_timeout(line 813): hasargv/exit_code/stdout_tail/stderr_tailbut novalidatedA defensively-coded caller using
.get("validated")won't break, but an agent reading the schema description from the MCP tool definition will reasonably assumevalidatedis always there and may writeresult["validated"]. Lowest-effort fix: mark them conditional in both doc sites (validated?: bool,argv?: list[str]) and add a one-liner noting that pre-flight failures (ok=False,reason ∈ {yaml_not_found, launcher_dir_not_found}) carryreason+diagnosticonly. Higher-effort fix (CodeRabbit's suggestion): populatevalidated=False,argv=[]etc. on all error branches so the contract is uniform — slightly heavier, but eliminates the question entirely.Non-blocking; just helpful for downstream agents.