Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/meshloop-adapters/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ impl HarnessCapabilities for CliHarness {
}
}

fn process_image(&self) -> Option<String> {
crate::process::process_image_name(&self.config.executable)
}

fn cancel(&self, handle: &HarnessHandle) -> Result<(), HarnessError> {
let mut registry = self
.running
Expand Down
69 changes: 67 additions & 2 deletions crates/meshloop-adapters/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,26 @@ pub fn kill_orphaned_tree(pid: u32, executable: &std::path::Path) -> bool {
true
}

/// The OS image name a process spawned from `executable` reports: `<stem>.exe` on Windows
/// (what `tasklist` shows, whether or not the configured path has the extension), the file
/// name elsewhere. This is what attempts persist so liveness checks can reject reused pids.
pub fn process_image_name(executable: &std::path::Path) -> Option<String> {
if cfg!(windows) {
executable
.file_stem()
.map(|stem| format!("{}.exe", stem.to_string_lossy()))
} else {
executable
.file_name()
.map(|name| name.to_string_lossy().into_owned())
}
}

#[cfg(windows)]
fn orphan_identity_matches(pid: u32, executable: &std::path::Path) -> bool {
let Some(stem) = executable.file_stem() else {
let Some(image) = process_image_name(executable) else {
return false;
};
let image = format!("{}.exe", stem.to_string_lossy());
WindowsProcessView.is_live(&ProcessHint {
pid,
image_name: Some(image),
Expand Down Expand Up @@ -218,6 +232,57 @@ mod tests {
);
}

#[test]
fn process_image_name_matches_what_the_os_reports() {
#[cfg(windows)]
{
assert_eq!(
process_image_name(std::path::Path::new("pwsh")).as_deref(),
Some("pwsh.exe")
);
assert_eq!(
process_image_name(std::path::Path::new(r"C:\tools\node.exe")).as_deref(),
Some("node.exe")
);
}
#[cfg(not(windows))]
assert_eq!(
process_image_name(std::path::Path::new("/usr/bin/node")).as_deref(),
Some("node")
);
}

/// The persisted image lets a process that does not own the child see it as live (#47).
#[test]
fn persisted_image_name_reports_a_running_child_live() {
#[cfg(windows)]
let (exe, args) = (
"powershell",
["-NoProfile", "-Command", "Start-Sleep -Seconds 30"],
);
#[cfg(not(windows))]
let (exe, args) = ("sleep", ["30", "", ""]);
let mut cmd = Command::new(exe);
cmd.args(args.iter().filter(|a| !a.is_empty()));
let mut child = cmd.spawn().expect("spawn sleeper");
let hint = ProcessHint {
pid: child.id(),
image_name: process_image_name(std::path::Path::new(exe)),
};
assert_eq!(HostProcessView {}.is_live(&hint), LiveCheck::Live);
#[cfg(windows)]
assert_eq!(
HostProcessView {}.is_live(&ProcessHint {
pid: child.id(),
image_name: Some("claude".into()),
}),
LiveCheck::Ambiguous,
"a harness name is not an image name; it must not read as Live"
);
let _ = child.kill();
let _ = child.wait();
}

#[test]
fn kills_live_child_process_tree() {
#[cfg(windows)]
Expand Down
34 changes: 32 additions & 2 deletions crates/meshloop-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub enum Command {
},
Status {
graph: Option<String>,
config: Option<PathBuf>,
db: Option<PathBuf>,
},
Plan {
objective: String,
Expand Down Expand Up @@ -163,7 +165,11 @@ pub fn parse(args: &[String]) -> Result<Invocation, String> {

fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| verb(s)) {
None => Ok(Command::Status { graph: None }),
None => Ok(Command::Status {
graph: None,
config: None,
db: None,
}),
Some(v) if v == "--help" || v == "-h" || v == "help" => Ok(Command::Help),
Some(v) if v == "--version" || v == "-V" || v == "version" => Ok(Command::Version),
Some(v) if v == "mcp" => Ok(Command::Mcp),
Expand Down Expand Up @@ -254,6 +260,8 @@ fn parse_command(args: &[String]) -> Result<Command, String> {
}
Some(v) if v == "status" => Ok(Command::Status {
graph: flag_value(&args[1..], "--graph"),
config: flag_value(&args[1..], "--config").map(PathBuf::from),
db: flag_value(&args[1..], "--db").map(PathBuf::from),
}),
Some(v) if v == "resume" => {
let rest = &args[1..];
Expand Down Expand Up @@ -413,7 +421,7 @@ pub fn help_text() -> &'static str {
\x20 meshloop review-plan --plan <path> --accept|--decline|--adjust [--reason] [--as] [--json]\n\
\x20 meshloop run --plan <path> [--accept-plan] [--reset] [--fixture-only] [--json]\n\
\x20 meshloop resume [--graph <id>] [--retry|--restart] [--json]\n\
\x20 meshloop status [--graph <id>] [--json]\n\
\x20 meshloop status [--graph <id>] [--config <path>] [--db <path>] [--json]\n\
\x20 meshloop roles [--json]\n\
\x20 meshloop doctor [--config <path>] [--db <path>] [--json]\n\
\x20 meshloop orchestrate --task <id> --model-a <ref> --model-b <ref> [--json]\n\
Expand All @@ -424,3 +432,25 @@ pub fn help_text() -> &'static str {
\x20 or MESHLOOP_ORIGIN_HARNESS / MESHLOOP_ORIGIN_SESSION.\n\
Live workers are the default (Herdr). --fixture-only forces the CI subprocess double."
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn status_keeps_config_db_and_graph() {
let args: Vec<String> = ["status", "--config", "x", "--db", "y", "--graph", "g"]
.iter()
.map(|s| s.to_string())
.collect();
let inv = parse(&args).expect("parse");
match inv.command {
Command::Status { graph, config, db } => {
assert_eq!(graph.as_deref(), Some("g"));
assert_eq!(config, Some(PathBuf::from("x")));
assert_eq!(db, Some(PathBuf::from("y")));
}
_ => panic!("expected status"),
}
}
}
6 changes: 6 additions & 0 deletions crates/meshloop-cli/src/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ impl HarnessCapabilities for DispatchHarness {
Self::Fixture(h) | Self::Direct(h) => h.pane_for_worktree(worktree),
}
}

fn process_image(&self) -> Option<String> {
match self {
Self::Fixture(h) | Self::Direct(h) => h.process_image(),
}
}
}

pub struct Composed {
Expand Down
60 changes: 43 additions & 17 deletions crates/meshloop-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ fn dispatch(inv: Invocation) -> ExitCode {
Command::Mcp => ExitCode::from(mcp::run_stdio() as u8),
Command::Roles => cmd_roles(inv.json, inv.origin),
Command::Doctor { config, db } => cmd_doctor(config, db, inv.json, inv.origin),
Command::Status { graph } => cmd_status(graph, inv.json, inv.origin),
Command::Status { graph, config, db } => {
cmd_status(graph, config, db, inv.json, inv.origin)
}
Command::Plan {
objective,
config,
Expand Down Expand Up @@ -629,6 +631,8 @@ fn cmd_review_plan(
ExitCode::SUCCESS
}

const EMPTY_VERIFY_WARNING: &str = "verify_command is empty: nodes are not checked beyond git diff";

#[allow(clippy::too_many_arguments)]
fn cmd_run(
plan: PathBuf,
Expand Down Expand Up @@ -685,6 +689,9 @@ fn cmd_run(
return ExitCode::from(2);
}
};
if composed.verify_command.is_empty() {
eprintln!("warning: {EMPTY_VERIFY_WARNING}");
}
let harness_refs: HashMap<String, &dyn HarnessCapabilities> = composed
.harnesses
.iter()
Expand Down Expand Up @@ -935,29 +942,33 @@ fn with_saga(

fn cmd_status(
graph: Option<String>,
config: Option<PathBuf>,
db: Option<PathBuf>,
json: bool,
origin: meshloop_engine::origin::Origin,
) -> ExitCode {
if crate::config::discover(None).is_err() {
if crate::config::discover(config.as_deref()).is_err() {
let msg = "no config found; pass --config or add meshloop.toml";
if json {
println!(
"{}",
json_out::ok(
"meshloop:status",
origin,
serde_json::json!({ "banner": report::banner(), "runs": [] }),
)
);
println!("{}", json_out::err("meshloop:status", origin, msg));
} else {
print!("{}", report::banner());
eprintln!("{msg}");
}
return ExitCode::SUCCESS;
return ExitCode::from(2);
}
let db = None;
with_saga(None, db, None, false, origin.clone(), |saga| {
with_saga(config, db, None, false, origin.clone(), |saga| {
let id = match saga.resolve_graph_id(graph.as_deref()) {
Ok(id) => id,
Err(_) => {
Err(e) => {
if let Some(g) = graph.as_deref() {
let msg = format!("graph '{g}' not found: {e:?}");
if json {
println!("{}", json_out::err("meshloop:status", origin.clone(), msg));
} else {
eprintln!("{msg}");
}
return ExitCode::from(2);
}
if json {
println!(
"{}",
Expand Down Expand Up @@ -1002,8 +1013,15 @@ fn cmd_status(
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{e:?}");
ExitCode::from(1)
if json {
println!(
"{}",
json_out::err("meshloop:status", origin.clone(), format!("{e:?}"))
);
} else {
eprintln!("{e:?}");
}
ExitCode::from(2)
}
}
})
Expand Down Expand Up @@ -1478,6 +1496,7 @@ fn cmd_doctor(
let origin_session = origin.session.clone();
let origin_harness = origin.harness.clone();

let mut warnings: Vec<&str> = Vec::new();
let (config_path, harnesses, error) = match crate::config::discover(config.as_deref()) {
Err(_) => (
None,
Expand All @@ -1491,6 +1510,9 @@ fn cmd_doctor(
Some(format!("failed to load config: {e:?}")),
),
Ok(cfg) => {
if cfg.verify.verify_command.is_empty() {
warnings.push(EMPTY_VERIFY_WARNING);
}
let rows = doctor_probe(&cfg);
let error = (!rows.iter().any(|r| r["dispatchable"] == true)).then(|| {
"no selected harness is dispatchable; see data.harnesses[].reason".to_string()
Expand Down Expand Up @@ -1518,6 +1540,7 @@ fn cmd_doctor(
"harnesses_ready": harnesses_ready,
"harnesses": harnesses,
"store_ignored": store_ignored,
"warnings": warnings,
"note": "Doctor loads the config and runs each selected harness's bounded version probe. Live workers run as direct CLI subprocesses in Git worktrees.",
});
if json {
Expand Down Expand Up @@ -1545,6 +1568,9 @@ fn cmd_doctor(
println!(" harness {}: {status}", h["name"].as_str().unwrap_or("?"));
}
println!(" store_ignored: {store_ignored}");
for w in &warnings {
println!(" warning: {w}");
}
if !store_ignored {
println!(
" fix: add `{}` to .gitignore, or run `meshloop bundle --dest . --gitignore`",
Expand Down
Loading
Loading