Skip to main content

vtcode_core/pods/
manager.rs

1use super::catalog::{PodCatalog, PodProfile};
2use super::state::{PodGpu, PodHealth, PodState, PodsState, RunningModel};
3use super::store::PodsStore;
4use super::transport::{PodTransport, SshTransport};
5use anyhow::{Context, Result, anyhow};
6use parking_lot::RwLock;
7use std::collections::BTreeMap;
8use std::fmt::Write;
9use std::sync::Arc;
10
11const DEFAULT_START_PORT: u16 = 8001;
12const DEFAULT_LOG_DIR: &str = ".vllm_logs";
13
14/// Request payload for starting a model on a pod.
15#[derive(Debug, Clone)]
16pub struct PodStartRequest {
17    /// Name of the target pod (uses the active pod when `None`).
18    pub pod_name: Option<String>,
19    /// SSH connection string for the pod.
20    pub ssh: Option<String>,
21    /// GPU inventory for the pod.
22    pub gpus: Vec<PodGpu>,
23    /// Path on the pod where model weights are stored.
24    pub models_path: Option<String>,
25    /// User-facing name for this model instance.
26    pub name: String,
27    /// Hugging Face model identifier.
28    pub model: String,
29    /// Explicit profile name to use (auto-resolved when `None`).
30    pub profile: Option<String>,
31    /// Override for the number of GPUs to allocate.
32    pub requested_gpu_count: Option<usize>,
33    /// GPU memory utilization percentage (0.0 -- 100.0).
34    pub memory: Option<f32>,
35    /// Maximum context length (e.g. `"32k"` or `"131072"`).
36    pub context: Option<String>,
37}
38
39/// Result of a successful model launch.
40#[derive(Debug, Clone)]
41pub struct PodStartResult {
42    /// Updated pod state after launch.
43    pub pod: PodState,
44    /// Metadata for the newly running model.
45    pub entry: RunningModel,
46    /// The resolved deployment profile used for the launch.
47    pub profile: PodProfile,
48    /// The shell command that was executed to start the model.
49    pub launch_command: String,
50}
51
52/// Row returned by `pods list`.
53#[derive(Debug, Clone)]
54pub struct PodListEntry {
55    /// User-facing model instance name.
56    pub name: String,
57    /// Hugging Face model identifier.
58    pub model: String,
59    /// Port the model server is listening on.
60    pub port: u16,
61    /// Remote process ID.
62    pub pid: u32,
63    /// IDs of GPUs allocated to this model.
64    pub gpu_ids: Vec<u32>,
65    /// Current health status.
66    pub status: PodHealth,
67}
68
69/// Row returned by `pods known-models`.
70#[derive(Debug, Clone)]
71pub struct PodStatusDetail {
72    /// Profile name.
73    pub name: String,
74    /// Hugging Face model identifier.
75    pub model: String,
76    /// Number of GPUs required by this profile.
77    pub gpu_count: usize,
78}
79
80/// Split known models into compatible and incompatible groups.
81#[derive(Debug, Clone)]
82pub struct KnownModelsReport {
83    /// Profiles that can run on the active pod.
84    pub compatible: Vec<PodStatusDetail>,
85    /// Profiles that require more GPUs than the active pod provides.
86    pub incompatible: Vec<PodStatusDetail>,
87}
88
89/// `pods list` report.
90#[derive(Debug, Clone)]
91pub struct PodStatusReport {
92    /// Name of the active pod.
93    pub pod_name: String,
94    /// Running model entries on the pod.
95    pub entries: Vec<PodListEntry>,
96}
97
98/// Pod manager coordinating persisted state, catalog lookup, and SSH execution.
99#[derive(Clone)]
100pub struct PodManager {
101    store: PodsStore,
102    transport: Arc<dyn PodTransport>,
103    cached_state: Arc<RwLock<Option<PodsState>>>,
104    cached_catalog: Arc<RwLock<Option<PodCatalog>>>,
105}
106
107impl PodManager {
108    /// Create a new `PodManager` using the default store and SSH transport.
109    pub fn new() -> Result<Self> {
110        Ok(Self::with_transport(PodsStore::default_store()?, Arc::new(SshTransport)))
111    }
112
113    /// Create a `PodManager` with an explicit store and transport (useful for testing).
114    pub fn with_transport(store: PodsStore, transport: Arc<dyn PodTransport>) -> Self {
115        Self {
116            store,
117            transport,
118            cached_state: Arc::new(RwLock::new(None)),
119            cached_catalog: Arc::new(RwLock::new(None)),
120        }
121    }
122
123    /// Load the current pod state, returning a cached copy when available.
124    pub async fn load_state(&self) -> Result<PodsState> {
125        if let Some(state) = self.cached_state.read().clone() {
126            return Ok(state);
127        }
128
129        let state = self.store.load_state().await?;
130        *self.cached_state.write() = Some(state.clone());
131        Ok(state)
132    }
133
134    /// Load the deployment catalog, returning a cached copy when available.
135    pub async fn load_catalog(&self) -> Result<PodCatalog> {
136        if let Some(catalog) = self.cached_catalog.read().clone() {
137            return Ok(catalog);
138        }
139
140        let catalog = self.store.load_catalog().await?;
141        *self.cached_catalog.write() = Some(catalog.clone());
142        Ok(catalog)
143    }
144
145    /// Start a model on the active pod, uploading launch scripts via SSH.
146    pub async fn start_model(&self, request: PodStartRequest) -> Result<PodStartResult> {
147        let mut state = self.load_state().await?;
148        let catalog = self.load_catalog().await?;
149        let pod = self.resolve_active_pod(&mut state, &request).await?;
150        let profile = self.resolve_profile(&catalog, &pod, &request)?;
151        let gpu_count = request.requested_gpu_count.unwrap_or(profile.gpu_count).max(1);
152
153        if gpu_count > pod.gpu_count() {
154            return Err(anyhow!("requested {} GPUs but pod '{}' only has {}", gpu_count, pod.name, pod.gpu_count()));
155        }
156
157        let selected_gpu_ids = select_gpus(&pod, gpu_count);
158        let port = next_port(&pod);
159        let sanitized_name = sanitize_component(&request.name);
160        let run_path = format!("/tmp/model_run_{sanitized_name}.sh");
161        let wrapper_path = format!("/tmp/model_wrapper_{sanitized_name}.sh");
162        let log_path = format!("~/{DEFAULT_LOG_DIR}/{sanitized_name}.log");
163        let vllm_args = render_args(&profile.vllm_args, request.memory, request.context.as_deref())?;
164        let run_script = render_run_script(
165            &profile,
166            &request.model,
167            &request.name,
168            port,
169            &vllm_args,
170            &selected_gpu_ids,
171            pod.models_path.as_deref(),
172        );
173        let wrapper_script = render_wrapper_script(&run_path, &log_path);
174
175        self.transport.write_file(&pod.ssh, &run_path, &run_script).await?;
176        self.transport.write_file(&pod.ssh, &wrapper_path, &wrapper_script).await?;
177
178        let chmod = self
179            .transport
180            .exec_capture(&pod.ssh, &format!("chmod +x {run_path} {wrapper_path}"))
181            .await?;
182        if !chmod.success {
183            return Err(anyhow!("failed to chmod remote scripts: {}", chmod.stderr));
184        }
185
186        let launch_command = format!(
187            "mkdir -p ~/{DEFAULT_LOG_DIR} && find ~/{DEFAULT_LOG_DIR} -name '*.log.old' -mtime +90 -delete 2>/dev/null; setsid {wrapper_path} >/dev/null 2>&1 < /dev/null & echo $!"
188        );
189        let launch = self.transport.exec_capture(&pod.ssh, &launch_command).await?;
190        if !launch.success {
191            return Err(anyhow!("failed to launch remote model: {}", launch.stderr));
192        }
193
194        let pid = parse_pid(&launch.stdout)?;
195        let entry = RunningModel {
196            model: request.model.clone(),
197            port,
198            gpu_ids: selected_gpu_ids.clone(),
199            pid,
200            profile: profile.name.clone(),
201        };
202
203        let mut updated_pod = pod.clone();
204        updated_pod.models.insert(request.name.clone(), entry.clone());
205        state.active_pod = Some(updated_pod.clone());
206        self.persist_state(&state).await?;
207
208        Ok(PodStartResult { pod: updated_pod, entry, profile, launch_command })
209    }
210
211    /// Stop a running model by name, sending `SIGTERM` followed by `SIGKILL`.
212    pub async fn stop_model(&self, name: &str) -> Result<Option<RunningModel>> {
213        let mut state = self.load_state().await?;
214        let Some(pod) = state.active_pod.as_mut() else {
215            return Ok(None);
216        };
217
218        let Some(entry) = pod.models.remove(name) else {
219            return Ok(None);
220        };
221
222        let command = format!("pkill -TERM -P {} || true; kill {} || true", entry.pid, entry.pid);
223        let output = self.transport.exec_capture(&pod.ssh, &command).await?;
224        if !output.success {
225            return Err(anyhow!("failed to stop model '{}': {}", name, output.stderr));
226        }
227
228        self.persist_state(&state).await?;
229        Ok(Some(entry))
230    }
231
232    /// Stop every running model on the active pod, returning the count stopped.
233    pub async fn stop_all_models(&self) -> Result<usize> {
234        let mut state = self.load_state().await?;
235        let Some(pod) = state.active_pod.as_mut() else {
236            return Ok(0);
237        };
238
239        let pids = pod.models.values().map(|entry| entry.pid.to_string()).collect::<Vec<_>>();
240
241        if pids.is_empty() {
242            return Ok(0);
243        }
244
245        let command =
246            format!("for PID in {}; do pkill -TERM -P \"$PID\" || true; kill \"$PID\" || true; done", pids.join(" "));
247        let output = self.transport.exec_capture(&pod.ssh, &command).await?;
248        if !output.success {
249            return Err(anyhow!("failed to stop models: {}", output.stderr));
250        }
251
252        let stopped = pod.models.len();
253        pod.models.clear();
254        self.persist_state(&state).await?;
255        Ok(stopped)
256    }
257
258    /// List all running models on the active pod with their health status.
259    pub async fn list_models(&self) -> Result<PodStatusReport> {
260        let state = self.load_state().await?;
261        let Some(pod) = state.active_pod.as_ref() else {
262            return Err(anyhow!("no active pod configured"));
263        };
264
265        let mut entries = Vec::new();
266        for (name, model) in &pod.models {
267            let status = self.inspect_model(pod, name, model).await?;
268            entries.push(PodListEntry {
269                name: name.clone(),
270                model: model.model.clone(),
271                port: model.port,
272                pid: model.pid,
273                gpu_ids: model.gpu_ids.clone(),
274                status,
275            });
276        }
277
278        Ok(PodStatusReport { pod_name: pod.name.clone(), entries })
279    }
280
281    /// Stream the log output of a running model to the terminal via SSH.
282    pub async fn stream_logs(&self, name: &str) -> Result<()> {
283        let state = self.load_state().await?;
284        let Some(pod) = state.active_pod.as_ref() else {
285            return Err(anyhow!("no active pod configured"));
286        };
287        let Some(entry) = pod.models.get(name) else {
288            return Err(anyhow!("unknown model '{name}'"));
289        };
290
291        let log_path = format!("~/{DEFAULT_LOG_DIR}/{}.log", sanitize_component(name));
292        let command = format!("tail -f {log_path}");
293        let _ = entry;
294        self.transport.exec_stream(&pod.ssh, &command).await
295    }
296
297    /// Return the catalog profiles split into compatible and incompatible with the active pod.
298    pub async fn known_models(&self) -> Result<KnownModelsReport> {
299        let state = self.load_state().await?;
300        let Some(pod) = state.active_pod.as_ref() else {
301            return Err(anyhow!("no active pod configured"));
302        };
303        let catalog = self.load_catalog().await?;
304        let (compatible, incompatible) = catalog.compatible_profiles(pod);
305
306        Ok(KnownModelsReport {
307            compatible: compatible
308                .into_iter()
309                .map(|profile| PodStatusDetail {
310                    name: profile.name.clone(),
311                    model: profile.model.clone(),
312                    gpu_count: profile.gpu_count,
313                })
314                .collect(),
315            incompatible: incompatible
316                .into_iter()
317                .map(|profile| PodStatusDetail {
318                    name: profile.name.clone(),
319                    model: profile.model.clone(),
320                    gpu_count: profile.gpu_count,
321                })
322                .collect(),
323        })
324    }
325
326    async fn persist_state(&self, state: &PodsState) -> Result<()> {
327        self.store.save_state(state).await?;
328        *self.cached_state.write() = Some(state.clone());
329        Ok(())
330    }
331
332    async fn resolve_active_pod(&self, state: &mut PodsState, request: &PodStartRequest) -> Result<PodState> {
333        let mut pod = state.active_pod.clone().unwrap_or_else(|| PodState {
334            name: request.pod_name.clone().unwrap_or_else(|| "active-pod".to_string()),
335            ssh: request.ssh.clone().unwrap_or_default(),
336            models_path: request.models_path.clone(),
337            gpus: Vec::new(),
338            models: BTreeMap::new(),
339        });
340
341        if let Some(name) = &request.pod_name {
342            pod.name = name.clone();
343        }
344        if let Some(ssh) = &request.ssh {
345            pod.ssh = ssh.clone();
346        }
347        if let Some(models_path) = &request.models_path {
348            pod.models_path = Some(models_path.clone());
349        }
350        if !request.gpus.is_empty() {
351            pod.gpus = request.gpus.clone();
352        }
353
354        if pod.ssh.is_empty() {
355            return Err(anyhow!("pod ssh command is required; pass --ssh or reuse the active pod"));
356        }
357        if pod.gpus.is_empty() {
358            return Err(anyhow!("pod gpu inventory is required; pass --gpu entries or reuse the active pod"));
359        }
360
361        state.active_pod = Some(pod.clone());
362        self.persist_state(state).await?;
363        Ok(pod)
364    }
365
366    fn resolve_profile(&self, catalog: &PodCatalog, pod: &PodState, request: &PodStartRequest) -> Result<PodProfile> {
367        if let Some(profile_name) = request.profile.as_deref() {
368            let profile = catalog
369                .profiles
370                .iter()
371                .find(|profile| profile.name == profile_name)
372                .cloned()
373                .ok_or_else(|| anyhow!("unknown pod profile '{profile_name}'"))?;
374            return Ok(profile);
375        }
376
377        let mut candidates = catalog.profiles_for_model(&request.model);
378        candidates.retain(|profile| profile.matches_pod(pod));
379
380        if let Some(requested_gpu_count) = request.requested_gpu_count {
381            if let Some(profile) = candidates
382                .iter()
383                .copied()
384                .find(|profile| profile.matches_gpu_count(requested_gpu_count))
385            {
386                return Ok(profile.clone());
387            }
388
389            if !candidates.is_empty() {
390                let valid_counts = candidates
391                    .iter()
392                    .map(|profile| profile.gpu_count.to_string())
393                    .collect::<Vec<_>>();
394                return Err(anyhow!(
395                    "no profile for '{}' with {} GPUs; valid counts: {}",
396                    request.model,
397                    requested_gpu_count,
398                    valid_counts.join(", ")
399                ));
400            }
401        }
402
403        candidates
404            .into_iter()
405            .max_by_key(|profile| profile.gpu_count)
406            .cloned()
407            .or_else(|| {
408                if request.model.is_empty() {
409                    None
410                } else {
411                    Some(PodProfile {
412                        name: request.model.clone(),
413                        model: request.model.clone(),
414                        gpu_count: request.requested_gpu_count.unwrap_or(1),
415                        gpu_types: Vec::new(),
416                        command_template: default_command_template(),
417                        vllm_args: vec![
418                            "--trust-remote-code".to_string(),
419                            "--dtype".to_string(),
420                            "auto".to_string(),
421                        ],
422                        env: BTreeMap::new(),
423                    })
424                }
425            })
426            .ok_or_else(|| anyhow!("no profile found for model '{}'", request.model))
427    }
428
429    async fn inspect_model(&self, pod: &PodState, name: &str, entry: &RunningModel) -> Result<PodHealth> {
430        let process = self.transport.exec_capture(&pod.ssh, &format!("ps -p {}", entry.pid)).await?;
431        let health = self
432            .transport
433            .exec_capture(&pod.ssh, &format!("curl -s -f http://localhost:{}/health", entry.port))
434            .await?;
435        let log_tail = self
436            .transport
437            .exec_capture(&pod.ssh, &format!("tail -n 20 ~/{DEFAULT_LOG_DIR}/{}.log", sanitize_component(name)))
438            .await?;
439
440        Ok(classify_status(process.success, health.success, &log_tail.stdout))
441    }
442}
443
444impl Default for PodManager {
445    fn default() -> Self {
446        Self::new().expect("pod manager should initialize with a home directory")
447    }
448}
449
450fn render_run_script(
451    profile: &PodProfile,
452    model: &str,
453    name: &str,
454    port: u16,
455    vllm_args: &[String],
456    gpu_ids: &[u32],
457    models_path: Option<&str>,
458) -> String {
459    let mut script = String::new();
460    script.push_str("#!/usr/bin/env bash\n");
461    script.push_str("set -euo pipefail\n");
462    script.push_str("export HF_HUB_ENABLE_HF_TRANSFER=1\n");
463    script.push_str("export VLLM_NO_USAGE_STATS=1\n");
464    script.push_str("export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True\n");
465    script.push_str("export FORCE_COLOR=1\n");
466    script.push_str("export TERM=xterm-256color\n");
467
468    for (key, value) in &profile.env {
469        let _ = writeln!(script, "export {key}={}", shell_quote(value));
470    }
471
472    if gpu_ids.len() == 1 {
473        let _ = writeln!(script, "export CUDA_VISIBLE_DEVICES={}", gpu_ids[0]);
474    }
475
476    if let Some(models_path) = models_path {
477        let _ = writeln!(script, "export MODELS_PATH={}", shell_quote(models_path));
478    }
479
480    let command = render_template(&profile.command_template, model, name, port, &join_args(vllm_args));
481    let _ = writeln!(script, "exec {command}");
482    script
483}
484
485fn render_wrapper_script(run_path: &str, log_path: &str) -> String {
486    format!(
487        r#"#!/usr/bin/env bash
488set -euo pipefail
489mkdir -p ~/.vllm_logs
490LOG_PATH={log_path}
491# Rotate log if it exceeds 100MB to prevent unbounded disk usage
492MAX_LOG_BYTES=104857600
493if [ -f "$LOG_PATH" ] && [ "$(stat -f%z "$LOG_PATH" 2>/dev/null || echo 0)" -ge $MAX_LOG_BYTES ] 2>/dev/null; then
494    mv "$LOG_PATH" "$LOG_PATH.old" 2>/dev/null || true
495fi
496script -q -f -c {run_path} "$LOG_PATH"
497"#
498    )
499}
500
501fn render_template(template: &str, model: &str, name: &str, port: u16, vllm_args: &str) -> String {
502    template
503        .replace("{{MODEL_ID}}", model)
504        .replace("{{NAME}}", name)
505        .replace("{{PORT}}", &port.to_string())
506        .replace("{{VLLM_ARGS}}", vllm_args)
507}
508
509fn join_args(args: &[String]) -> String {
510    args.iter().map(|value| shell_quote(value)).collect::<Vec<_>>().join(" ")
511}
512
513fn shell_quote(value: &str) -> String {
514    if value.is_empty() {
515        return "''".to_string();
516    }
517
518    if value
519        .chars()
520        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':' | ',' | '=' | '@'))
521    {
522        return value.to_string();
523    }
524
525    format!("'{}'", value.replace('\'', r#"'"'"'"#))
526}
527
528fn select_gpus(pod: &PodState, count: usize) -> Vec<u32> {
529    if count >= pod.gpus.len() {
530        return pod.gpus.iter().map(|gpu| gpu.id).collect();
531    }
532
533    let mut usage: BTreeMap<u32, usize> = pod.gpus.iter().map(|gpu| (gpu.id, 0)).collect();
534    for model in pod.models.values() {
535        for gpu_id in &model.gpu_ids {
536            if let Some(slot) = usage.get_mut(gpu_id) {
537                *slot += 1;
538            }
539        }
540    }
541
542    let mut gpus = pod.gpus.clone();
543    gpus.sort_by_key(|gpu| (usage.get(&gpu.id).copied().unwrap_or(0), gpu.id));
544    gpus.into_iter().take(count).map(|gpu| gpu.id).collect()
545}
546
547fn next_port(pod: &PodState) -> u16 {
548    let mut port = DEFAULT_START_PORT;
549    let occupied: std::collections::HashSet<u16> = pod.models.values().map(|model| model.port).collect();
550    while occupied.contains(&port) {
551        port = port.saturating_add(1);
552    }
553    port
554}
555
556fn parse_pid(output: &str) -> Result<u32> {
557    let pid = output
558        .trim()
559        .lines()
560        .find_map(|line| line.trim().parse::<u32>().ok())
561        .ok_or_else(|| anyhow!("launch command did not return a pid"))?;
562    Ok(pid)
563}
564
565fn sanitize_component(name: &str) -> String {
566    let mut out = String::with_capacity(name.len());
567    for ch in name.chars() {
568        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
569            out.push(ch);
570        } else {
571            out.push('_');
572        }
573    }
574    if out.is_empty() { "model".to_string() } else { out }
575}
576
577fn classify_status(process_alive: bool, health_ok: bool, log_tail: &str) -> PodHealth {
578    if process_alive && health_ok {
579        return PodHealth::Running;
580    }
581
582    let lower = log_tail.to_lowercase();
583    let failed = lower.contains("model runner exiting with code")
584        || lower.contains("script exited with code")
585        || lower.contains("torch.outofmemoryerror")
586        || lower.contains("cuda out of memory")
587        || lower.contains("runtimeerror: engine core initialization failed");
588
589    if failed {
590        PodHealth::Crashed
591    } else if process_alive {
592        PodHealth::Starting
593    } else {
594        PodHealth::Dead
595    }
596}
597
598fn render_args(args: &[String], memory: Option<f32>, context: Option<&str>) -> Result<Vec<String>> {
599    let mut rendered = Vec::new();
600    let mut index = 0;
601    while index < args.len() {
602        let arg = &args[index];
603        if arg == "--gpu-memory-utilization" {
604            index += 2;
605            continue;
606        }
607        if arg == "--max-model-len" {
608            index += 2;
609            continue;
610        }
611        rendered.push(arg.clone());
612        index += 1;
613    }
614
615    if let Some(memory) = memory {
616        let utilization = (memory / 100.0).clamp(0.0, 1.0);
617        rendered.push("--gpu-memory-utilization".to_string());
618        rendered.push(format!("{utilization:.2}"));
619    }
620
621    if let Some(context) = context {
622        rendered.push("--max-model-len".to_string());
623        rendered.push(parse_context_size(context)?.to_string());
624    }
625
626    Ok(rendered)
627}
628
629fn parse_context_size(value: &str) -> Result<u32> {
630    let trimmed = value.trim().to_lowercase();
631    if let Some(stripped) = trimmed.strip_suffix('k') {
632        return Ok(stripped
633            .parse::<u32>()
634            .with_context(|| format!("invalid context size '{value}'"))?
635            .saturating_mul(1024));
636    }
637
638    trimmed
639        .parse::<u32>()
640        .with_context(|| format!("invalid context size '{value}'"))
641}
642
643fn default_command_template() -> String {
644    "vllm serve {{MODEL_ID}} --served-model-name {{NAME}} --port {{PORT}} {{VLLM_ARGS}}".to_string()
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::pods::catalog::PodProfile;
651    use crate::pods::state::PodGpu;
652    use crate::pods::transport::CommandOutput;
653    use anyhow::Result;
654    use async_trait::async_trait;
655    use parking_lot::Mutex;
656    use std::collections::VecDeque;
657
658    #[derive(Clone, Default)]
659    struct MockTransport {
660        commands: Arc<Mutex<Vec<String>>>,
661        writes: Arc<Mutex<Vec<(String, String)>>>,
662        responses: Arc<Mutex<VecDeque<CommandOutput>>>,
663    }
664
665    #[async_trait]
666    impl PodTransport for MockTransport {
667        async fn exec_capture(&self, _ssh_target: &str, command: &str) -> Result<CommandOutput> {
668            self.commands.lock().push(command.to_string());
669            Ok(self.responses.lock().pop_front().unwrap_or_else(|| CommandOutput {
670                success: true,
671                stdout: "12345\n".to_string(),
672                stderr: String::new(),
673            }))
674        }
675
676        async fn write_file(&self, _ssh_target: &str, remote_path: &str, contents: &str) -> Result<()> {
677            self.writes.lock().push((remote_path.to_string(), contents.to_string()));
678            Ok(())
679        }
680
681        async fn exec_stream(&self, _ssh_target: &str, command: &str) -> Result<()> {
682            self.commands.lock().push(command.to_string());
683            Ok(())
684        }
685    }
686
687    #[test]
688    fn context_parser_handles_shorthand() {
689        assert_eq!(parse_context_size("32k").expect("parse"), 32_768);
690        assert_eq!(parse_context_size("131072").expect("parse"), 131_072);
691    }
692
693    #[test]
694    fn classify_status_detects_failure_patterns() {
695        assert_eq!(classify_status(true, false, "RuntimeError: Engine core initialization failed"), PodHealth::Crashed);
696        assert_eq!(classify_status(false, false, ""), PodHealth::Dead);
697        assert_eq!(classify_status(true, false, ""), PodHealth::Starting);
698    }
699
700    #[test]
701    fn select_gpus_prefers_less_loaded_devices() {
702        let pod = PodState {
703            name: "pod".to_string(),
704            ssh: "ssh root@example.com".to_string(),
705            models_path: None,
706            gpus: vec![
707                PodGpu { id: 0, name: "A100".to_string() },
708                PodGpu { id: 1, name: "A100".to_string() },
709            ],
710            models: BTreeMap::from([(
711                "existing".to_string(),
712                RunningModel {
713                    model: "model".to_string(),
714                    port: 8001,
715                    gpu_ids: vec![0],
716                    pid: 1,
717                    profile: "profile".to_string(),
718                },
719            )]),
720        };
721
722        assert_eq!(select_gpus(&pod, 1), vec![1]);
723    }
724
725    #[tokio::test]
726    async fn render_and_launch_flow_updates_state() {
727        let store = PodsStore::new(std::env::temp_dir().join(format!("vtcode-pods-start-test-{}", std::process::id())));
728        let transport = Arc::new(MockTransport::default());
729        let manager = PodManager::with_transport(store, transport.clone());
730        let state = PodsState {
731            version: env!("CARGO_PKG_VERSION").to_string(),
732            active_pod: Some(PodState {
733                name: "gpu-box".to_string(),
734                ssh: "ssh root@example.com".to_string(),
735                models_path: Some("/models".to_string()),
736                gpus: vec![PodGpu { id: 0, name: "A100".to_string() }],
737                models: BTreeMap::new(),
738            }),
739        };
740        manager.store.save_state(&state).await.expect("save");
741        manager
742            .store
743            .save_catalog(&PodCatalog {
744                version: "1".to_string(),
745                profiles: vec![PodProfile {
746                    name: "test".to_string(),
747                    model: "test/model".to_string(),
748                    gpu_count: 1,
749                    gpu_types: vec!["A100".to_string()],
750                    command_template: default_command_template(),
751                    vllm_args: vec!["--max-model-len".to_string(), "4096".to_string()],
752                    env: BTreeMap::new(),
753                }],
754            })
755            .await
756            .expect("save catalog");
757
758        let result = manager
759            .start_model(PodStartRequest {
760                pod_name: None,
761                ssh: None,
762                gpus: Vec::new(),
763                models_path: None,
764                name: "local".to_string(),
765                model: "test/model".to_string(),
766                profile: None,
767                requested_gpu_count: None,
768                memory: Some(75.0),
769                context: Some("4k".to_string()),
770            })
771            .await
772            .expect("start");
773
774        assert_eq!(result.entry.pid, 12345);
775        assert!(
776            transport
777                .writes
778                .lock()
779                .iter()
780                .any(|(path, contents)| path.contains("model_run_local.sh") && contents.contains("vllm serve"))
781        );
782    }
783
784    #[test]
785    fn memory_override_rewrites_existing_argument() {
786        let args = render_args(
787            &[
788                "--dtype".to_string(),
789                "auto".to_string(),
790                "--gpu-memory-utilization".to_string(),
791                "0.80".to_string(),
792            ],
793            Some(90.0),
794            None,
795        )
796        .expect("render args");
797
798        assert!(args.windows(2).any(|pair| pair == ["--gpu-memory-utilization", "0.90"]));
799    }
800}