Просмотр исходного кода

feat(recipe): add deploy field for docker/tmux variant selection

Alexander Korolev 2 месяцев назад
Родитель
Сommit
c4a74cc152
14 измененных файлов с 531 добавлено и 137 удалено
  1. 27 1
      AGENTS.md
  2. 3 0
      locales/en/main.ftl
  3. 1 0
      src/after_stop.rs
  4. 1 0
      src/before_serve.rs
  5. 18 0
      src/config/tests.rs
  6. 21 0
      src/config/types.rs
  7. 2 1
      src/docker/command.rs
  8. 16 59
      src/docker/tail.rs
  9. 3 1
      src/docker/tests.rs
  10. 1 0
      src/doctor.rs
  11. 226 0
      src/engine_flags.rs
  12. 54 5
      src/main.rs
  13. 144 70
      src/tmux/commands.rs
  14. 14 0
      src/tmux/serve.rs

+ 27 - 1
AGENTS.md

@@ -50,11 +50,12 @@ model = "org/model"
 port = 30000                        # default
 required_nodes = 2                  # optional; also caps the launch set (N nodes, not all)
 solo = false                       # optional; single-node on first free node, no dist flags
+deploy = "docker"                   # optional; "docker" (default) or "tmux"
 tensor_parallel_size = 2
 pipeline_parallel_size = 1          # vLLM only, 0 = auto
 distributed_backend = "mp"          # or "ray"
 shm_size = "64gb"                   # optional, only set when non-empty
-entrypoint = ["vllm", "serve"]      # optional, after image
+entrypoint = ["vllm", "serve"]      # optional, after image; used by both docker and tmux
 docker_image_dir = "glm52-nf3-hybrid"  # optional; build dir under docker-images/ (default: recipe name)
 before_serve = [{ command = "echo", on_failure = "abort" }]
 after_stop = [{ script = "restore_dgx_services" }]   # runs after `sm121 stop`
@@ -95,6 +96,31 @@ KEY = "val"                         # overrides launcher defaults
 VLLM_COMMIT = "00907fc..."          # recipe wins over the image's pin; folded into build checksum
 ```
 
+### Deployment target (`deploy`)
+
+The `deploy` field selects how the recipe is launched on each node:
+
+- `docker` (default): `docker run` with the recipe's `entrypoint`. Supports all features
+  (mods, composed_model, image sync, extra_mounts).
+- `tmux`: a detached `tmux` session running the engine directly (no Docker). The recipe's
+  `entrypoint` is used when present (e.g. `["vllm", "serve"]` → `vllm serve`); otherwise
+  the launcher falls back to the engine's Python module path. Docker-only features are
+  rejected: `[mods]`, `[composed_model]`, image sync, and `--sync-image`/`--build-node`/
+  `--force-build` CLI opts are ignored with a warning.
+
+The `serve` command dispatches based on `recipe.deploy`:
+
+```toml
+engine = "vllm"
+deploy = "tmux"                     # or "docker" (default)
+entrypoint = ["vllm", "serve"]
+```
+
+Engine-specific args (`args`) are interpreted by the engine itself — the launcher doesn't
+validate them, so the same `args` list works for both `vllm` and `sglang` recipes. The
+`entrypoint` field is shared: both deployment paths use it, so a single recipe can be
+served via either `docker` or `tmux` without changes.
+
 ## Env vars (auto-set)
 
 Cluster-driven: `NCCL_IB_DISABLE/HCA/SOCKET_IFNAME/GID_INDEX`, `GLOO_/TP_SOCKET_IFNAME`, `MN_IF_NAME`, `UCX_NET_DEVICES`, `OMPI_MCA_btl_tcp_if_include`

+ 3 - 0
locales/en/main.ftl

@@ -149,6 +149,9 @@ health-recipe-line =   { $recipe } — { $nodes } { $nodes ->
 tmux-ray-backend = 🐍 Ray backend detected — starting head node first...
 tmux-mp-backend = 🔗 MP backend detected — starting all nodes in parallel...
 tmux-following-logs = 📜 Following logs from '{ $sess }' on { $name }...
+tmux-deploy-ignored-opts = ⚠️ tmux deploy: ignoring Docker-only CLI opts: { $opts }
+tmux-mods-unsupported = Recipe '{ $name }' uses [mods], which is Docker-only. tmux deployment does not support mods.
+tmux-composed-model-unsupported = Recipe '{ $name }' uses [composed_model], which is Docker-only. tmux deployment does not support composed model directories.
 composed-dir-dry-run = [dry-run] would build composed model dir '{ $host }' on '{ $node }' (mount: { $mount }, { $sources } { $sources ->
         [one] source
        *[other] sources

+ 1 - 0
src/after_stop.rs

@@ -198,6 +198,7 @@ mod tests {
             before_serve: vec![],
             after_stop,
             solo: None,
+            deploy: crate::config::DeployTarget::Docker,
             mods: Default::default(),
             sync_image: true,
             docker_image_dir: None,

+ 1 - 0
src/before_serve.rs

@@ -308,6 +308,7 @@ mod tests {
             before_serve: vec![],
             after_stop: vec![],
             solo: None,
+            deploy: crate::config::DeployTarget::Docker,
             mods: Default::default(),
             sync_image: true,
             docker_image_dir: None,

+ 18 - 0
src/config/tests.rs

@@ -192,6 +192,24 @@ fn test_default_distributed_backend_is_none() {
     assert_eq!(r.distributed_backend, None);
 }
 
+#[test]
+fn test_deploy_defaults_to_docker() {
+    let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\n").unwrap();
+    assert_eq!(r.deploy, DeployTarget::Docker);
+}
+
+#[test]
+fn test_deploy_explicit_tmux() {
+    let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\ndeploy = \"tmux\"\n").unwrap();
+    assert_eq!(r.deploy, DeployTarget::Tmux);
+}
+
+#[test]
+fn test_deploy_explicit_docker() {
+    let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\ndeploy = \"docker\"\n").unwrap();
+    assert_eq!(r.deploy, DeployTarget::Docker);
+}
+
 #[test]
 fn default_config_dir_appends_sm121() {
     let Some(d) = default_config_dir() else {

+ 21 - 0
src/config/types.rs

@@ -153,6 +153,12 @@ pub struct Recipe {
     /// flag overrides this per-invocation.
     #[serde(default)]
     pub solo: Option<bool>,
+    /// Deployment target — `docker` (default) or `tmux`. When `tmux`, the
+    /// `serve` command launches via detached `tmux` sessions instead of
+    /// `docker run`. Docker-only features (mods, composed_model, image
+    /// sync) are rejected in tmux mode.
+    #[serde(default)]
+    pub deploy: DeployTarget,
     #[serde(default)]
     pub mods: ModsTable,
     #[serde(default = "default_sync_image")]
@@ -172,6 +178,8 @@ pub struct Recipe {
     /// `mount`, and rewrites `model` to that path. Lets a recipe combine
     /// multiple HF repos (e.g. a base checkpoint + an expert overlay)
     /// without mutating the HF cache. See [`ComposedModel`].
+    ///
+    /// Docker-only: rejected when `deploy = "tmux"`.
     #[serde(default)]
     pub composed_model: Option<ComposedModel>,
     /// Extra bind-mounts to add to `docker run` (e.g. persistent JIT cache).
@@ -370,6 +378,19 @@ pub fn resolve_mount_host(
     Ok(expand_tilde(raw, ssh_user))
 }
 
+/// Deployment target — how the recipe is launched on each node.
+/// `docker` (default): `docker run` with the recipe's `entrypoint`.
+/// `tmux`: a detached `tmux` session running the engine directly (no Docker).
+/// Docker-only features (mods, composed_model, image sync) are rejected
+/// when `deploy = "tmux"`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DeployTarget {
+    #[default]
+    Docker,
+    Tmux,
+}
+
 #[cfg(test)]
 mod tests {
     use super::{expand_tilde, model_cache_dir_name, resolve_mount_host};

+ 2 - 1
src/docker/command.rs

@@ -297,7 +297,7 @@ pub(crate) fn build_docker_command(
 mod tests {
     use super::*;
     use crate::config::{
-        Cluster, DistributedBackend, ExtraMount, NcclConfig, Node, Recipe, RecipeArgs,
+        Cluster, DeployTarget, DistributedBackend, ExtraMount, NcclConfig, Node, Recipe, RecipeArgs,
     };
     use std::collections::HashMap;
 
@@ -385,6 +385,7 @@ mod tests {
             before_serve: vec![],
             after_stop: vec![],
             solo: None,
+            deploy: DeployTarget::Docker,
             mods: Default::default(),
             shm_size: String::new(),
             sync_image: true,

+ 16 - 59
src/docker/tail.rs

@@ -1,7 +1,8 @@
 //! Builds the "tail" of the docker command — everything after the image
 //! name (entrypoint, model, args, auto-generated flags).
 
-use crate::config::{DistributedBackend, Node, Recipe};
+use crate::config::{Node, Recipe};
+use crate::engine_flags::{EngineFlagsCtx, build_engine_args};
 
 use super::shq;
 
@@ -17,14 +18,11 @@ pub(crate) struct TailCtx<'a> {
 
 /// Build the docker command tail — everything after the image name.
 pub fn build_tail(ctx: &TailCtx<'_>, node: &Node) -> Vec<String> {
-    let is_sglang = ctx.recipe.engine == "sglang";
-    let is_distributed = matches!(
-        ctx.recipe.distributed_backend,
-        Some(DistributedBackend::Mp | DistributedBackend::Ray)
-    );
-
     let mut tail = Vec::new();
 
+    // Entrypoint — from the recipe (e.g. ["vllm", "serve"]).
+    // When mods are absent and the entrypoint ends with `-c <script>`,
+    // shell-quote the script part so it survives word-splitting.
     if !ctx.recipe.entrypoint.is_empty() {
         let mut ep = ctx.recipe.entrypoint.clone();
         if ctx.recipe.mods.commands.is_empty() && ep.len() >= 3 && ep[ep.len() - 2] == "-c" {
@@ -34,59 +32,18 @@ pub fn build_tail(ctx: &TailCtx<'_>, node: &Node) -> Vec<String> {
         tail.extend(ep);
     }
 
-    if is_sglang {
-        tail.push("--model-path".to_string());
-    }
-    tail.push(ctx.recipe.model.clone());
-    tail.extend(ctx.recipe.args.to_vec());
-
-    // Single-node run (solo, required_nodes=1, or a single --node): emit
-    // NO distributed flags — the engine defaults to single-node. Launching
-    // one container with `--nnodes 1 --master-addr ...` is redundant at best
-    // and rejected by some engines at worst. Multi-node runs get the full
-    // rendezvous flag set below.
-    let mut flags: Vec<String> = if ctx.nnodes > 1 {
-        match ctx.recipe.distributed_backend {
-            Some(DistributedBackend::Ray | DistributedBackend::Mp) if is_sglang => vec![
-                "--tp".into(),
-                ctx.tp_size.to_string(),
-                "--nnodes".into(),
-                ctx.nnodes.to_string(),
-                "--node-rank".into(),
-                node.rank.to_string(),
-                "--dist-init-addr".into(),
-                format!("{}:{}", ctx.master_ib_ip, ctx.init_port),
-            ],
-            Some(DistributedBackend::Ray | DistributedBackend::Mp) => {
-                let mut fp = vec![
-                    "--tensor-parallel-size".into(),
-                    ctx.tp_size.to_string(),
-                    "--pipeline-parallel-size".into(),
-                    ctx.pp_size.to_string(),
-                    "--nnodes".into(),
-                    ctx.nnodes.to_string(),
-                    "--node-rank".into(),
-                    node.rank.to_string(),
-                    "--master-addr".into(),
-                    ctx.master_ib_ip.to_string(),
-                    "--master-port".into(),
-                    ctx.init_port.to_string(),
-                ];
-                if ctx.recipe.distributed_backend == Some(DistributedBackend::Ray) {
-                    fp.push("--distributed-executor-backend".into());
-                    fp.push("ray".into());
-                }
-                fp
-            }
-            _ => vec![],
-        }
-    } else {
-        vec![]
+    // Shared engine args: model, recipe args, distributed flags, headless.
+    let ectx = EngineFlagsCtx {
+        recipe: ctx.recipe,
+        tp_size: ctx.tp_size,
+        pp_size: ctx.pp_size,
+        nnodes: ctx.nnodes,
+        init_port: ctx.init_port,
+        master_ib_ip: ctx.master_ib_ip,
     };
-    if ctx.nnodes > 1 && !is_sglang && node.rank > 0 && is_distributed {
-        flags.push("--headless".into());
-    }
-    tail.extend(flags);
+    tail.extend(build_engine_args(&ectx, node));
+
+    // Docker always binds --host 0.0.0.0 and --port.
     tail.push("--host".to_string());
     tail.push("0.0.0.0".to_string());
     tail.extend(["--port".into(), ctx.recipe.port.to_string()]);

+ 3 - 1
src/docker/tests.rs

@@ -1,6 +1,6 @@
 use super::command::{DockerCommandCtx, build_docker_command};
 use super::*;
-use crate::config::{DistributedBackend, RecipeArgs};
+use crate::config::{DeployTarget, DistributedBackend, RecipeArgs};
 use std::collections::HashMap;
 
 fn tcls() -> Cluster {
@@ -61,6 +61,7 @@ fn trec() -> Recipe {
         before_serve: vec![],
         after_stop: vec![],
         solo: None,
+        deploy: DeployTarget::Docker,
         mods: Default::default(),
         shm_size: String::new(),
         sync_image: true,
@@ -250,6 +251,7 @@ fn sglang_recipe() -> Recipe {
         before_serve: vec![],
         after_stop: vec![],
         solo: None,
+        deploy: DeployTarget::Docker,
         mods: Default::default(),
         shm_size: String::new(),
         sync_image: true,

+ 1 - 0
src/doctor.rs

@@ -371,6 +371,7 @@ mod tests {
             before_serve: vec![],
             after_stop: vec![],
             solo: None,
+            deploy: crate::config::DeployTarget::Docker,
             mods: Default::default(),
             sync_image: true,
             docker_image_dir: None,

+ 226 - 0
src/engine_flags.rs

@@ -0,0 +1,226 @@
+//! Shared engine-specific argument builder — used by both the Docker
+//! (`docker/tail.rs`) and tmux (`tmux/commands.rs`) deployment paths to
+//! produce the model, recipe args, and distributed-rendezvous flags.
+//!
+//! This consolidates the duplicated flag logic that previously lived in
+//! `tail.rs` and `commands.rs` separately. Each deployment path still
+//! adds its own entrypoint and host/port flags on top.
+
+use crate::config::{DistributedBackend, Node, Recipe};
+
+/// Context for [`build_engine_args`] — the subset of `DockerCommandCtx`
+/// / `TmuxManager` state that the shared flag builder needs.
+pub struct EngineFlagsCtx<'a> {
+    pub recipe: &'a Recipe,
+    pub tp_size: u32,
+    pub pp_size: u32,
+    pub nnodes: u32,
+    pub init_port: u16,
+    pub master_ib_ip: &'a str,
+}
+
+/// Build the engine-specific arguments for a recipe: model path, recipe
+/// args, and distributed-rendezvous flags.
+///
+/// Does **not** include the entrypoint, `--host`, or `--port` — those are
+/// deployment-specific and added by each path:
+/// - Docker (`tail.rs`): always adds `--host 0.0.0.0 --port <port>`.
+/// - tmux (`commands.rs`): adds `--port <port>` on the head node (or all
+///   nodes for SGLang).
+///
+/// Single-node runs emit **no** distributed flags — the engine defaults
+/// to single-node. Multi-node runs get the full rendezvous flag set.
+pub fn build_engine_args(ctx: &EngineFlagsCtx<'_>, node: &Node) -> Vec<String> {
+    let is_sglang = ctx.recipe.engine == "sglang";
+    let is_distributed = matches!(
+        ctx.recipe.distributed_backend,
+        Some(DistributedBackend::Mp | DistributedBackend::Ray)
+    );
+
+    let mut args = Vec::new();
+
+    // Model — SGLang uses --model-path, vLLM uses the bare model arg.
+    if is_sglang {
+        args.push("--model-path".to_string());
+    }
+    args.push(ctx.recipe.model.clone());
+
+    // Recipe-specific args (e.g. --kv-cache-dtype, --max-model-len).
+    args.extend(ctx.recipe.args.to_vec());
+
+    // Distributed flags — only for multi-node runs.
+    if ctx.nnodes > 1 {
+        match ctx.recipe.distributed_backend {
+            Some(DistributedBackend::Ray | DistributedBackend::Mp) if is_sglang => {
+                args.extend(vec![
+                    "--tp".into(),
+                    ctx.tp_size.to_string(),
+                    "--nnodes".into(),
+                    ctx.nnodes.to_string(),
+                    "--node-rank".into(),
+                    node.rank.to_string(),
+                    "--dist-init-addr".into(),
+                    format!("{}:{}", ctx.master_ib_ip, ctx.init_port),
+                ]);
+            }
+            Some(DistributedBackend::Ray | DistributedBackend::Mp) => {
+                let mut fp = vec![
+                    "--tensor-parallel-size".into(),
+                    ctx.tp_size.to_string(),
+                    "--pipeline-parallel-size".into(),
+                    ctx.pp_size.to_string(),
+                    "--nnodes".into(),
+                    ctx.nnodes.to_string(),
+                    "--node-rank".into(),
+                    node.rank.to_string(),
+                    "--master-addr".into(),
+                    ctx.master_ib_ip.to_string(),
+                    "--master-port".into(),
+                    ctx.init_port.to_string(),
+                ];
+                if ctx.recipe.distributed_backend == Some(DistributedBackend::Ray) {
+                    fp.push("--distributed-executor-backend".into());
+                    fp.push("ray".into());
+                }
+                args.extend(fp);
+            }
+            _ => {}
+        }
+    }
+
+    // vLLM workers in distributed mode run headless (no port binding).
+    if ctx.nnodes > 1 && !is_sglang && node.rank > 0 && is_distributed {
+        args.push("--headless".into());
+    }
+
+    args
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{DeployTarget, RecipeArgs};
+    use std::collections::HashMap;
+
+    fn base_recipe() -> Recipe {
+        Recipe {
+            engine: "vllm".into(),
+            name: "T".into(),
+            docker_image: "img".into(),
+            model: "mdl".into(),
+            port: 8000,
+            required_nodes: None,
+            pipeline_parallel_size: Some(1),
+            tensor_parallel_size: 1,
+            args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+            env: HashMap::new(),
+            cluster: None,
+            entrypoint: vec!["vllm".into(), "serve".into()],
+            distributed_backend: Some(DistributedBackend::Mp),
+            before_serve: vec![],
+            after_stop: vec![],
+            solo: None,
+            deploy: DeployTarget::Docker,
+            mods: Default::default(),
+            shm_size: String::new(),
+            sync_image: true,
+            docker_image_dir: None,
+            extra_mounts: vec![],
+            build: Default::default(),
+            composed_model: None,
+        }
+    }
+
+    fn tnode(rank: u32) -> Node {
+        Node {
+            name: format!("n{}", rank),
+            ssh_ip: "10.0.0.1".into(),
+            ib_ip: "10.0.0.1".into(),
+            rank,
+            host_key: None,
+            entity: None,
+        }
+    }
+
+    #[test]
+    fn single_node_no_distributed_flags() {
+        let r = base_recipe();
+        let ctx = EngineFlagsCtx {
+            recipe: &r,
+            tp_size: 1,
+            pp_size: 1,
+            nnodes: 1,
+            init_port: 25000,
+            master_ib_ip: "10.0.0.1",
+        };
+        let args = build_engine_args(&ctx, &tnode(0));
+        // model + recipe args, no distributed flags
+        assert_eq!(args, vec!["mdl", "--x", "4096"]);
+    }
+
+    #[test]
+    fn multi_node_vllm_mp_flags() {
+        let r = base_recipe();
+        let ctx = EngineFlagsCtx {
+            recipe: &r,
+            tp_size: 2,
+            pp_size: 1,
+            nnodes: 2,
+            init_port: 25000,
+            master_ib_ip: "10.0.0.1",
+        };
+        let head_args = build_engine_args(&ctx, &tnode(0));
+        assert!(head_args.contains(&"--tensor-parallel-size".into()));
+        assert!(head_args.contains(&"2".into()));
+        assert!(head_args.contains(&"--nnodes".into()));
+        assert!(head_args.contains(&"2".into()));
+        assert!(head_args.contains(&"--master-addr".into()));
+        assert!(head_args.contains(&"10.0.0.1".into()));
+        assert!(head_args.contains(&"--master-port".into()));
+        assert!(head_args.contains(&"25000".into()));
+        // head node: no --headless
+        assert!(!head_args.contains(&"--headless".into()));
+
+        let worker_args = build_engine_args(&ctx, &tnode(1));
+        assert!(worker_args.contains(&"--headless".into()));
+    }
+
+    #[test]
+    fn multi_node_sglang_flags() {
+        let mut r = base_recipe();
+        r.engine = "sglang".into();
+        let ctx = EngineFlagsCtx {
+            recipe: &r,
+            tp_size: 2,
+            pp_size: 1,
+            nnodes: 2,
+            init_port: 25000,
+            master_ib_ip: "10.0.0.1",
+        };
+        let args = build_engine_args(&ctx, &tnode(0));
+        // SGLang uses --model-path, --tp, --dist-init-addr
+        assert!(args.contains(&"--model-path".into()));
+        assert!(args.contains(&"--tp".into()));
+        assert!(args.contains(&"--dist-init-addr".into()));
+        assert!(args.contains(&"10.0.0.1:25000".into()));
+        // SGLang never gets --headless
+        assert!(!args.contains(&"--headless".into()));
+    }
+
+    #[test]
+    fn ray_backend_adds_executor() {
+        let mut r = base_recipe();
+        r.distributed_backend = Some(DistributedBackend::Ray);
+        let ctx = EngineFlagsCtx {
+            recipe: &r,
+            tp_size: 1,
+            pp_size: 1,
+            nnodes: 2,
+            init_port: 25000,
+            master_ib_ip: "10.0.0.1",
+        };
+        let args = build_engine_args(&ctx, &tnode(0));
+        assert!(args.contains(&"--distributed-executor-backend".into()));
+        assert!(args.contains(&"ray".into()));
+    }
+}

+ 54 - 5
src/main.rs

@@ -14,6 +14,7 @@ mod config;
 mod docker;
 mod doctor;
 mod ends;
+mod engine_flags;
 mod exec;
 mod health;
 mod home_assistant;
@@ -27,7 +28,8 @@ mod tmux;
 
 use crate::cli::{Cli, Commands, PowerAction};
 use crate::config::{
-    Cluster, Config, default_config_dir, load_recipe, resolve_cluster, select_cluster_by_name,
+    Cluster, Config, DeployTarget, default_config_dir, load_recipe, resolve_cluster,
+    select_cluster_by_name,
 };
 use crate::ssh::fetch_host_key;
 use std::collections::HashSet;
@@ -202,6 +204,11 @@ async fn main() -> Result<()> {
 // ---------------------------------------------------------------------------
 
 /// Handle `serve` command: launch inference containers on a cluster.
+///
+/// Dispatches to `docker::serve` or `tmux::serve` based on the recipe's
+/// `deploy` field. Docker-only CLI opts (sync_image, build_node,
+/// force_build, keep, auto_power_on) are ignored with a warning when
+/// `deploy = "tmux"`.
 async fn handle_serve(
     config: &Config,
     recipe: &str,
@@ -211,10 +218,52 @@ async fn handle_serve(
     follow: bool,
     dry_run: bool,
 ) -> Result<()> {
-    let cluster = docker::serve(config, recipe, cluster, node.as_deref(), opts).await?;
-    if follow && !dry_run {
-        tracing::info!("{}", fluent_i18n::t!("container-log-header"));
-        docker::show_logs(config, &cluster, recipe, true, node.as_deref(), dry_run).await?;
+    let recipe_obj = load_recipe(recipe).await?;
+    match recipe_obj.deploy {
+        DeployTarget::Docker => {
+            let cluster = docker::serve(config, recipe, cluster, node.as_deref(), opts).await?;
+            if follow && !dry_run {
+                tracing::info!("{}", fluent_i18n::t!("container-log-header"));
+                docker::show_logs(config, &cluster, recipe, true, node.as_deref(), dry_run).await?;
+            }
+        }
+        DeployTarget::Tmux => {
+            // Warn about docker-only CLI opts that are silently ignored.
+            let ignored: Vec<&str> = [
+                (opts.sync_image, "--sync-image"),
+                (opts.sync_image_source.is_some(), "--sync-image-source"),
+                (opts.build_node.is_some(), "--build-node"),
+                (opts.force_build, "--force-build"),
+                (opts.keep, "--keep"),
+                (opts.auto_power_on, "--on"),
+                (opts.force, "--force"),
+                (opts.solo.is_some(), "--solo/--no-solo"),
+            ]
+            .iter()
+            .filter_map(|(set, flag)| if *set { Some(*flag) } else { None })
+            .collect();
+            if !ignored.is_empty() {
+                tracing::warn!(
+                    "{}",
+                    fluent_i18n::t!("tmux-deploy-ignored-opts", { "opts" => &ignored.join(", ") })
+                );
+            }
+            crate::tmux::serve::tmux_serve(
+                config,
+                recipe,
+                cluster,
+                opts.skip_before_serve,
+                dry_run,
+            )
+            .await?;
+            if follow && !dry_run {
+                tracing::info!(
+                    "{}",
+                    fluent_i18n::t!("tmux-following-logs", { "name" => recipe.to_string() })
+                );
+                crate::tmux::ops::tmux_logs(config, recipe, cluster, true, dry_run).await?;
+            }
+        }
     }
     Ok(())
 }

+ 144 - 70
src/tmux/commands.rs

@@ -1,8 +1,18 @@
-use crate::config::{Cluster, DistributedBackend, NcclConfig, Node, Recipe};
+use crate::config::{Cluster, Node, Recipe};
+use crate::engine_flags::{EngineFlagsCtx, build_engine_args};
 use crate::tmux::TmuxManager;
 
 impl TmuxManager {
-    /// Builds the full model launch command for a given node.
+    /// Build the engine launch command for a node.
+    ///
+    /// Uses `recipe.entrypoint` when present (e.g. `["vllm", "serve"]` →
+    /// `vllm serve`), falling back to the hardcoded Python module path
+    /// when the entrypoint is empty. This lets the same recipe work
+    /// under both Docker and tmux deployment.
+    ///
+    /// Appends shared engine args (model, recipe args, distributed flags)
+    /// from [`crate::engine_flags::build_engine_args`], plus the port
+    /// flag (head node only, or all nodes for SGLang).
     pub fn build_launch_command(&self, recipe: &Recipe, node: &Node, cluster: &Cluster) -> String {
         let nnodes = cluster.nodes.len() as u32;
         let pp_size = recipe.pipeline_parallel_size.unwrap_or(nnodes);
@@ -10,78 +20,35 @@ impl TmuxManager {
 
         let mut parts: Vec<String> = Vec::new();
 
-        // Engine entrypoint
-        match recipe.engine.as_str() {
-            "vllm" => {
-                parts.push("python -m vllm.entrypoints.openai.api_server".to_string());
-            }
-            "sglang" => {
-                parts.push("python -m sglang.launch_server".to_string());
-            }
-            other => {
-                parts.push(format!("python -m {}", other));
-            }
-        }
-
-        // Model — engine-specific
-        if is_sglang {
-            parts.push("--model-path".to_string());
-        }
-        parts.push(recipe.model.clone());
-
-        // Recipe-specific args
-        parts.extend(recipe.args.to_vec());
-
-        // Distributed flags — engine-specific
-        if is_sglang {
-            parts.extend(vec![
-                "--tp".to_string(),
-                recipe.tensor_parallel_size.to_string(),
-                "--nnodes".to_string(),
-                nnodes.to_string(),
-                "--node-rank".to_string(),
-                node.rank.to_string(),
-                "--master-addr".to_string(),
-                cluster.master_ib_ip.clone(),
-            ]);
+        // Entrypoint — from recipe when present, else hardcoded Python path.
+        if !recipe.entrypoint.is_empty() {
+            parts.extend(recipe.entrypoint.clone());
         } else {
-            match recipe.distributed_backend {
-                Some(DistributedBackend::Ray) => {
-                    parts.extend(vec![
-                        "--tensor-parallel-size".to_string(),
-                        "1".to_string(),
-                        "--pipeline-parallel-size".to_string(),
-                        pp_size.to_string(),
-                        "--nnodes".to_string(),
-                        nnodes.to_string(),
-                        "--node-rank".to_string(),
-                        node.rank.to_string(),
-                        "--master-addr".to_string(),
-                        cluster.master_ib_ip.clone(),
-                        "--distributed-executor-backend".to_string(),
-                        "ray".to_string(),
-                    ]);
+            match recipe.engine.as_str() {
+                "vllm" => {
+                    parts.push("python -m vllm.entrypoints.openai.api_server".to_string());
                 }
-                _ => {
-                    parts.extend(vec![
-                        "--tensor-parallel-size".to_string(),
-                        "1".to_string(),
-                        "--pipeline-parallel-size".to_string(),
-                        pp_size.to_string(),
-                        "--nnodes".to_string(),
-                        nnodes.to_string(),
-                        "--node-rank".to_string(),
-                        node.rank.to_string(),
-                        "--master-addr".to_string(),
-                        cluster.master_ib_ip.clone(),
-                        "--distributed-executor-backend".to_string(),
-                        "mp".to_string(),
-                    ]);
+                "sglang" => {
+                    parts.push("python -m sglang.launch_server".to_string());
+                }
+                other => {
+                    parts.push(format!("python -m {}", other));
                 }
             }
         }
 
-        // Port — SGLang needs it on all nodes, vLLM only on head node
+        // Shared engine args: model, recipe args, distributed flags, headless.
+        let ectx = EngineFlagsCtx {
+            recipe,
+            tp_size: recipe.tensor_parallel_size,
+            pp_size,
+            nnodes,
+            init_port: 25000,
+            master_ib_ip: &cluster.master_ib_ip,
+        };
+        parts.extend(build_engine_args(&ectx, node));
+
+        // Port — SGLang needs it on all nodes, vLLM only on head node.
         if is_sglang || node.rank == 0 {
             parts.extend(vec!["--port".to_string(), recipe.port.to_string()]);
         }
@@ -94,7 +61,7 @@ impl TmuxManager {
         &self,
         node: &Node,
         recipe: &Recipe,
-        nccl: &NcclConfig,
+        nccl: &crate::config::NcclConfig,
         cluster: &Cluster,
     ) -> Vec<String> {
         let sess = self.session_name(recipe, node.rank);
@@ -113,7 +80,9 @@ impl TmuxManager {
         for export in self.build_host_ip_export(&recipe.engine, node) {
             cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, export));
         }
-        if recipe.distributed_backend == Some(DistributedBackend::Ray) && node.rank > 0 {
+        if recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray)
+            && node.rank > 0
+        {
             cmds.push(format!(
                 "tmux send-keys -t {}:0 'export RAY_ADDRESS={}:6379' Enter",
                 sess, cluster.master_ib_ip
@@ -150,3 +119,108 @@ impl TmuxManager {
         cmds
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::{DeployTarget, DistributedBackend, RecipeArgs};
+    use std::collections::HashMap;
+
+    fn trecipe() -> Recipe {
+        Recipe {
+            engine: "vllm".into(),
+            name: "T".into(),
+            docker_image: "img".into(),
+            model: "org/model".into(),
+            port: 8000,
+            required_nodes: None,
+            pipeline_parallel_size: Some(1),
+            tensor_parallel_size: 1,
+            args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+            env: HashMap::new(),
+            cluster: None,
+            entrypoint: vec!["vllm".into(), "serve".into()],
+            distributed_backend: Some(DistributedBackend::Mp),
+            before_serve: vec![],
+            after_stop: vec![],
+            solo: None,
+            deploy: DeployTarget::Docker,
+            mods: Default::default(),
+            shm_size: String::new(),
+            sync_image: true,
+            docker_image_dir: None,
+            extra_mounts: vec![],
+            build: Default::default(),
+            composed_model: None,
+        }
+    }
+
+    fn tcluster() -> Cluster {
+        Cluster {
+            name: "c".into(),
+            master_ib_ip: "10.0.0.1".into(),
+            nodes: vec![Node {
+                name: "n0".into(),
+                ssh_ip: "10.0.0.1".into(),
+                ib_ip: "10.0.0.1".into(),
+                rank: 0,
+                host_key: None,
+                entity: None,
+            }],
+            extra_entities: vec![],
+        }
+    }
+
+    #[test]
+    fn entrypoint_used_when_present() {
+        let r = trecipe();
+        let c = tcluster();
+        let mgr = TmuxManager;
+        let cmd = mgr.build_launch_command(&r, &c.nodes[0], &c);
+        // Should start with "vllm serve" (from entrypoint), not "python -m"
+        assert!(cmd.starts_with("vllm serve "), "cmd: {cmd}");
+        // Should include recipe args
+        assert!(cmd.contains("--x 4096"));
+    }
+
+    #[test]
+    fn entrypoint_fallback_when_empty() {
+        let mut r = trecipe();
+        r.entrypoint = vec![];
+        let c = tcluster();
+        let mgr = TmuxManager;
+        let cmd = mgr.build_launch_command(&r, &c.nodes[0], &c);
+        // Should fall back to hardcoded python module path
+        assert!(
+            cmd.starts_with("python -m vllm.entrypoints.openai.api_server "),
+            "cmd: {cmd}"
+        );
+    }
+
+    #[test]
+    fn sglang_entrypoint_fallback() {
+        let mut r = trecipe();
+        r.entrypoint = vec![];
+        r.engine = "sglang".into();
+        let c = tcluster();
+        let mgr = TmuxManager;
+        let cmd = mgr.build_launch_command(&r, &c.nodes[0], &c);
+        assert!(
+            cmd.starts_with("python -m sglang.launch_server "),
+            "cmd: {cmd}"
+        );
+    }
+
+    #[test]
+    fn single_node_no_distributed_flags() {
+        let r = trecipe();
+        let c = tcluster();
+        let mgr = TmuxManager;
+        let cmd = mgr.build_launch_command(&r, &c.nodes[0], &c);
+        // Single node → no --nnodes, --master-addr, etc.
+        assert!(!cmd.contains("--nnodes"));
+        assert!(!cmd.contains("--master-addr"));
+        // But port should be present (head node)
+        assert!(cmd.contains("--port 8000"));
+    }
+}

+ 14 - 0
src/tmux/serve.rs

@@ -19,6 +19,20 @@ pub async fn tmux_serve(
     let recipe_path = format!("recipes/{}/recipe.toml", recipe_name);
     let recipe: Recipe = toml::from_str(&tokio::fs::read_to_string(&recipe_path).await?)?;
 
+    // Feature gaps: tmux deployment doesn't support Docker-only features.
+    if !recipe.mods.commands.is_empty() {
+        anyhow::bail!(
+            "{}",
+            fluent_i18n::t!("tmux-mods-unsupported", { "name" => &recipe.name })
+        );
+    }
+    if recipe.composed_model.is_some() {
+        anyhow::bail!(
+            "{}",
+            fluent_i18n::t!("tmux-composed-model-unsupported", { "name" => &recipe.name })
+        );
+    }
+
     info!(
         "🚀 TMUX launch: {} ({}) [backend: {}] [engine: {}]",
         recipe.name,