swactor/crates/mvp-system/src/provisioning.rs
Zachery Aaron Shores-Chmielewski 6608824cb0 feat(mvp-chat): local e2e chat on cuda gpu
Stand up an interactive end-to-end chat over a CUDA GPU, provisioning a Dockerized node that loads a GGUF model and serves prompts over TCP.

- prompt_rpc: add the newline-JSON prompt protocol (`SubmitPrompt` + `PromptEvent::{TextDelta,Done,Fault}`) carried over TCP
- mvp_chat: add an interactive REPL client connecting to the prompt RPC port (default 127.0.0.1:19777)
- mvp_orch_one_node / mvp_one_node_chat: add the single-node orchestrator that provisions a `LocalDockerPlugin` node, loads `bartowski/Llama-3.2-1B-Instruct-GGUF` (Q4_K_M), and exposes the prompt RPC listener with boot/route/weight timeouts
- mvp_node: add the GPU worker binary that spawns `tinygrad_worker.py` (default device CUDA) and ships runtime telemetry via a `ClusterFrameSink`
- vastai_provisioning / bootstrap_datastream: add the vast.ai provider adapter (`VastAiProvisioningConfig`, `VastAiLeaseClient`) wrapping `swactor_vastai`, plus a bridge that folds provision stdout onto a per-node datastream
- apps/mvp-node: add CUDA base/runtime Dockerfiles (nvidia/cuda 12.6.3, tinygrad 0.12.0, sshd), `mvp_entrypoint.sh` (sshd + mvp-node, held for postmortem), `local_docker_e2e.sh`, the GGUF tinygrad worker, and one-node-chat/bootstrap/vastai guarantee tests

Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-07-01 12:44:25 +04:00

280 lines
7.6 KiB
Rust

//! Minimal provisioning model and local Docker plugin for the MVP system.
//!
//! Swactor actors own provisioning control. Plugins only perform concrete I/O and
//! report observations back to the provisioner actor through [`PluginSink`].
use std::collections::BTreeMap;
use std::io::Write;
use std::process::{ChildStdin, Command, Stdio};
use std::sync::Arc;
use std::thread;
use iroh::EndpointAddr;
use serde::{Deserialize, Serialize};
use swactor::actor::ActorAddress;
use crate::bootstrap_datastream::BootstrapDatastreamBridge;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeProvisionSpec {
pub run_id: u64,
pub node_id: u64,
pub stage_index: Option<u32>,
pub image: String,
pub env: Vec<(String, String)>,
pub args: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProvisionEvent {
pub run_id: u64,
pub node_id: u64,
pub kind: ProvisionEventKind,
pub message: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvisionEventKind {
ProvisionStart,
NodeLive,
ProvisionFailed,
NodeStopped,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProvisionLogLine {
pub run_id: u64,
pub node_id: u64,
pub stream: ProvisionLogStream,
pub line: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvisionLogStream {
Stdout,
Stderr,
Provider,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PluginObservation {
StdoutLine {
run_id: u64,
node_id: u64,
line: String,
},
StderrLine {
run_id: u64,
node_id: u64,
line: String,
},
ProviderLine {
run_id: u64,
node_id: u64,
line: String,
},
RuntimeReady {
run_id: u64,
node_id: u64,
stage_index: Option<u32>,
endpoint: EndpointAddr,
node_actor: ActorAddress,
},
Exited {
run_id: u64,
node_id: u64,
status: Option<i32>,
},
Failed {
run_id: u64,
node_id: u64,
reason: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginNodeHandle {
pub id: u64,
pub provider_process_id: Option<u32>,
}
pub trait PluginObservationSink: Send + Sync {
fn observe(&self, observation: PluginObservation);
}
#[derive(Clone)]
pub struct PluginSink {
inner: Arc<dyn PluginObservationSink>,
}
impl PluginSink {
pub fn new(inner: Arc<dyn PluginObservationSink>) -> Self {
Self { inner }
}
pub fn observe(&self, observation: PluginObservation) {
self.inner.observe(observation);
}
}
pub trait ProvisionPlugin: Send {
fn start_node(
&mut self,
spec: NodeProvisionSpec,
sink: PluginSink,
) -> Result<PluginNodeHandle, String>;
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String>;
}
pub struct LocalDockerPlugin {
container_name_prefix: String,
next_handle_id: u64,
nodes: BTreeMap<u64, LocalDockerNode>,
}
struct LocalDockerNode {
container_name: String,
stdin: ChildStdin,
}
impl LocalDockerPlugin {
pub fn new(container_name_prefix: impl Into<String>) -> Self {
Self {
container_name_prefix: container_name_prefix.into(),
next_handle_id: 1,
nodes: BTreeMap::new(),
}
}
}
impl ProvisionPlugin for LocalDockerPlugin {
fn start_node(
&mut self,
spec: NodeProvisionSpec,
sink: PluginSink,
) -> Result<PluginNodeHandle, String> {
let container_name = format!(
"{}-{}-{}",
self.container_name_prefix, spec.run_id, spec.node_id
);
let mut command = Command::new("docker");
command
.arg("run")
.arg("--rm")
.arg("--add-host")
.arg("host.docker.internal:host-gateway")
.arg("--name")
.arg(&container_name)
.arg("-i");
let spec_gpus = spec
.env
.iter()
.find(|(key, _)| key == "MVP_DOCKER_GPUS")
.map(|(_, value)| value.clone());
if let Some(gpus) = spec_gpus
.or_else(|| std::env::var("MVP_DOCKER_GPUS").ok())
.filter(|value| !value.trim().is_empty())
{
command.arg("--gpus").arg(gpus);
}
for (key, value) in &spec.env {
command.arg("-e").arg(format!("{key}={value}"));
}
command.arg(&spec.image);
for arg in &spec.args {
command.arg(arg);
}
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("spawn Docker node {}: {e}", spec.node_id))?;
let provider_process_id = child.id();
let stdin = child
.stdin
.take()
.ok_or_else(|| format!("Docker node {} stdin missing", spec.node_id))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| format!("Docker node {} stdout missing", spec.node_id))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| format!("Docker node {} stderr missing", spec.node_id))?;
let handle = PluginNodeHandle {
id: self.next_handle_id,
provider_process_id: Some(provider_process_id),
};
self.next_handle_id = self.next_handle_id.wrapping_add(1).max(1);
self.nodes.insert(
handle.id,
LocalDockerNode {
container_name: container_name.clone(),
stdin,
},
);
spawn_stdout_reader(spec.clone(), sink.clone(), stdout);
spawn_stderr_reader(spec.clone(), sink.clone(), stderr);
thread::spawn(move || match child.wait() {
Ok(status) => sink.observe(PluginObservation::Exited {
run_id: spec.run_id,
node_id: spec.node_id,
status: status.code(),
}),
Err(error) => sink.observe(PluginObservation::Failed {
run_id: spec.run_id,
node_id: spec.node_id,
reason: format!("wait Docker node: {error}"),
}),
});
Ok(handle)
}
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
let Some(mut node) = self.nodes.remove(&handle.id) else {
return Ok(());
};
let _ = writeln!(node.stdin, "shutdown");
let _ = node.stdin.flush();
let status = Command::new("docker")
.arg("stop")
.arg("-t")
.arg("2")
.arg(&node.container_name)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|e| format!("docker stop {}: {e}", node.container_name))?;
if status.success() {
Ok(())
} else {
Err(format!(
"docker stop {} exited with {status}",
node.container_name
))
}
}
}
fn spawn_stdout_reader(
spec: NodeProvisionSpec,
sink: PluginSink,
stdout: impl std::io::Read + Send + 'static,
) {
BootstrapDatastreamBridge::new(spec, sink, None).spawn_stdout_reader(stdout);
}
fn spawn_stderr_reader(
spec: NodeProvisionSpec,
sink: PluginSink,
stderr: impl std::io::Read + Send + 'static,
) {
BootstrapDatastreamBridge::new(spec, sink, None).spawn_stderr_reader(stderr);
}