2026-07-22 07:50:53 +00:00
|
|
|
use std::fs;
|
|
|
|
|
use std::io::{Read, Write};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::process::{Child, Command, ExitCode, ExitStatus, Stdio};
|
|
|
|
|
use std::thread;
|
|
|
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
|
|
|
|
|
|
use serde_json::Value;
|
2026-02-15 17:03:31 +00:00
|
|
|
|
|
|
|
|
struct TestStep {
|
|
|
|
|
label: &'static str,
|
|
|
|
|
args: &'static [&'static str],
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 07:50:53 +00:00
|
|
|
const MVP_CHAT_CHECK_TIMEOUT_SECS: u64 = 900;
|
|
|
|
|
const MVP_CHAT_CHECK_POLL_MS: u64 = 100;
|
|
|
|
|
const MVP_CHAT_CHECK_TERM_GRACE_MS: u64 = 2_000;
|
|
|
|
|
const MVP_CHAT_CHECK_PROMPTS: &[u8] = b"ping\nsecond prompt\n";
|
|
|
|
|
|
|
|
|
|
struct MvpChatCheckPaths {
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
dump_log: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct MvpChatCheckOutput {
|
|
|
|
|
status: ExitStatus,
|
|
|
|
|
stdout: String,
|
|
|
|
|
stderr: String,
|
|
|
|
|
timed_out: bool,
|
|
|
|
|
stdin_error: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
const BASIC_TESTS: &[TestStep] = &[
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "root crate",
|
|
|
|
|
args: &["test"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "datastream",
|
|
|
|
|
args: &["test", "-p", "datastream"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "distribution",
|
|
|
|
|
args: &["test", "-p", "distribution"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "iroh-driver",
|
|
|
|
|
args: &["test", "-p", "iroh-driver"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "mvp-system",
|
|
|
|
|
args: &["test", "-p", "mvp-system"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "swactor-process",
|
|
|
|
|
args: &["test", "-p", "swactor-process"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "swactor-transport",
|
|
|
|
|
args: &["test", "-p", "swactor-transport"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "dashboard",
|
|
|
|
|
args: &["test", "-p", "dashboard"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "swactor-vastai",
|
|
|
|
|
args: &["test", "-p", "swactor-vastai"],
|
|
|
|
|
},
|
|
|
|
|
TestStep {
|
|
|
|
|
label: "xtask",
|
|
|
|
|
args: &["test", "-p", "xtask"],
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-02-24 09:12:28 +00:00
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
fn cargo_bin() -> String {
|
|
|
|
|
option_env!("CARGO")
|
|
|
|
|
.map(str::to_string)
|
|
|
|
|
.unwrap_or_else(|| "cargo".to_string())
|
2026-02-15 17:03:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_usage() {
|
|
|
|
|
println!(
|
|
|
|
|
"\
|
2026-07-05 09:59:51 +00:00
|
|
|
USAGE: cargo xtask <command>
|
2026-02-15 17:03:31 +00:00
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
COMMANDS:
|
2026-07-15 08:49:56 +00:00
|
|
|
mvp-chat [--process|--docker|--vastai] [--pipeline-stages n] [--cached-model] [-- args...] Run the human chat wrapper against the real orchestrator/worker bins.
|
2026-07-22 07:50:53 +00:00
|
|
|
mvp-chat-check Run real cargo mvp-chat acceptance check.
|
|
|
|
|
test Run the basic non-binding test barrier: root crate plus each
|
|
|
|
|
non-binding repository package with `cargo test -p`."
|
2026-02-15 17:03:31 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
fn run_step(step: &TestStep) -> bool {
|
|
|
|
|
println!("\n=== {} ===", step.label);
|
|
|
|
|
println!(" cargo {}", step.args.join(" "));
|
|
|
|
|
println!();
|
2026-02-15 17:03:31 +00:00
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
match Command::new(cargo_bin()).args(step.args).status() {
|
|
|
|
|
Ok(status) => status.success(),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
eprintln!("Failed to execute cargo: {error}");
|
|
|
|
|
false
|
2026-02-15 17:03:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
fn run_tests() -> ExitCode {
|
2026-02-15 17:03:31 +00:00
|
|
|
let start = Instant::now();
|
2026-07-22 07:50:53 +00:00
|
|
|
let check = run_mvp_chat_check();
|
|
|
|
|
if check != ExitCode::SUCCESS {
|
|
|
|
|
return check;
|
|
|
|
|
}
|
2026-02-15 17:03:31 +00:00
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
for (index, step) in BASIC_TESTS.iter().enumerate() {
|
|
|
|
|
if !run_step(step) {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"\n--- FAILED after {:.1}s ({index} passed, 1 failed) ---",
|
|
|
|
|
start.elapsed().as_secs_f64()
|
|
|
|
|
);
|
|
|
|
|
return ExitCode::from(1);
|
2026-02-15 17:03:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!(
|
2026-06-25 12:30:18 +00:00
|
|
|
"\n--- All {} step(s) passed in {:.1}s ---",
|
|
|
|
|
BASIC_TESTS.len(),
|
|
|
|
|
start.elapsed().as_secs_f64()
|
2026-02-15 17:03:31 +00:00
|
|
|
);
|
2026-06-25 12:30:18 +00:00
|
|
|
ExitCode::SUCCESS
|
2026-02-15 17:03:31 +00:00
|
|
|
}
|
2026-02-16 15:39:27 +00:00
|
|
|
|
2026-07-05 09:59:51 +00:00
|
|
|
fn run_mvp_chat(args: Vec<String>) -> ExitCode {
|
2026-07-07 10:40:02 +00:00
|
|
|
let mut command = Command::new(cargo_bin());
|
2026-07-12 06:14:34 +00:00
|
|
|
command.args(["run", "--package", "mvp-system", "--bin", "mvp-chat", "--"]);
|
2026-07-22 07:50:53 +00:00
|
|
|
let forwarded = if args.first().is_some_and(|arg| arg == "--") {
|
|
|
|
|
args[1..].to_vec()
|
|
|
|
|
} else {
|
|
|
|
|
args
|
|
|
|
|
};
|
|
|
|
|
command.args(forwarded);
|
2026-07-05 09:59:51 +00:00
|
|
|
|
2026-07-07 10:40:02 +00:00
|
|
|
match command.status() {
|
|
|
|
|
Ok(status) if status.success() => ExitCode::SUCCESS,
|
|
|
|
|
Ok(status) => ExitCode::from(
|
|
|
|
|
status
|
|
|
|
|
.code()
|
|
|
|
|
.and_then(|code| u8::try_from(code).ok())
|
|
|
|
|
.unwrap_or(1),
|
|
|
|
|
),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
eprintln!("Failed to execute cargo mvp-chat: {error}");
|
|
|
|
|
ExitCode::from(1)
|
2026-07-05 09:59:51 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 07:50:53 +00:00
|
|
|
fn workspace_root() -> PathBuf {
|
|
|
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
.parent()
|
|
|
|
|
.expect("xtask manifest dir has a parent")
|
|
|
|
|
.to_path_buf()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
|
|
|
|
let pid = std::process::id();
|
|
|
|
|
for attempt in 0..100 {
|
|
|
|
|
let timestamp_nanos = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_nanos();
|
|
|
|
|
let root =
|
|
|
|
|
std::env::temp_dir().join(format!("{prefix}-{pid}-{timestamp_nanos}-{attempt}"));
|
|
|
|
|
match fs::create_dir(&root) {
|
|
|
|
|
Ok(()) => return root,
|
|
|
|
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
|
|
|
Err(error) => panic!("mvp-chat-check: create temp dir {}: {error}", root.display()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
panic!("mvp-chat-check: could not allocate unique temp dir for prefix {prefix}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_mvp_chat_check_paths(root: &Path) -> Result<MvpChatCheckPaths, String> {
|
|
|
|
|
if !root.is_dir() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: temp root {} is not a directory",
|
|
|
|
|
root.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let dump_log = root.join("mvp-chat.ndjson");
|
|
|
|
|
if dump_log.exists() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: dump log path already exists: {}",
|
|
|
|
|
dump_log.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(MvpChatCheckPaths {
|
|
|
|
|
root: root.to_path_buf(),
|
|
|
|
|
dump_log,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_mvp_chat_check() -> ExitCode {
|
|
|
|
|
let workspace = workspace_root();
|
|
|
|
|
let temp_root = unique_temp_dir("mvp-chat-check");
|
|
|
|
|
let paths = match write_mvp_chat_check_paths(&temp_root) {
|
|
|
|
|
Ok(paths) => paths,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
eprintln!("{error}");
|
|
|
|
|
eprintln!(
|
|
|
|
|
"mvp-chat-check: temp directory kept at {}",
|
|
|
|
|
temp_root.display()
|
|
|
|
|
);
|
|
|
|
|
return ExitCode::from(1);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let output = match run_mvp_chat_check_process(&workspace, &paths) {
|
|
|
|
|
Ok(output) => output,
|
|
|
|
|
Err(error) => return fail_mvp_chat_check(&error, &paths, "", "", None),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Some(error) = &output.stdin_error {
|
|
|
|
|
return fail_mvp_chat_check(
|
|
|
|
|
error,
|
|
|
|
|
&paths,
|
|
|
|
|
&output.stdout,
|
|
|
|
|
&output.stderr,
|
|
|
|
|
Some(&output.status),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if output.timed_out {
|
|
|
|
|
let reason = format!("timeout after {MVP_CHAT_CHECK_TIMEOUT_SECS} seconds");
|
|
|
|
|
return fail_mvp_chat_check(
|
|
|
|
|
&reason,
|
|
|
|
|
&paths,
|
|
|
|
|
&output.stdout,
|
|
|
|
|
&output.stderr,
|
|
|
|
|
Some(&output.status),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
return fail_mvp_chat_check(
|
|
|
|
|
"child exited nonzero",
|
|
|
|
|
&paths,
|
|
|
|
|
&output.stdout,
|
|
|
|
|
&output.stderr,
|
|
|
|
|
Some(&output.status),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let responses = match assert_stdout_contains_two_prompt_cycles(&output.stdout) {
|
|
|
|
|
Ok(responses) => responses,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
return fail_mvp_chat_check(
|
|
|
|
|
&error,
|
|
|
|
|
&paths,
|
|
|
|
|
&output.stdout,
|
|
|
|
|
&output.stderr,
|
|
|
|
|
Some(&output.status),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Err(error) = assert_dump_log_facts(&paths.dump_log) {
|
|
|
|
|
return fail_mvp_chat_check(
|
|
|
|
|
&error,
|
|
|
|
|
&paths,
|
|
|
|
|
&output.stdout,
|
|
|
|
|
&output.stderr,
|
|
|
|
|
Some(&output.status),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Err(error) = fs::remove_dir_all(&paths.root) {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"mvp-chat-check: remove temp directory {}: {error}",
|
|
|
|
|
paths.root.display()
|
|
|
|
|
);
|
|
|
|
|
return ExitCode::from(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
println!("mvp-chat-check: ok");
|
|
|
|
|
for (index, response) in responses.iter().enumerate() {
|
|
|
|
|
println!("mvp-chat-check: response {}: {}", index + 1, response);
|
|
|
|
|
}
|
|
|
|
|
ExitCode::SUCCESS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_mvp_chat_check_process(
|
|
|
|
|
workspace: &Path,
|
|
|
|
|
paths: &MvpChatCheckPaths,
|
|
|
|
|
) -> Result<MvpChatCheckOutput, String> {
|
|
|
|
|
let mut command = Command::new(cargo_bin());
|
|
|
|
|
command
|
|
|
|
|
.current_dir(workspace)
|
|
|
|
|
.args(["mvp-chat", "--", "--cached-model"])
|
|
|
|
|
.arg(format!("--dump-logs={}", paths.dump_log.display()))
|
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped());
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
unsafe {
|
|
|
|
|
command.pre_exec(|| {
|
|
|
|
|
let result = libc::setpgid(0, 0);
|
|
|
|
|
if result == 0 {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(std::io::Error::last_os_error())
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut child = command
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| format!("mvp-chat-check: spawn cargo mvp-chat: {e}"))?;
|
|
|
|
|
let stdout = child
|
|
|
|
|
.stdout
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| "mvp-chat-check: child stdout was not piped".to_owned())?;
|
|
|
|
|
let stderr = child
|
|
|
|
|
.stderr
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| "mvp-chat-check: child stderr was not piped".to_owned())?;
|
|
|
|
|
let stdout_reader = thread::spawn(move || read_pipe_to_string(stdout, "stdout"));
|
|
|
|
|
let stderr_reader = thread::spawn(move || read_pipe_to_string(stderr, "stderr"));
|
|
|
|
|
|
|
|
|
|
let stdin_error = match child.stdin.take() {
|
|
|
|
|
Some(mut stdin) => {
|
|
|
|
|
let result = stdin.write_all(MVP_CHAT_CHECK_PROMPTS);
|
|
|
|
|
drop(stdin);
|
|
|
|
|
result
|
|
|
|
|
.err()
|
|
|
|
|
.map(|error| format!("mvp-chat-check: write child stdin: {error}"))
|
|
|
|
|
}
|
|
|
|
|
None => Some("mvp-chat-check: child stdin was not piped".to_owned()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (status, timed_out) = if stdin_error.is_some() {
|
|
|
|
|
(terminate_mvp_chat_child(&mut child)?, false)
|
|
|
|
|
} else {
|
|
|
|
|
wait_mvp_chat_check_child(&mut child)?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let stdout = join_reader(stdout_reader, "stdout")?;
|
|
|
|
|
let stderr = join_reader(stderr_reader, "stderr")?;
|
|
|
|
|
Ok(MvpChatCheckOutput {
|
|
|
|
|
status,
|
|
|
|
|
stdout,
|
|
|
|
|
stderr,
|
|
|
|
|
timed_out,
|
|
|
|
|
stdin_error,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wait_mvp_chat_check_child(child: &mut Child) -> Result<(ExitStatus, bool), String> {
|
|
|
|
|
let timeout = Duration::from_secs(MVP_CHAT_CHECK_TIMEOUT_SECS);
|
|
|
|
|
let poll = Duration::from_millis(MVP_CHAT_CHECK_POLL_MS);
|
|
|
|
|
let deadline = Instant::now() + timeout;
|
|
|
|
|
loop {
|
|
|
|
|
match child.try_wait() {
|
|
|
|
|
Ok(Some(status)) => return Ok((status, false)),
|
|
|
|
|
Ok(None) if Instant::now() >= deadline => {
|
|
|
|
|
return terminate_mvp_chat_child(child).map(|status| (status, true));
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => thread::sleep(poll),
|
|
|
|
|
Err(error) => return Err(format!("mvp-chat-check: poll child status: {error}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn terminate_mvp_chat_child(child: &mut Child) -> Result<ExitStatus, String> {
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
{
|
|
|
|
|
signal_mvp_chat_process_group(child, libc::SIGTERM);
|
|
|
|
|
let grace_polls = MVP_CHAT_CHECK_TERM_GRACE_MS / MVP_CHAT_CHECK_POLL_MS;
|
|
|
|
|
for _ in 0..grace_polls {
|
|
|
|
|
match child.try_wait() {
|
|
|
|
|
Ok(Some(status)) => return Ok(status),
|
|
|
|
|
Ok(None) => thread::sleep(Duration::from_millis(MVP_CHAT_CHECK_POLL_MS)),
|
|
|
|
|
Err(error) => {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: poll child after SIGTERM: {error}"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
signal_mvp_chat_process_group(child, libc::SIGKILL);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
|
|
|
{
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
child
|
|
|
|
|
.wait()
|
|
|
|
|
.map_err(|e| format!("mvp-chat-check: wait for terminated child: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
fn signal_mvp_chat_process_group(child: &Child, signal: libc::c_int) {
|
|
|
|
|
let process_group = -(child.id() as libc::pid_t);
|
|
|
|
|
let _ = unsafe { libc::kill(process_group, signal) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_pipe_to_string<R: Read>(mut reader: R, label: &'static str) -> Result<String, String> {
|
|
|
|
|
let mut text = String::new();
|
|
|
|
|
reader
|
|
|
|
|
.read_to_string(&mut text)
|
|
|
|
|
.map_err(|e| format!("mvp-chat-check: read child {label}: {e}"))?;
|
|
|
|
|
Ok(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn join_reader(
|
|
|
|
|
handle: thread::JoinHandle<Result<String, String>>,
|
|
|
|
|
label: &str,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
handle
|
|
|
|
|
.join()
|
|
|
|
|
.map_err(|_| format!("mvp-chat-check: child {label} reader panicked"))?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn fail_mvp_chat_check(
|
|
|
|
|
reason: &str,
|
|
|
|
|
paths: &MvpChatCheckPaths,
|
|
|
|
|
stdout: &str,
|
|
|
|
|
stderr: &str,
|
|
|
|
|
status: Option<&ExitStatus>,
|
|
|
|
|
) -> ExitCode {
|
|
|
|
|
eprintln!("mvp-chat-check: failed: {reason}");
|
|
|
|
|
if let Some(status) = status {
|
|
|
|
|
eprintln!("mvp-chat-check: child exit status: {status}");
|
|
|
|
|
}
|
|
|
|
|
eprintln!(
|
|
|
|
|
"mvp-chat-check: temp directory kept at {}",
|
|
|
|
|
paths.root.display()
|
|
|
|
|
);
|
|
|
|
|
eprintln!("--- captured stdout ---");
|
|
|
|
|
if stdout.is_empty() {
|
|
|
|
|
eprintln!("<empty>");
|
|
|
|
|
} else {
|
|
|
|
|
eprint!("{stdout}");
|
|
|
|
|
if !stdout.ends_with('\n') {
|
|
|
|
|
eprintln!();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
eprintln!("--- captured stderr ---");
|
|
|
|
|
if stderr.is_empty() {
|
|
|
|
|
eprintln!("<empty>");
|
|
|
|
|
} else {
|
|
|
|
|
eprint!("{stderr}");
|
|
|
|
|
if !stderr.ends_with('\n') {
|
|
|
|
|
eprintln!();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ExitCode::from(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn assert_stdout_contains_two_prompt_cycles(stdout: &str) -> Result<Vec<String>, String> {
|
|
|
|
|
let decoding_count = stdout.matches("decoding...").count();
|
|
|
|
|
if decoding_count < 2 {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: expected at least two decoding... markers, found {decoding_count}"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let response_count = stdout.matches("Response: ").count();
|
|
|
|
|
if response_count < 2 {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: expected at least two Response: prefixes, found {response_count}"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut cursor = 0;
|
|
|
|
|
let mut responses = Vec::with_capacity(2);
|
|
|
|
|
for cycle in 1..=2 {
|
|
|
|
|
let prompt_at = find_stdout_marker(stdout, "prompt:>", cursor, cycle, "prompt")?;
|
|
|
|
|
let decoding_at = find_stdout_marker(
|
|
|
|
|
stdout,
|
|
|
|
|
"decoding...",
|
|
|
|
|
prompt_at + "prompt:>".len(),
|
|
|
|
|
cycle,
|
|
|
|
|
"decoding",
|
|
|
|
|
)?;
|
|
|
|
|
let response_at = find_stdout_marker(
|
|
|
|
|
stdout,
|
|
|
|
|
"Response: ",
|
|
|
|
|
decoding_at + "decoding...".len(),
|
|
|
|
|
cycle,
|
|
|
|
|
"response",
|
|
|
|
|
)?;
|
|
|
|
|
let response_start = response_at + "Response: ".len();
|
|
|
|
|
let response_end = stdout[response_start..]
|
|
|
|
|
.find('\n')
|
|
|
|
|
.map_or(stdout.len(), |offset| response_start + offset);
|
|
|
|
|
let response = &stdout[response_start..response_end];
|
|
|
|
|
if !response.chars().any(|ch| !ch.is_whitespace()) {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: empty Response text for prompt cycle {cycle}"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
responses.push(response.to_owned());
|
|
|
|
|
cursor = response_end;
|
|
|
|
|
}
|
|
|
|
|
Ok(responses)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn find_stdout_marker(
|
|
|
|
|
stdout: &str,
|
|
|
|
|
marker: &str,
|
|
|
|
|
start: usize,
|
|
|
|
|
cycle: usize,
|
|
|
|
|
label: &str,
|
|
|
|
|
) -> Result<usize, String> {
|
|
|
|
|
stdout[start..]
|
|
|
|
|
.find(marker)
|
|
|
|
|
.map(|offset| start + offset)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!("mvp-chat-check: missing {label} marker for prompt cycle {cycle}")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
struct DumpLogFacts {
|
|
|
|
|
chat_config_ready: bool,
|
|
|
|
|
prepare_runtime_ready: bool,
|
|
|
|
|
prompt_rpc_ready: bool,
|
|
|
|
|
orch_iroh_driver_ready: bool,
|
|
|
|
|
node_iroh_driver_ready: bool,
|
|
|
|
|
node_worker_initialize_ready: bool,
|
|
|
|
|
orch_weights_loaded_ready: bool,
|
|
|
|
|
response_text_1: bool,
|
|
|
|
|
request_completed_1: bool,
|
|
|
|
|
response_text_2: bool,
|
|
|
|
|
request_completed_2: bool,
|
|
|
|
|
shutdown_requested: bool,
|
|
|
|
|
orchestrator_stopped: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn assert_dump_log_facts(path: &Path) -> Result<(), String> {
|
|
|
|
|
let content = fs::read_to_string(path)
|
|
|
|
|
.map_err(|e| format!("mvp-chat-check: read dump log {}: {e}", path.display()))?;
|
|
|
|
|
if content.lines().next().is_none() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: dump log {} is empty",
|
|
|
|
|
path.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut facts = DumpLogFacts::default();
|
|
|
|
|
for (line_index, line) in content.lines().enumerate() {
|
|
|
|
|
if line.trim().is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let outer: Value = serde_json::from_str(line).map_err(|e| {
|
|
|
|
|
format!(
|
|
|
|
|
"mvp-chat-check: parse dump log {} line {}: {e}",
|
|
|
|
|
path.display(),
|
|
|
|
|
line_index + 1
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let channel = outer
|
|
|
|
|
.get("channel")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"mvp-chat-check: dump log line {} missing channel",
|
|
|
|
|
line_index + 1
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let payload = outer.get("payload").ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"mvp-chat-check: dump log line {} missing payload",
|
|
|
|
|
line_index + 1
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if payload.get("encoding").and_then(Value::as_str) != Some("utf8") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let inner_text = payload.get("value").and_then(Value::as_str).ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"mvp-chat-check: dump log line {} missing utf8 payload value",
|
|
|
|
|
line_index + 1
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let event: Value = serde_json::from_str(inner_text).map_err(|e| {
|
|
|
|
|
format!(
|
|
|
|
|
"mvp-chat-check: parse inner event on dump log line {}: {e}",
|
|
|
|
|
line_index + 1
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
record_dump_log_event(channel, &event, &mut facts)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
require_dump_log_fact(facts.chat_config_ready, "config ready")?;
|
|
|
|
|
require_dump_log_fact(facts.prepare_runtime_ready, "prepare_runtime ready")?;
|
|
|
|
|
require_dump_log_fact(facts.prompt_rpc_ready, "prompt_rpc ready")?;
|
|
|
|
|
require_dump_log_fact(facts.orch_iroh_driver_ready, "OrchBootstrap iroh_driver ready")?;
|
|
|
|
|
require_dump_log_fact(facts.node_iroh_driver_ready, "NodeEvent iroh_driver ready")?;
|
|
|
|
|
require_dump_log_fact(
|
|
|
|
|
facts.node_worker_initialize_ready,
|
|
|
|
|
"NodeEvent worker_initialize ready",
|
|
|
|
|
)?;
|
|
|
|
|
require_dump_log_fact(facts.orch_weights_loaded_ready, "OrchBootstrap weights_loaded ready")?;
|
|
|
|
|
require_dump_log_fact(facts.response_text_1, "response_text request_id=1")?;
|
|
|
|
|
require_dump_log_fact(facts.request_completed_1, "request_completed request_id=1")?;
|
|
|
|
|
require_dump_log_fact(facts.response_text_2, "response_text request_id=2")?;
|
|
|
|
|
require_dump_log_fact(facts.request_completed_2, "request_completed request_id=2")?;
|
|
|
|
|
require_dump_log_fact(facts.shutdown_requested, "shutdown requested")?;
|
|
|
|
|
require_dump_log_fact(facts.orchestrator_stopped, "orchestrator_process stopped")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn record_dump_log_event(
|
|
|
|
|
channel: &str,
|
|
|
|
|
event: &Value,
|
|
|
|
|
facts: &mut DumpLogFacts,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let event_type = event.get("type").and_then(Value::as_str);
|
|
|
|
|
let phase = event.get("phase").and_then(Value::as_str);
|
|
|
|
|
let status = event.get("status").and_then(Value::as_str);
|
|
|
|
|
if status == Some("failed") {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"mvp-chat-check: failed event channel={channel} type={} phase={} detail={}",
|
|
|
|
|
event_type.unwrap_or("<missing>"),
|
|
|
|
|
phase.unwrap_or("<missing>"),
|
|
|
|
|
event.get("detail").unwrap_or(&Value::Null)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match (channel, event_type, phase, status) {
|
|
|
|
|
("mvp.chat.lifecycle", Some("ChatProgress"), Some("config"), Some("ready")) => {
|
|
|
|
|
facts.chat_config_ready = true;
|
|
|
|
|
}
|
|
|
|
|
("mvp.chat.runtime", Some("ChatProgress"), Some("prepare_runtime"), Some("ready")) => {
|
|
|
|
|
facts.prepare_runtime_ready = true;
|
|
|
|
|
}
|
|
|
|
|
("mvp.chat.runtime", Some("ChatProgress"), Some("prompt_rpc"), Some("ready")) => {
|
|
|
|
|
facts.prompt_rpc_ready = true;
|
|
|
|
|
}
|
|
|
|
|
(_, Some("OrchBootstrap"), Some("iroh_driver"), Some("ready")) => {
|
|
|
|
|
facts.orch_iroh_driver_ready = true;
|
|
|
|
|
}
|
|
|
|
|
(_, Some("NodeEvent"), Some("iroh_driver"), Some("ready")) => {
|
|
|
|
|
facts.node_iroh_driver_ready = true;
|
|
|
|
|
}
|
|
|
|
|
(_, Some("NodeEvent"), Some("worker_initialize"), Some("ready")) => {
|
|
|
|
|
facts.node_worker_initialize_ready = true;
|
|
|
|
|
}
|
|
|
|
|
(_, Some("OrchBootstrap"), Some("weights_loaded"), Some("ready")) => {
|
|
|
|
|
facts.orch_weights_loaded_ready = true;
|
|
|
|
|
}
|
|
|
|
|
("mvp.chat.prompt", Some("ChatProgress"), Some("response_text"), Some("observed")) => {
|
|
|
|
|
match dump_log_request_id(event) {
|
|
|
|
|
Some(1) => facts.response_text_1 = true,
|
|
|
|
|
Some(2) => facts.response_text_2 = true,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
("mvp.chat.prompt", Some("ChatProgress"), Some("request_completed"), Some("ready")) => {
|
|
|
|
|
match dump_log_request_id(event) {
|
|
|
|
|
Some(1) => facts.request_completed_1 = true,
|
|
|
|
|
Some(2) => facts.request_completed_2 = true,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
("mvp.chat.lifecycle", Some("ChatProgress"), Some("shutdown"), Some("requested")) => {
|
|
|
|
|
facts.shutdown_requested = true;
|
|
|
|
|
}
|
|
|
|
|
(
|
|
|
|
|
"mvp.chat.component",
|
|
|
|
|
Some("ChatProgress"),
|
|
|
|
|
Some("orchestrator_process"),
|
|
|
|
|
Some("stopped"),
|
|
|
|
|
) => {
|
|
|
|
|
facts.orchestrator_stopped = true;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn dump_log_request_id(event: &Value) -> Option<u64> {
|
|
|
|
|
event
|
|
|
|
|
.get("detail")
|
|
|
|
|
.and_then(|detail| detail.get("request_id"))
|
|
|
|
|
.and_then(Value::as_u64)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn require_dump_log_fact(found: bool, fact: &str) -> Result<(), String> {
|
|
|
|
|
if found {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(format!("mvp-chat-check: missing {fact}"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
fn main() -> ExitCode {
|
2026-06-25 12:30:18 +00:00
|
|
|
let mut args = std::env::args().skip(1);
|
2026-07-05 09:59:51 +00:00
|
|
|
match args.next().as_deref() {
|
|
|
|
|
Some("test") if args.next().is_none() => run_tests(),
|
2026-07-22 07:50:53 +00:00
|
|
|
Some("mvp-chat-check") if args.next().is_none() => run_mvp_chat_check(),
|
2026-07-05 09:59:51 +00:00
|
|
|
Some("mvp-chat") => run_mvp_chat(args.collect()),
|
|
|
|
|
Some("help" | "--help" | "-h") | None => {
|
2026-06-25 12:30:18 +00:00
|
|
|
print_usage();
|
2026-05-21 19:48:02 +00:00
|
|
|
ExitCode::SUCCESS
|
|
|
|
|
}
|
2026-06-25 12:30:18 +00:00
|
|
|
_ => {
|
2026-02-24 09:12:28 +00:00
|
|
|
print_usage();
|
2026-05-21 19:48:02 +00:00
|
|
|
ExitCode::from(1)
|
2026-02-16 15:39:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|