From 746d8f83d0317b3578060d263143caaf36fd3ff0 Mon Sep 17 00:00:00 2001 From: meshloop Date: Thu, 8 Oct 2026 07:02:44 +0200 Subject: [PATCH 1/8] meshloop: attempt 7 for task 1 --- .../skills/meshloop-accept/SKILL.md | 3 + .../skills/meshloop-review-plan/SKILL.md | 3 + crates/meshloop-cli/src/main.rs | 18 +++ crates/meshloop-cli/src/report.rs | 11 ++ crates/meshloop-cli/tests/plan_and_run.rs | 110 ++++++++++++++++++ crates/meshloop-engine/src/run_loop.rs | 62 ++++++++++ docs/start.md | 2 + skills/meshloop-accept/SKILL.md | 3 + skills/meshloop-review-plan/SKILL.md | 3 + 9 files changed, 215 insertions(+) diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-accept/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-accept/SKILL.md index edcc3f3..f742709 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-accept/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-accept/SKILL.md @@ -5,6 +5,9 @@ description: Record human acceptance of a Meshloop node. Slash /meshloop:accept. # /meshloop:accept +R1 rule: every node stops for `meshloop accept`; the tier selects harness/model only. +`status`/`inspect` report `waiting_for: acceptance` for nodes awaiting this step. + Only after the user agrees in this supervisor session: ```text diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md index baca7ae..a741a63 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-review-plan/SKILL.md @@ -36,3 +36,6 @@ meshloop review-plan --plan meshloop-plan.json --adjust --objective "" | Adjust | still `AwaitingPlanReview` | refuses; ask again after the new graph | Do not call `run --accept-plan` to skip this question. + +R1 rule: every node stops for `meshloop accept`; the tier selects harness/model only. +The review-plan JSON lists them under `acceptance_required`. diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index 3f66225..7b97f3a 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -614,6 +614,8 @@ fn cmd_review_plan( "reason": reason, "as": identity, "graph": graph, + "acceptance_required": graph.nodes.iter().map(|n| n.id.0).collect::>(), + "acceptance_rule": report::ACCEPTANCE_RULE, "next": next, }), ) @@ -624,6 +626,16 @@ fn cmd_review_plan( println!(" note: {n}"); } println!(" next: {next}"); + println!( + " acceptance required (meshloop accept): {}", + graph + .nodes + .iter() + .map(|n| n.id.0.to_string()) + .collect::>() + .join(",") + ); + println!(" {}", report::ACCEPTANCE_RULE); if decision == PlanDecision::Adjust { print!("{}", report::format_plan(&graph)); } @@ -1003,6 +1015,8 @@ fn cmd_status( "live": n.live, "worktree": n.worktree.as_ref().map(|p| p.display().to_string()), "note": n.note, + "waiting_for": n.waiting_for.map(|w| w.as_str()), + "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), })).collect::>(), }), ) @@ -1200,6 +1214,8 @@ fn cmd_inspect( "pane_id": n.pane_id, "live": n.live, "note": n.note, + "waiting_for": n.waiting_for.map(|w| w.as_str()), + "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), }), ) ); @@ -1238,6 +1254,8 @@ fn cmd_inspect( "live": n.live, "worktree": n.worktree.as_ref().map(|p| p.display().to_string()), "note": n.note, + "waiting_for": n.waiting_for.map(|w| w.as_str()), + "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), })).collect::>(), }); if let Some(export) = git_export { diff --git a/crates/meshloop-cli/src/report.rs b/crates/meshloop-cli/src/report.rs index f42df76..a2ed422 100644 --- a/crates/meshloop-cli/src/report.rs +++ b/crates/meshloop-cli/src/report.rs @@ -4,6 +4,9 @@ use meshloop_domain::state::TaskState; use meshloop_domain::task_graph::TaskGraph; use meshloop_engine::run_loop::{IdleReason, RunStatus}; +pub const ACCEPTANCE_RULE: &str = + "R1: every node stops for meshloop accept; tier selects harness/model only"; + pub fn format_plan(graph: &TaskGraph) -> String { let mut out = format!( "Plan '{}': {} task(s) — awaiting-plan-review (ADR 0009).\n\ @@ -73,6 +76,14 @@ pub fn format_status(status: &RunStatus) -> String { n.pane_id.as_deref().unwrap_or("-"), n.live.as_deref().unwrap_or("-") ); + if let Some(w) = n.waiting_for { + if n.blocked_by.is_empty() { + out += &format!(" waiting: {}\n", w.as_str()); + } else { + let ids: Vec = n.blocked_by.iter().map(|d| d.0.to_string()).collect(); + out += &format!(" waiting: {} {}\n", w.as_str(), ids.join(",")); + } + } if let Some(note) = &n.note { out += &format!(" note: {note}\n"); } diff --git a/crates/meshloop-cli/tests/plan_and_run.rs b/crates/meshloop-cli/tests/plan_and_run.rs index 1849346..ea4d736 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -812,6 +812,116 @@ fn review_plan_adjust_rewrites_and_stays_awaiting_review() { fs::remove_dir_all(&dir).ok(); } +fn status_json(dir: &Path, config: &Path, db: &Path) -> serde_json::Value { + let out = meshloop() + .current_dir(dir) + .args(["status", "--json", "--config"]) + .arg(config) + .args(["--db"]) + .arg(db) + .output() + .expect("status"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "status stdout: {stdout}"); + serde_json::from_str(&stdout).expect("status json") +} + +#[test] +fn status_reports_waiting_for_acceptance_and_dependency() { + let dir = disposable_repo("waiting-for"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let plan_path = dir.join("plan.json"); + let db_path = dir.join(".meshloop").join("state.sqlite"); + let worktree_base = dir.join("worktrees"); + fs::create_dir_all(dir.join(".meshloop")).unwrap(); + fs::write( + &plan_path, + r#"{"graph_id":"gw","nodes":[{"id":1,"description":"first","depends_on":[],"tier":null},{"id":2,"description":"second","depends_on":[1],"tier":null}]}"#, + ) + .unwrap(); + + let output = meshloop() + .current_dir(&dir) + .args(["run", "--plan"]) + .arg(&plan_path) + .args(["--accept-plan", "--config"]) + .arg(&config_path) + .args(["--worktree-base"]) + .arg(&worktree_base) + .args(["--db"]) + .arg(&db_path) + .output() + .expect("run"); + assert!( + output.status.success(), + "run stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + + let parsed = status_json(&dir, &config_path, &db_path); + let nodes = parsed["data"]["nodes"].as_array().expect("nodes"); + let n1 = nodes.iter().find(|n| n["id"] == 1).expect("node 1"); + let n2 = nodes.iter().find(|n| n["id"] == 2).expect("node 2"); + assert_eq!(n1["waiting_for"], "acceptance", "{parsed}"); + assert_eq!(n1["blocked_by"], serde_json::json!([])); + assert_eq!(n2["waiting_for"], "dependency", "{parsed}"); + assert_eq!(n2["blocked_by"], serde_json::json!([1])); + + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn review_plan_lists_acceptance_required_and_nodes_wait_for_plan_acceptance() { + let dir = disposable_repo("plan-acceptance"); + let config_path = write_config(&dir, r#"["--emit-graph"]"#); + let plan_path = dir.join("plan.json"); + let db_path = dir.join(".meshloop").join("state.sqlite"); + fs::create_dir_all(dir.join(".meshloop")).unwrap(); + fs::write( + &plan_path, + r#"{"graph_id":"gpa","nodes":[{"id":1,"description":"first","depends_on":[],"tier":null}]}"#, + ) + .unwrap(); + + let reviewed = meshloop() + .current_dir(&dir) + .args(["review-plan", "--plan"]) + .arg(&plan_path) + .args(["--adjust", "--objective", "tweak", "--json", "--config"]) + .arg(&config_path) + .args(["--db"]) + .arg(&db_path) + .output() + .expect("review-plan"); + let stdout = String::from_utf8_lossy(&reviewed.stdout); + assert!(reviewed.status.success(), "stdout={stdout}"); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("json"); + let graph_ids: Vec = parsed["data"]["graph"]["nodes"] + .as_array() + .expect("graph nodes") + .iter() + .map(|n| n["id"].clone()) + .collect(); + assert!(!graph_ids.is_empty()); + assert_eq!( + parsed["data"]["acceptance_required"], + serde_json::Value::Array(graph_ids) + ); + assert_eq!( + parsed["data"]["acceptance_rule"], + "R1: every node stops for meshloop accept; tier selects harness/model only" + ); + + let status = status_json(&dir, &config_path, &db_path); + let nodes = status["data"]["nodes"].as_array().expect("nodes"); + assert!(!nodes.is_empty()); + for n in nodes { + assert_eq!(n["waiting_for"], "plan_acceptance", "{status}"); + } + + fs::remove_dir_all(&dir).ok(); +} + #[test] fn bundle_emits_version_locked_session_pack() { let dest = std::env::temp_dir().join(format!( diff --git a/crates/meshloop-engine/src/run_loop.rs b/crates/meshloop-engine/src/run_loop.rs index a05cf8f..22323b9 100644 --- a/crates/meshloop-engine/src/run_loop.rs +++ b/crates/meshloop-engine/src/run_loop.rs @@ -123,9 +123,31 @@ pub struct RunLoop<'a> { pub active_graph: Option, } +/// Why a node is not progressing on its own (R1: every node stops for `meshloop accept`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WaitingFor { + Acceptance, + Dependency, + PlanAcceptance, + RetryBudgetExhausted, +} + +impl WaitingFor { + pub fn as_str(&self) -> &'static str { + match self { + WaitingFor::Acceptance => "acceptance", + WaitingFor::Dependency => "dependency", + WaitingFor::PlanAcceptance => "plan_acceptance", + WaitingFor::RetryBudgetExhausted => "retry_budget_exhausted", + } + } +} + #[derive(Debug, Clone)] pub struct NodeStatus { pub task_id: TaskId, + pub waiting_for: Option, + pub blocked_by: Vec, pub description: String, pub state: TaskState, pub note: Option, @@ -1951,8 +1973,48 @@ impl<'a> RunLoop<'a> { } else { None }; + let mut blocked_by: Vec = n + .depends_on + .iter() + .copied() + .filter(|d| { + !matches!( + tasks.get(d).copied().unwrap_or(TaskState::Pending), + TaskState::Accepted | TaskState::Integrated + ) + }) + .collect(); + blocked_by.sort(); + blocked_by.dedup(); + let terminal = matches!( + state, + TaskState::Accepted | TaskState::Integrated | TaskState::Cancelled + ); + let waiting_for = if row.plan_state == PlanState::AwaitingPlanReview && !terminal { + Some(WaitingFor::PlanAcceptance) + } else if state == TaskState::AwaitingReview { + Some(WaitingFor::Acceptance) + } else if matches!( + state, + TaskState::Pending | TaskState::Ready | TaskState::Blocked + ) && !blocked_by.is_empty() + { + Some(WaitingFor::Dependency) + } else if state == TaskState::Failed + && self.store.attempts_for_task(graph_id, n.id)?.len() as u32 + >= self.limits.max_retries + { + Some(WaitingFor::RetryBudgetExhausted) + } else { + None + }; + if waiting_for != Some(WaitingFor::Dependency) { + blocked_by.clear(); + } nodes.push(NodeStatus { task_id: n.id, + waiting_for, + blocked_by, description: n.description.clone(), state, note, diff --git a/docs/start.md b/docs/start.md index 7cf17d7..995e47e 100644 --- a/docs/start.md +++ b/docs/start.md @@ -95,6 +95,8 @@ meshloop accept --task 1 --as your-name meshloop resume ``` +**R1 acceptance rule:** every node stops for `meshloop accept`; the tier selects the harness/model only (ADR 0007 requires human acceptance at Tier 3, and ADR 0016's R1 subset applies it to every node). `meshloop status` / `inspect` show why each node is blocked in `waiting_for` (`acceptance`, `dependency`, `plan_acceptance`, `retry_budget_exhausted`) and `blocked_by`. + ### 5. Integrate — Land Changes on Target Branch Only explicit integration merges verified commits into your active branch: ```bash diff --git a/skills/meshloop-accept/SKILL.md b/skills/meshloop-accept/SKILL.md index edcc3f3..f742709 100644 --- a/skills/meshloop-accept/SKILL.md +++ b/skills/meshloop-accept/SKILL.md @@ -5,6 +5,9 @@ description: Record human acceptance of a Meshloop node. Slash /meshloop:accept. # /meshloop:accept +R1 rule: every node stops for `meshloop accept`; the tier selects harness/model only. +`status`/`inspect` report `waiting_for: acceptance` for nodes awaiting this step. + Only after the user agrees in this supervisor session: ```text diff --git a/skills/meshloop-review-plan/SKILL.md b/skills/meshloop-review-plan/SKILL.md index baca7ae..a741a63 100644 --- a/skills/meshloop-review-plan/SKILL.md +++ b/skills/meshloop-review-plan/SKILL.md @@ -36,3 +36,6 @@ meshloop review-plan --plan meshloop-plan.json --adjust --objective "" | Adjust | still `AwaitingPlanReview` | refuses; ask again after the new graph | Do not call `run --accept-plan` to skip this question. + +R1 rule: every node stops for `meshloop accept`; the tier selects harness/model only. +The review-plan JSON lists them under `acceptance_required`. From 21db24d60ed414a30cac3ada41940fe740ea4d72 Mon Sep 17 00:00:00 2001 From: meshloop Date: Thu, 8 Oct 2026 08:03:53 +0200 Subject: [PATCH 2/8] meshloop: attempt 9 for task 3 --- .../skills/meshloop-run/SKILL.md | 4 +- .../skills/meshloop-status/SKILL.md | 7 + crates/meshloop-cli/src/args.rs | 35 ++++ crates/meshloop-cli/src/main.rs | 160 ++++++++++++++++++ crates/meshloop-cli/tests/plan_and_run.rs | 153 +++++++++++++++++ skills/meshloop-run/SKILL.md | 4 +- skills/meshloop-status/SKILL.md | 7 + 7 files changed, 368 insertions(+), 2 deletions(-) diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md index 74efa81..00a940d 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-run/SKILL.md @@ -18,7 +18,9 @@ meshloop run --plan --json --origin-harness --origin-session A **worker executes via direct CLI in an ephemeral git worktree**. This origin pane must not implement the work. Current branch stays put. -Do not poll. Read the JSON idle reason; then `/meshloop:status` or +Do not poll `inspect`. Block with `meshloop watch --graph --json` (NDJSON +state changes; last line `{"exit":,"nodes":[…]}`; exit 0 = needs a human +or completed, 1 = failed/cancelled, 3 = `--timeout`). Then `/meshloop:status` or `/meshloop:accept`. `--fixture-only` is CI only. `--accept-plan` on `run` is a debug shortcut that skips the 3-way question. diff --git a/crates/meshloop-cli/session-bundle/skills/meshloop-status/SKILL.md b/crates/meshloop-cli/session-bundle/skills/meshloop-status/SKILL.md index d444a38..0d52761 100644 --- a/crates/meshloop-cli/session-bundle/skills/meshloop-status/SKILL.md +++ b/crates/meshloop-cli/session-bundle/skills/meshloop-status/SKILL.md @@ -10,3 +10,10 @@ If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. ```text meshloop status --json [--graph ] --origin-harness --origin-session ``` + +To wait for a run instead of polling, use +`meshloop watch --graph --json [--interval ] [--timeout ]`. It reads +the store only (works on `run --detach`), prints one NDJSON line per node state +change, and ends with `{"exit":,"nodes":[…]}`: `awaiting_review`, +`awaiting_plan_acceptance`, `completed` (exit 0), `failed`/`cancelled` (exit 1), +`timeout` (exit 3). diff --git a/crates/meshloop-cli/src/args.rs b/crates/meshloop-cli/src/args.rs index 4eb21f9..f8913ef 100644 --- a/crates/meshloop-cli/src/args.rs +++ b/crates/meshloop-cli/src/args.rs @@ -83,6 +83,14 @@ pub enum Command { config: Option, db: Option, }, + Watch { + graph: Option, + session_id: Option, + config: Option, + db: Option, + interval_secs: u64, + timeout_secs: u64, + }, Accept { task: u32, identity: String, @@ -306,6 +314,31 @@ fn parse_command(args: &[String]) -> Result { db: flag_value(rest, "--db").map(PathBuf::from), }) } + Some(v) if v == "watch" => { + let rest = &args[1..]; + let session_id = flag_value(rest, "--session-id"); + let graph = flag_value(rest, "--graph").or_else(|| session_id.clone()); + let interval_secs = match flag_value(rest, "--interval") { + Some(raw) => raw.parse::().ok().filter(|n| *n >= 1).ok_or_else(|| { + format!("meshloop:watch --interval expects whole seconds >= 1, got {raw}") + })?, + None => 5, + }; + let timeout_secs = match flag_value(rest, "--timeout") { + Some(raw) => raw.parse::().map_err(|_| { + format!("meshloop:watch --timeout expects whole seconds (0 = none), got {raw}") + })?, + None => 0, + }; + Ok(Command::Watch { + graph, + session_id, + config: flag_value(rest, "--config").map(PathBuf::from), + db: flag_value(rest, "--db").map(PathBuf::from), + interval_secs, + timeout_secs, + }) + } Some(v) if v == "accept" => { let rest = &args[1..]; let task = flag_value(rest, "--task") @@ -414,6 +447,7 @@ pub fn help_text() -> &'static str { Canonical ids: meshloop:plan | meshloop:review-plan | meshloop:run | meshloop:status | meshloop:accept\n\ \x20 meshloop:resume | meshloop:cancel | meshloop:inspect | meshloop:integrate | meshloop:roles\n\ \x20 meshloop:doctor | meshloop:orchestrate | meshloop:mutate-plan | meshloop:mcp | meshloop:bundle\n\ + \x20 meshloop:watch\n\ CLI verbs (binary already namespaces): meshloop plan|run|status|... or meshloop meshloop:plan\n\ Slash: /meshloop:plan MCP tools: meshloop_plan\n\ Usage:\n\ @@ -422,6 +456,7 @@ pub fn help_text() -> &'static str { \x20 meshloop run --plan [--accept-plan] [--reset] [--fixture-only] [--json]\n\ \x20 meshloop resume [--graph ] [--retry|--restart] [--json]\n\ \x20 meshloop status [--graph ] [--config ] [--db ] [--json]\n\ + \x20 meshloop watch --graph [--session-id ] [--interval ] [--timeout ] [--json]\n\ \x20 meshloop roles [--json]\n\ \x20 meshloop doctor [--config ] [--db ] [--json]\n\ \x20 meshloop orchestrate --task --model-a --model-b [--json]\n\ diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index 7b97f3a..d56f82f 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -193,6 +193,23 @@ fn dispatch(inv: Invocation) -> ExitCode { config, db, } => cmd_inspect(task, graph, session_id, config, db, inv.json, inv.origin), + Command::Watch { + graph, + session_id, + config, + db, + interval_secs, + timeout_secs, + } => cmd_watch( + graph, + session_id, + config, + db, + interval_secs, + timeout_secs, + inv.json, + inv.origin, + ), Command::Accept { task, identity, @@ -1284,6 +1301,149 @@ fn cmd_inspect( }) } +/// Why a `watch` stopped, and the nodes responsible. Returns `None` while the run is still moving. +fn watch_exit(s: &meshloop_engine::run_loop::RunStatus) -> Option<(&'static str, Vec)> { + let ids = |state: TaskState| -> Vec { + s.nodes + .iter() + .filter(|n| n.state == state) + .map(|n| n.task_id.0) + .collect() + }; + let review = ids(TaskState::AwaitingReview); + if !review.is_empty() { + return Some(("awaiting_review", review)); + } + if s.plan_state == PlanState::AwaitingPlanReview { + return Some(( + "awaiting_plan_acceptance", + s.nodes.iter().map(|n| n.task_id.0).collect(), + )); + } + if compute_overall_status(s) == "completed" { + return Some(("completed", Vec::new())); + } + let failed = ids(TaskState::Failed); + if !failed.is_empty() { + return Some(("failed", failed)); + } + let cancelled = ids(TaskState::Cancelled); + if !cancelled.is_empty() { + return Some(("cancelled", cancelled)); + } + None +} + +/// Reads the store only (never owns workers), so it also works on `run --detach`. +#[allow(clippy::too_many_arguments)] +fn cmd_watch( + graph: Option, + session_id: Option, + config: Option, + db: Option, + interval_secs: u64, + timeout_secs: u64, + json: bool, + origin: meshloop_engine::origin::Origin, +) -> ExitCode { + let resolved_id = graph.or(session_id); + with_saga(config, db, None, false, origin.clone(), |saga| { + let id = match saga.resolve_graph_id(resolved_id.as_deref()) { + Ok(id) => id, + Err(e) => { + let msg = format!("graph not found: {e:?}"); + if json { + println!("{}", json_out::err("meshloop:watch", origin, msg)); + } else { + eprintln!("{msg}"); + } + return ExitCode::from(2); + } + }; + let started = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + let interval = std::time::Duration::from_secs(interval_secs); + let mut seen: HashMap = HashMap::new(); + loop { + let s = match saga.status(&id) { + Ok(s) => s, + Err(e) => { + let missing = matches!(e, OrchestratorError::MissingGraph(_)); + let msg = if missing { + format!("graph '{id}' not found: {e:?}") + } else { + format!("{e:?}") + }; + if json { + println!("{}", json_out::err("meshloop:watch", origin, msg)); + } else { + eprintln!("{msg}"); + } + return ExitCode::from(if missing { 2 } else { 1 }); + } + }; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + for n in &s.nodes { + let prev = seen.insert(n.task_id.0, n.state); + if prev == Some(n.state) { + continue; + } + let waiting = n.waiting_for.map(|w| w.as_str()); + if json { + println!( + "{}", + serde_json::json!({ + "ts": ts, + "graph_id": s.graph_id, + "node": n.task_id.0, + "from": prev.map(|p| format!("{p:?}")), + "to": format!("{:?}", n.state), + "waiting_for": waiting, + }) + ); + } else { + let from = prev.map_or("-".to_string(), |p| format!("{p:?}")); + let wait = waiting.map_or(String::new(), |w| format!(" (waiting: {w})")); + println!( + "[{ts}] {} node {}: {from} -> {:?}{wait}", + s.graph_id, n.task_id.0, n.state + ); + } + } + let (reason, nodes, code) = match watch_exit(&s) { + Some((reason, nodes)) => { + let code = match reason { + "failed" | "cancelled" => 1, + _ => 0, + }; + (reason, nodes, code) + } + None if timeout_secs > 0 && started.elapsed() >= timeout => { + ("timeout", Vec::new(), 3) + } + None => { + let mut nap = interval; + if timeout_secs > 0 { + nap = nap.min(timeout.saturating_sub(started.elapsed())); + } + std::thread::sleep(nap); + continue; + } + }; + if json { + println!("{}", serde_json::json!({ "exit": reason, "nodes": nodes })); + } else { + let ids: Vec = nodes.iter().map(u32::to_string).collect(); + println!("exit: {reason} nodes=[{}]", ids.join(",")); + } + return ExitCode::from(code); + } + }) +} + fn cmd_accept( task: u32, identity: String, diff --git a/crates/meshloop-cli/tests/plan_and_run.rs b/crates/meshloop-cli/tests/plan_and_run.rs index ea4d736..3baa201 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -1766,3 +1766,156 @@ fn doctor_has_no_warnings_when_verify_command_is_set() { let warnings = doctor_warnings("doctor-verify-set", r#"verify_command = ["true"]"#); assert!(warnings.is_empty(), "{warnings:?}"); } + +fn watch_lines(out: &std::process::Output) -> Vec { + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("NDJSON line {l:?}: {e}"))) + .collect() +} + +fn run_fixture_plan(dir: &Path, config: &Path, db: &Path, graph_id: &str) { + let plan_path = dir.join("plan.json"); + fs::write( + &plan_path, + format!( + r#"{{"graph_id":"{graph_id}","nodes":[{{"id":1,"description":"first","depends_on":[],"tier":null}}]}}"# + ), + ) + .unwrap(); + meshloop() + .current_dir(dir) + .args(["run", "--plan"]) + .arg(&plan_path) + .args(["--accept-plan", "--fixture-only", "--config"]) + .arg(config) + .arg("--db") + .arg(db) + .arg("--worktree-base") + .arg(dir.join("worktrees")) + .output() + .expect("run"); +} + +#[test] +fn watch_exits_zero_when_a_node_awaits_review() { + let dir = disposable_repo("watch-review"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let db_path = dir.join("state.sqlite"); + run_fixture_plan(&dir, &config_path, &db_path, "gwatch"); + + let elsewhere = empty_temp_dir("watch-review-cwd"); + let out = meshloop() + .current_dir(&elsewhere) + .args(["watch", "--graph", "gwatch", "--json", "--config"]) + .arg(&config_path) + .arg("--db") + .arg(&db_path) + .output() + .expect("watch"); + assert_eq!(out.status.code(), Some(0), "{out:?}"); + let lines = watch_lines(&out); + let last = lines.last().expect("lines"); + assert_eq!( + *last, + serde_json::json!({"exit":"awaiting_review","nodes":[1]}) + ); + let first = &lines[0]; + assert_eq!(first["node"], 1); + assert_eq!(first["from"], serde_json::Value::Null); + assert_eq!(first["to"], "AwaitingReview"); + assert_eq!(first["waiting_for"], "acceptance"); + assert_eq!(first["graph_id"], "gwatch"); + + fs::remove_dir_all(&dir).ok(); + fs::remove_dir_all(&elsewhere).ok(); +} + +#[test] +fn watch_exits_nonzero_naming_a_failed_node() { + let dir = disposable_repo("watch-failed"); + let config_path = write_config(&dir, r#"["--fail"]"#); + let db_path = dir.join("state.sqlite"); + run_fixture_plan(&dir, &config_path, &db_path, "gfail"); + + let out = meshloop() + .current_dir(&dir) + .args(["watch", "--graph", "gfail", "--json", "--config"]) + .arg(&config_path) + .arg("--db") + .arg(&db_path) + .output() + .expect("watch"); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + let lines = watch_lines(&out); + assert_eq!( + *lines.last().expect("lines"), + serde_json::json!({"exit":"failed","nodes":[1]}) + ); + + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn watch_with_unknown_graph_is_an_error_naming_it() { + let dir = disposable_repo("watch-unknown"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let out = meshloop() + .current_dir(&dir) + .args(["watch", "--graph", "nope", "--json", "--config"]) + .arg(&config_path) + .arg("--db") + .arg(dir.join("state.sqlite")) + .output() + .expect("watch"); + assert_eq!(out.status.code(), Some(2), "{out:?}"); + let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json"); + assert_eq!(parsed["ok"], false); + assert!(parsed.to_string().contains("nope"), "{parsed}"); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn watch_times_out_when_the_run_does_not_change() { + let dir = disposable_repo("watch-timeout"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let plan_path = dir.join("plan.json"); + let db_path = dir.join("state.sqlite"); + fs::write( + &plan_path, + r#"{"graph_id":"gidle","nodes":[{"id":1,"description":"first","depends_on":[],"tier":null}]}"#, + ) + .unwrap(); + // Accepted but never run: node state stays put. + let reviewed = meshloop() + .current_dir(&dir) + .args(["review-plan", "--plan"]) + .arg(&plan_path) + .args(["--accept", "--as", "sam", "--json", "--config"]) + .arg(&config_path) + .arg("--db") + .arg(&db_path) + .output() + .expect("review-plan"); + assert!(reviewed.status.success(), "{reviewed:?}"); + + let started = std::time::Instant::now(); + let out = meshloop() + .current_dir(&dir) + .args([ + "watch", "--graph", "gidle", "--timeout", "1", "--json", "--config", + ]) + .arg(&config_path) + .arg("--db") + .arg(&db_path) + .output() + .expect("watch"); + assert_eq!(out.status.code(), Some(3), "{out:?}"); + assert!(started.elapsed().as_secs() < 5); + let lines = watch_lines(&out); + assert_eq!( + *lines.last().expect("lines"), + serde_json::json!({"exit":"timeout","nodes":[]}) + ); + fs::remove_dir_all(&dir).ok(); +} diff --git a/skills/meshloop-run/SKILL.md b/skills/meshloop-run/SKILL.md index 74efa81..00a940d 100644 --- a/skills/meshloop-run/SKILL.md +++ b/skills/meshloop-run/SKILL.md @@ -18,7 +18,9 @@ meshloop run --plan --json --origin-harness --origin-session A **worker executes via direct CLI in an ephemeral git worktree**. This origin pane must not implement the work. Current branch stays put. -Do not poll. Read the JSON idle reason; then `/meshloop:status` or +Do not poll `inspect`. Block with `meshloop watch --graph --json` (NDJSON +state changes; last line `{"exit":,"nodes":[…]}`; exit 0 = needs a human +or completed, 1 = failed/cancelled, 3 = `--timeout`). Then `/meshloop:status` or `/meshloop:accept`. `--fixture-only` is CI only. `--accept-plan` on `run` is a debug shortcut that skips the 3-way question. diff --git a/skills/meshloop-status/SKILL.md b/skills/meshloop-status/SKILL.md index d444a38..0d52761 100644 --- a/skills/meshloop-status/SKILL.md +++ b/skills/meshloop-status/SKILL.md @@ -10,3 +10,10 @@ If `MESHLOOP_ORIGIN_SESSION` is unset, run `/meshloop:doctor` first. ```text meshloop status --json [--graph ] --origin-harness --origin-session ``` + +To wait for a run instead of polling, use +`meshloop watch --graph --json [--interval ] [--timeout ]`. It reads +the store only (works on `run --detach`), prints one NDJSON line per node state +change, and ends with `{"exit":,"nodes":[…]}`: `awaiting_review`, +`awaiting_plan_acceptance`, `completed` (exit 0), `failed`/`cancelled` (exit 1), +`timeout` (exit 3). From 0f69686efa1e85ae70d530301cc44b5f3cf95261 Mon Sep 17 00:00:00 2001 From: meshloop Date: Thu, 8 Oct 2026 08:04:27 +0200 Subject: [PATCH 3/8] meshloop: repair round 1 for task 3 --- crates/meshloop-cli/tests/plan_and_run.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/meshloop-cli/tests/plan_and_run.rs b/crates/meshloop-cli/tests/plan_and_run.rs index 3baa201..c430e91 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -1903,7 +1903,13 @@ fn watch_times_out_when_the_run_does_not_change() { let out = meshloop() .current_dir(&dir) .args([ - "watch", "--graph", "gidle", "--timeout", "1", "--json", "--config", + "watch", + "--graph", + "gidle", + "--timeout", + "1", + "--json", + "--config", ]) .arg(&config_path) .arg("--db") From 60e326f683fa55d437f1158ac4f75ed790f0aa9c Mon Sep 17 00:00:00 2001 From: meshloop Date: Thu, 8 Oct 2026 08:13:01 +0200 Subject: [PATCH 4/8] meshloop: attempt 11 for task 2 --- crates/meshloop-adapters/src/store.rs | 24 ++++++ crates/meshloop-cli/src/args.rs | 2 + crates/meshloop-cli/src/main.rs | 62 +++++++++++++- crates/meshloop-cli/src/report.rs | 34 +++++++- crates/meshloop-engine/src/ports.rs | 23 ++++++ crates/meshloop-engine/src/run_loop.rs | 107 +++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 2 deletions(-) diff --git a/crates/meshloop-adapters/src/store.rs b/crates/meshloop-adapters/src/store.rs index 59d576e..f0ff889 100644 --- a/crates/meshloop-adapters/src/store.rs +++ b/crates/meshloop-adapters/src/store.rs @@ -370,6 +370,30 @@ impl EvidenceStore for SqliteStore { } Ok(out) } + + fn evidence_for_attempt( + &self, + task_id: TaskId, + attempt_id: AttemptId, + ) -> Result, StoreError> { + let mut stmt = self + .conn + .prepare( + "SELECT payload_json FROM evidence WHERE task_id = ?1 AND attempt_id = ?2 ORDER BY rowid", + ) + .map_err(|e| StoreError::Io(e.to_string()))?; + let rows = stmt + .query_map(params![task_id.0, attempt_id.0], |r| r.get::<_, String>(0)) + .map_err(|e| StoreError::Io(e.to_string()))?; + let mut out = Vec::new(); + for row in rows { + let json = row.map_err(|e| StoreError::Io(e.to_string()))?; + let ev: Evidence = + serde_json::from_str(&json).map_err(|e| StoreError::Corrupt(e.to_string()))?; + out.push(ev); + } + Ok(out) + } } impl RoutingFeedbackStore for SqliteStore { diff --git a/crates/meshloop-cli/src/args.rs b/crates/meshloop-cli/src/args.rs index 4eb21f9..8187b32 100644 --- a/crates/meshloop-cli/src/args.rs +++ b/crates/meshloop-cli/src/args.rs @@ -82,6 +82,7 @@ pub enum Command { session_id: Option, config: Option, db: Option, + attempts: bool, }, Accept { task: u32, @@ -304,6 +305,7 @@ fn parse_command(args: &[String]) -> Result { session_id, config: flag_value(rest, "--config").map(PathBuf::from), db: flag_value(rest, "--db").map(PathBuf::from), + attempts: has_flag(rest, "--attempts"), }) } Some(v) if v == "accept" => { diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index 7b97f3a..639ad85 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -192,7 +192,10 @@ fn dispatch(inv: Invocation) -> ExitCode { session_id, config, db, - } => cmd_inspect(task, graph, session_id, config, db, inv.json, inv.origin), + attempts, + } => cmd_inspect( + task, graph, session_id, config, db, attempts, inv.json, inv.origin, + ), Command::Accept { task, identity, @@ -999,6 +1002,16 @@ fn cmd_status( match saga.status(&id) { Ok(s) => { if json { + let summary = match saga.run_summary(&id) { + Ok(v) => v, + Err(e) => { + println!( + "{}", + json_out::err("meshloop:status", origin.clone(), format!("{e:?}")) + ); + return ExitCode::from(2); + } + }; println!( "{}", json_out::ok( @@ -1006,6 +1019,11 @@ fn cmd_status( origin.clone(), serde_json::json!({ "graph_id": s.graph_id, + "summary": { + "attempts": summary.attempts, + "retries": summary.retries, + "worker_wall_seconds": summary.worker_wall_seconds, + }, "plan_state": format!("{:?}", s.plan_state), "nodes": s.nodes.iter().map(|n| serde_json::json!({ "id": n.task_id.0, @@ -1176,6 +1194,7 @@ fn cmd_inspect( session_id: Option, config: Option, db: Option, + with_attempts: bool, json: bool, origin: meshloop_engine::origin::Origin, ) -> ExitCode { @@ -1199,6 +1218,20 @@ fn cmd_inspect( Ok(s) => { if let Some(t) = task { if let Some(n) = s.nodes.iter().find(|n| n.task_id.0 == t) { + let attempts = match saga.attempts(&id, n.task_id) { + Ok(a) => a, + Err(e) => { + if json { + println!( + "{}", + json_out::err("meshloop:inspect", origin, format!("{e:?}")) + ); + } else { + eprintln!("{e:?}"); + } + return ExitCode::from(1); + } + }; if json { println!( "{}", @@ -1216,6 +1249,7 @@ fn cmd_inspect( "note": n.note, "waiting_for": n.waiting_for.map(|w| w.as_str()), "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), + "attempts": attempts.iter().map(report::attempt_json).collect::>(), }), ) ); @@ -1224,6 +1258,9 @@ fn cmd_inspect( "task {} state={:?} {} worktree={:?}", n.task_id.0, n.state, n.description, n.worktree ); + for a in &attempts { + println!("{}", report::format_attempt(a)); + } } ExitCode::SUCCESS } else { @@ -1258,6 +1295,29 @@ fn cmd_inspect( "blocked_by": n.blocked_by.iter().map(|d| d.0).collect::>(), })).collect::>(), }); + if with_attempts { + for node in data["nodes"].as_array_mut().into_iter().flatten() { + let tid = TaskId(node["id"].as_u64().unwrap_or(0) as u32); + match saga.attempts(&id, tid) { + Ok(a) => { + node["attempts"] = serde_json::Value::Array( + a.iter().map(report::attempt_json).collect(), + ); + } + Err(e) => { + println!( + "{}", + json_out::err( + "meshloop:inspect", + origin, + format!("{e:?}") + ) + ); + return ExitCode::from(1); + } + } + } + } if let Some(export) = git_export { data["git_export"] = serde_json::to_value(export).unwrap_or(serde_json::Value::Null); diff --git a/crates/meshloop-cli/src/report.rs b/crates/meshloop-cli/src/report.rs index a2ed422..9632e92 100644 --- a/crates/meshloop-cli/src/report.rs +++ b/crates/meshloop-cli/src/report.rs @@ -2,7 +2,7 @@ use meshloop_domain::state::TaskState; use meshloop_domain::task_graph::TaskGraph; -use meshloop_engine::run_loop::{IdleReason, RunStatus}; +use meshloop_engine::run_loop::{AttemptView, IdleReason, RunStatus}; pub const ACCEPTANCE_RULE: &str = "R1: every node stops for meshloop accept; tier selects harness/model only"; @@ -97,6 +97,38 @@ pub fn format_status(status: &RunStatus) -> String { out } +pub fn attempt_json(a: &AttemptView) -> serde_json::Value { + serde_json::json!({ + "attempt_id": a.attempt_id.0, + "harness": a.harness, + "model_ref": a.model_ref, + "started_at": a.started_at, + "ended_at": a.ended_at, + "duration_s": a.duration_s, + "outcome": a.outcome, + "evidence": a.evidence.iter().map(|e| serde_json::json!({ + "kind": e.kind, + "tool": e.tool, + "exit_code": e.exit_code, + "summary_redacted": e.summary_redacted, + })).collect::>(), + }) +} + +pub fn format_attempt(a: &AttemptView) -> String { + format!( + " attempt {} harness={} model={} duration={} outcome={} evidence={}", + a.attempt_id.0, + a.harness.as_deref().unwrap_or("-"), + a.model_ref.as_deref().unwrap_or("-"), + a.duration_s + .map(|d| format!("{d}s")) + .unwrap_or_else(|| "-".into()), + a.outcome.as_deref().unwrap_or("-"), + a.evidence.len() + ) +} + pub fn banner() -> String { "Meshloop: agent-session control plane (native Windows). Binary is the engine; skills/MCP are the UX.\n\ Commands: plan, review-plan, run --accept-plan, status, resume, cancel, inspect, accept, integrate, orchestrate, doctor.\n\ diff --git a/crates/meshloop-engine/src/ports.rs b/crates/meshloop-engine/src/ports.rs index a5b8b39..4b63da9 100644 --- a/crates/meshloop-engine/src/ports.rs +++ b/crates/meshloop-engine/src/ports.rs @@ -226,6 +226,13 @@ pub struct AttemptRow { pub trait EvidenceStore { fn record(&mut self, evidence: Evidence) -> Result<(), StoreError>; fn evidence_for(&self, candidate: &CandidateRef) -> Result, StoreError>; + /// Every evidence row recorded for an attempt, whatever revision it was bound to + /// (read-only display; gating still uses the exact-candidate `evidence_for`). + fn evidence_for_attempt( + &self, + task_id: TaskId, + attempt_id: AttemptId, + ) -> Result, StoreError>; } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -369,6 +376,22 @@ impl EvidenceStore for InMemoryEvidenceStore { .cloned() .collect()) } + + fn evidence_for_attempt( + &self, + task_id: TaskId, + attempt_id: AttemptId, + ) -> Result, StoreError> { + Ok(self + .evidence + .iter() + .filter(|e| { + let c = e.candidate(); + c.task_id == task_id && c.attempt_id == attempt_id + }) + .cloned() + .collect()) + } } #[cfg(test)] diff --git a/crates/meshloop-engine/src/run_loop.rs b/crates/meshloop-engine/src/run_loop.rs index 22323b9..ead11ee 100644 --- a/crates/meshloop-engine/src/run_loop.rs +++ b/crates/meshloop-engine/src/run_loop.rs @@ -164,6 +164,68 @@ pub struct RunStatus { pub nodes: Vec, } +/// One evidence row as shown to operators: only the already-redacted summary is carried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvidenceView { + pub kind: &'static str, + pub tool: Option, + pub exit_code: Option, + pub summary_redacted: String, +} + +impl EvidenceView { + fn from_evidence(e: &Evidence) -> Self { + match e { + Evidence::Deterministic(d) => Self { + kind: "deterministic", + tool: Some(d.tool.clone()), + exit_code: Some(d.exit_code), + summary_redacted: d.output_redacted.clone(), + }, + Evidence::ModelReview(m) => Self { + kind: "model_review", + tool: Some(format!("{}/{}", m.harness, m.model_ref)), + exit_code: None, + summary_redacted: format!("{:?}: {}", m.verdict, m.rationale_redacted), + }, + Evidence::HumanAcceptance(h) => Self { + kind: "human_acceptance", + tool: None, + exit_code: None, + summary_redacted: format!("accepted by {} at {}", h.accepted_by, h.accepted_at), + }, + } + } +} + +/// One attempt as shown to operators (`inspect`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttemptView { + pub attempt_id: AttemptId, + pub harness: Option, + pub model_ref: Option, + pub started_at: Option, + pub ended_at: Option, + pub duration_s: Option, + pub outcome: Option, + pub evidence: Vec, +} + +/// Run-level totals for `status`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RunSummary { + pub attempts: u32, + /// Attempts beyond the first per task. + pub retries: u32, + pub worker_wall_seconds: u64, +} + +fn duration_secs(started: &Option, ended: &Option) -> Option { + let s = started.as_deref()?.parse::().ok()?; + let e = ended.as_deref()?.parse::().ok()?; + Some(e.saturating_sub(s)) +} + fn stamp() -> String { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1945,6 +2007,51 @@ impl<'a> RunLoop<'a> { Ok(()) } + /// Attempts for a task, oldest first, with their (redacted) evidence rows. + pub fn attempts( + &self, + graph_id: &str, + task_id: TaskId, + ) -> Result, OrchestratorError> { + let mut rows = self.store.attempts_for_task(graph_id, task_id)?; + rows.sort_by_key(|a| a.attempt_id.0); + rows.into_iter() + .map(|a| { + let evidence = self + .store + .evidence_for_attempt(a.task_id, a.attempt_id)? + .iter() + .map(EvidenceView::from_evidence) + .collect(); + Ok(AttemptView { + attempt_id: a.attempt_id, + duration_s: duration_secs(&a.started_at, &a.ended_at), + harness: a.harness, + model_ref: a.model_ref, + started_at: a.started_at, + ended_at: a.ended_at, + outcome: a.outcome, + evidence, + }) + }) + .collect() + } + + pub fn run_summary(&self, graph_id: &str) -> Result { + let rows = self.store.attempts_for_graph(graph_id)?; + let mut per_task: HashMap = HashMap::new(); + let mut wall = 0u64; + for a in &rows { + *per_task.entry(a.task_id).or_default() += 1; + wall += duration_secs(&a.started_at, &a.ended_at).unwrap_or(0); + } + Ok(RunSummary { + attempts: rows.len() as u32, + retries: per_task.values().map(|c| c - 1).sum(), + worker_wall_seconds: wall, + }) + } + pub fn status(&mut self, graph_id: &str) -> Result { let row = self .store From 803df3c804d8c973f242562fbe1c05d1a064cd36 Mon Sep 17 00:00:00 2001 From: Samuel <184254+smota@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:23:11 +0200 Subject: [PATCH 5/8] fix(engine): record attempt end time and report ledger outcome; add #57 tests - set attempts.ended_at on HarnessExited (crash/timeout left open: a live pane may still be harvested, and liveness treats an ended attempt as Dead) - inspect outcome comes from the attempt's last ledger event; the outcome column keeps holding the base revision recovery reads, now shown as base_revision - restore node 2's attempt-8 attempts/summary tests that attempt 11 dropped - regression test drives a real fixture run, not hand-written attempt rows Co-Authored-By: Claude Opus 5.5 --- crates/meshloop-cli/src/report.rs | 1 + crates/meshloop-cli/tests/plan_and_run.rs | 215 ++++++++++++++++++++++ crates/meshloop-engine/src/run_loop.rs | 34 +++- 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/crates/meshloop-cli/src/report.rs b/crates/meshloop-cli/src/report.rs index 9632e92..0428e66 100644 --- a/crates/meshloop-cli/src/report.rs +++ b/crates/meshloop-cli/src/report.rs @@ -106,6 +106,7 @@ pub fn attempt_json(a: &AttemptView) -> serde_json::Value { "ended_at": a.ended_at, "duration_s": a.duration_s, "outcome": a.outcome, + "base_revision": a.base_revision, "evidence": a.evidence.iter().map(|e| serde_json::json!({ "kind": e.kind, "tool": e.tool, diff --git a/crates/meshloop-cli/tests/plan_and_run.rs b/crates/meshloop-cli/tests/plan_and_run.rs index c430e91..7ad5b5b 100644 --- a/crates/meshloop-cli/tests/plan_and_run.rs +++ b/crates/meshloop-cli/tests/plan_and_run.rs @@ -179,6 +179,56 @@ fn run_refuses_without_accept_plan_flag() { fs::remove_dir_all(&dir).ok(); } +#[test] +fn inspect_reports_real_attempt_timing_and_outcome() { + let dir = disposable_repo("attempt-timing"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let plan_path = dir.join("plan.json"); + let db_path = dir.join(".meshloop").join("state.sqlite"); + let worktree_base = dir.join("worktrees"); + fs::create_dir_all(dir.join(".meshloop")).unwrap(); + fs::write( + &plan_path, + r#"{"graph_id":"gt","nodes":[{"id":1,"description":"first","depends_on":[],"tier":null}]}"#, + ) + .unwrap(); + let run = meshloop() + .current_dir(&dir) + .args(["run", "--plan"]) + .arg(&plan_path) + .args(["--accept-plan", "--config"]) + .arg(&config_path) + .args(["--worktree-base"]) + .arg(&worktree_base) + .args(["--db"]) + .arg(&db_path) + .output() + .expect("run"); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + + let out = meshloop() + .current_dir(&dir) + .args([ + "inspect", "--graph", "gt", "--task", "1", "--json", "--config", + ]) + .arg(&config_path) + .args(["--db"]) + .arg(&db_path) + .output() + .expect("inspect"); + let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json"); + let attempt = &parsed["data"]["attempts"][0]; + // Real runs must record when the worker exited, not only fixture rows. + assert!(attempt["ended_at"].is_string(), "{parsed}"); + assert!(attempt["duration_s"].is_u64(), "{parsed}"); + assert_eq!(attempt["outcome"], "DeterministicChecksPassed", "{parsed}"); + assert!(attempt["base_revision"].is_string(), "{parsed}"); +} + #[test] fn run_pauses_for_human_accept_then_resume_integrates() { let dir = disposable_repo("run"); @@ -870,6 +920,171 @@ fn status_reports_waiting_for_acceptance_and_dependency() { fs::remove_dir_all(&dir).ok(); } +fn json_cmd(dir: &Path, config: &Path, db: &Path, args: &[&str]) -> serde_json::Value { + let out = meshloop() + .current_dir(dir) + .args(args) + .args(["--json", "--config"]) + .arg(config) + .args(["--db"]) + .arg(db) + .output() + .expect("meshloop json command"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{args:?} stdout: {stdout}"); + serde_json::from_str(&stdout).expect("json") +} + +#[test] +fn inspect_lists_attempts_with_redacted_evidence_and_status_summarizes_run() { + use meshloop_adapters::store::SqliteStore; + use meshloop_domain::evidence::{ + AttemptId, CandidateRef, DeterministicEvidence, Evidence, HumanAcceptanceEvidence, + }; + use meshloop_domain::task_graph::TaskId; + use meshloop_engine::ports::{AttemptRow, EvidenceStore, RunStore}; + + let dir = disposable_repo("inspect-attempts"); + let config_path = write_config(&dir, r#"["--prompt-file", "{prompt_file}"]"#); + let plan_path = dir.join("plan.json"); + let db_path = dir.join(".meshloop").join("state.sqlite"); + let worktree_base = dir.join("worktrees"); + fs::create_dir_all(dir.join(".meshloop")).unwrap(); + fs::write( + &plan_path, + r#"{"graph_id":"gat","nodes":[{"id":1,"description":"first","depends_on":[],"tier":null},{"id":2,"description":"second","depends_on":[1],"tier":null}]}"#, + ) + .unwrap(); + let output = meshloop() + .current_dir(&dir) + .args(["run", "--plan"]) + .arg(&plan_path) + .args(["--accept-plan", "--config"]) + .arg(&config_path) + .args(["--worktree-base"]) + .arg(&worktree_base) + .args(["--db"]) + .arg(&db_path) + .output() + .expect("run"); + assert!(output.status.success()); + + // Node 2 never started (blocked on node 1), so seed two known attempts for it. + { + let mut store = SqliteStore::open(&db_path).expect("open store"); + let mut seed = |harness: &str, model: &str, start: &str, end: &str, outcome: &str| { + let attempt_id = store.next_attempt_id().unwrap(); + store + .save_attempt(&AttemptRow { + attempt_id, + graph_id: "gat".into(), + task_id: TaskId(2), + harness: Some(harness.into()), + model_ref: Some(model.into()), + worktree_path: None, + pid: None, + image_name: None, + pane_id: None, + started_at: Some(start.into()), + ended_at: Some(end.into()), + outcome: Some(outcome.into()), + }) + .unwrap(); + attempt_id + }; + let first = seed("alpha", "model-a", "1000", "1010", "failed"); + let second = seed("beta", "model-b", "2000", "2025", "ok"); + let cand = |attempt_id: AttemptId| CandidateRef { + task_id: TaskId(2), + attempt_id, + revision: "rev".into(), + }; + store + .record(Evidence::Deterministic(DeterministicEvidence { + candidate: cand(first), + tool: "git-diff".into(), + tool_version: "n/a".into(), + exit_code: 1, + output_redacted: "REDACTED-STAT".into(), + })) + .unwrap(); + store + .record(Evidence::HumanAcceptance(HumanAcceptanceEvidence { + candidate: cand(second), + accepted_by: "sam".into(), + accepted_at: "3000".into(), + })) + .unwrap(); + } + + let task = json_cmd( + &dir, + &config_path, + &db_path, + &["inspect", "--task", "2", "--graph", "gat"], + ); + let attempts = task["data"]["attempts"].as_array().expect("attempts"); + assert_eq!(attempts.len(), 2, "{task}"); + assert_eq!(attempts[0]["harness"], "alpha"); + assert_eq!(attempts[0]["model_ref"], "model-a"); + assert_eq!(attempts[0]["started_at"], "1000"); + assert_eq!(attempts[0]["ended_at"], "1010"); + assert_eq!(attempts[0]["duration_s"], 10); + assert_eq!(attempts[0]["outcome"], "failed"); + assert_eq!(attempts[0]["evidence"][0]["kind"], "deterministic"); + assert_eq!(attempts[0]["evidence"][0]["tool"], "git-diff"); + assert_eq!(attempts[0]["evidence"][0]["exit_code"], 1); + assert_eq!( + attempts[0]["evidence"][0]["summary_redacted"], + "REDACTED-STAT" + ); + assert_eq!(attempts[1]["harness"], "beta"); + assert_eq!(attempts[1]["duration_s"], 25); + assert_eq!(attempts[1]["evidence"][0]["kind"], "human_acceptance"); + assert!(attempts[1]["evidence"][0]["exit_code"].is_null()); + + // Whole-graph inspect only carries attempts when asked. + let plain = json_cmd(&dir, &config_path, &db_path, &["inspect", "--graph", "gat"]); + assert!( + plain["data"]["nodes"][1].get("attempts").is_none(), + "{plain}" + ); + let with = json_cmd( + &dir, + &config_path, + &db_path, + &["inspect", "--graph", "gat", "--attempts"], + ); + let n2 = with["data"]["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == 2) + .unwrap(); + assert_eq!(n2["attempts"].as_array().unwrap().len(), 2); + + // Node 1 ran once for real; its wall time joins the seeded 10s + 25s. + let t1 = json_cmd( + &dir, + &config_path, + &db_path, + &["inspect", "--task", "1", "--graph", "gat"], + ); + assert_eq!(t1["data"]["attempts"].as_array().unwrap().len(), 1, "{t1}"); + let n1_wall = t1["data"]["attempts"][0]["duration_s"] + .as_u64() + .unwrap_or(0); + let status = json_cmd(&dir, &config_path, &db_path, &["status", "--graph", "gat"]); + assert_eq!(status["data"]["summary"]["attempts"], 3, "{status}"); + assert_eq!(status["data"]["summary"]["retries"], 1); + assert_eq!( + status["data"]["summary"]["worker_wall_seconds"], + 35 + n1_wall + ); + + fs::remove_dir_all(&dir).ok(); +} + #[test] fn review_plan_lists_acceptance_required_and_nodes_wait_for_plan_acceptance() { let dir = disposable_repo("plan-acceptance"); diff --git a/crates/meshloop-engine/src/run_loop.rs b/crates/meshloop-engine/src/run_loop.rs index ead11ee..5913edd 100644 --- a/crates/meshloop-engine/src/run_loop.rs +++ b/crates/meshloop-engine/src/run_loop.rs @@ -207,7 +207,10 @@ pub struct AttemptView { pub started_at: Option, pub ended_at: Option, pub duration_s: Option, + /// The attempt's last ledger event (e.g. `DeterministicChecksPassed`). pub outcome: Option, + /// Revision the attempt's worktree started from. + pub base_revision: Option, pub evidence: Vec, } @@ -324,6 +327,16 @@ impl<'a> RunLoop<'a> { occurred_at: stamp(), }; self.store.append(rec.clone())?; + // A normal exit ends the attempt. Crash/timeout leaves `ended_at` unset: a live pane + // may still be harvested, and liveness treats any ended attempt as Dead. + if event == Event::HarnessExited + && let Some(id) = attempt + && let Some(mut row) = self.store.load_attempt(id)? + && row.ended_at.is_none() + { + row.ended_at = Some(rec.occurred_at.clone()); + self.store.save_attempt(&row)?; + } Ok(rec) } @@ -2015,8 +2028,26 @@ impl<'a> RunLoop<'a> { ) -> Result, OrchestratorError> { let mut rows = self.store.attempts_for_task(graph_id, task_id)?; rows.sort_by_key(|a| a.attempt_id.0); + // The attempt row's `outcome` column holds the attempt's base revision ("base:"), + // which recovery reads; the outcome shown is the attempt's last ledger event instead. + let mut last_event: HashMap = HashMap::new(); + for r in self.store.records_for_graph(graph_id)? { + if let Some(id) = r.attempt_id { + last_event.insert(id, r.event); + } + } rows.into_iter() .map(|a| { + let base_revision = a + .outcome + .as_deref() + .and_then(|o| o.strip_prefix("base:")) + .map(String::from); + let outcome = match last_event.get(&a.attempt_id) { + Some(e) => Some(format!("{e:?}")), + None if base_revision.is_none() => a.outcome.clone(), + None => None, + }; let evidence = self .store .evidence_for_attempt(a.task_id, a.attempt_id)? @@ -2030,7 +2061,8 @@ impl<'a> RunLoop<'a> { model_ref: a.model_ref, started_at: a.started_at, ended_at: a.ended_at, - outcome: a.outcome, + outcome, + base_revision, evidence, }) }) From c2449661b046310bca58af317ea0c9e9fedeae86 Mon Sep 17 00:00:00 2001 From: Samuel <184254+smota@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:27:51 +0200 Subject: [PATCH 6/8] fix(cli): watch reports a failed or cancelled node before a review stop Co-Authored-By: Claude Opus 5.5 --- crates/meshloop-cli/src/main.rs | 52 ++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/meshloop-cli/src/main.rs b/crates/meshloop-cli/src/main.rs index 32a2b8b..3357f8c 100644 --- a/crates/meshloop-cli/src/main.rs +++ b/crates/meshloop-cli/src/main.rs @@ -1370,6 +1370,15 @@ fn watch_exit(s: &meshloop_engine::run_loop::RunStatus) -> Option<(&'static str, .map(|n| n.task_id.0) .collect() }; + // A failure outranks a review stop: exiting 0 on `awaiting_review` would hide it. + let failed = ids(TaskState::Failed); + if !failed.is_empty() { + return Some(("failed", failed)); + } + let cancelled = ids(TaskState::Cancelled); + if !cancelled.is_empty() { + return Some(("cancelled", cancelled)); + } let review = ids(TaskState::AwaitingReview); if !review.is_empty() { return Some(("awaiting_review", review)); @@ -1383,14 +1392,6 @@ fn watch_exit(s: &meshloop_engine::run_loop::RunStatus) -> Option<(&'static str, if compute_overall_status(s) == "completed" { return Some(("completed", Vec::new())); } - let failed = ids(TaskState::Failed); - if !failed.is_empty() { - return Some(("failed", failed)); - } - let cancelled = ids(TaskState::Cancelled); - if !cancelled.is_empty() { - return Some(("cancelled", cancelled)); - } None } @@ -2178,3 +2179,38 @@ fn cmd_mutate_plan( } ExitCode::SUCCESS } + +#[cfg(test)] +mod watch_exit_tests { + use super::*; + use meshloop_engine::run_loop::{NodeStatus, RunStatus}; + + fn node(id: u32, state: TaskState) -> NodeStatus { + NodeStatus { + task_id: TaskId(id), + waiting_for: None, + blocked_by: Vec::new(), + description: String::new(), + state, + note: None, + worktree: None, + revision: None, + pane_id: None, + live: None, + } + } + + #[test] + fn a_failed_node_outranks_one_awaiting_review() { + let s = RunStatus { + graph_id: "g".into(), + plan_state: PlanState::PlanAccepted, + nodes: vec![ + node(1, TaskState::Integrated), + node(2, TaskState::Failed), + node(3, TaskState::AwaitingReview), + ], + }; + assert_eq!(watch_exit(&s), Some(("failed", vec![2]))); + } +} From 9570320ef29aa514d9bb70a395a5d7c61d8478e3 Mon Sep 17 00:00:00 2001 From: meshloop Date: Thu, 8 Oct 2026 08:30:14 +0200 Subject: [PATCH 7/8] meshloop: attempt 13 for task 1 --- crates/meshloop-cli/src/args.rs | 242 +++++++++++++++++++++ crates/meshloop-cli/tests/unknown_flags.rs | 36 +++ 2 files changed, 278 insertions(+) create mode 100644 crates/meshloop-cli/tests/unknown_flags.rs diff --git a/crates/meshloop-cli/src/args.rs b/crates/meshloop-cli/src/args.rs index 96a97db..fd37015 100644 --- a/crates/meshloop-cli/src/args.rs +++ b/crates/meshloop-cli/src/args.rs @@ -172,7 +172,168 @@ pub fn parse(args: &[String]) -> Result { }) } +/// Flags every subcommand accepts: `(takes_value, name)`. +const GLOBAL_FLAGS: &[(bool, &str)] = &[ + (false, "--json"), + (true, "--origin-harness"), + (true, "--origin-session"), + (false, "--help"), + (false, "-h"), +]; + +/// Per-verb flags beyond the global ones: `(takes_value, name)`. `None` for verbs that are +/// not subcommands (help, version) or unknown. Must mirror what `parse_command` reads. +fn verb_flags(verb: &str) -> Option<&'static [(bool, &'static str)]> { + Some(match verb { + "mcp" | "roles" => &[], + "doctor" => &[(true, "--config"), (true, "--db")], + "plan" => &[ + (true, "--objective"), + (true, "--config"), + (true, "--out"), + (true, "--scope"), + (true, "--db"), + (true, "--intent-file"), + ], + "review-plan" => &[ + (false, "--accept"), + (false, "--decline"), + (false, "--adjust"), + (true, "--plan"), + (true, "--reason"), + (true, "--as"), + (true, "--objective"), + (true, "--intent-file"), + (true, "--config"), + (true, "--db"), + (true, "--out"), + (true, "--scope"), + (false, "--fixture-only"), + ], + "run" => &[ + (true, "--plan"), + (false, "--accept-plan"), + (false, "--reset"), + (true, "--config"), + (true, "--worktree-base"), + (true, "--db"), + (false, "--fixture-only"), + (false, "--detach"), + ], + "status" => &[(true, "--graph"), (true, "--config"), (true, "--db")], + "resume" => &[ + (true, "--graph"), + (false, "--retry"), + (false, "--restart"), + (true, "--config"), + (true, "--db"), + (true, "--worktree-base"), + (false, "--fixture-only"), + ], + "cancel" => &[ + (true, "--session-id"), + (true, "--graph"), + (true, "--task"), + (true, "--config"), + (true, "--db"), + (true, "--worktree-base"), + ], + "inspect" => &[ + (true, "--session-id"), + (true, "--graph"), + (true, "--task"), + (true, "--config"), + (true, "--db"), + (false, "--attempts"), + ], + "watch" => &[ + (true, "--session-id"), + (true, "--graph"), + (true, "--interval"), + (true, "--timeout"), + (true, "--config"), + (true, "--db"), + ], + "accept" => &[ + (true, "--task"), + (true, "--as"), + (true, "--graph"), + (true, "--config"), + (true, "--db"), + (true, "--worktree-base"), + ], + "integrate" => &[ + (true, "--graph"), + (true, "--into"), + (false, "--accept-integrate"), + (true, "--config"), + (true, "--db"), + (true, "--worktree-base"), + ], + "orchestrate" => &[ + (true, "--task"), + (true, "--graph"), + (true, "--config"), + (true, "--db"), + (true, "--worktree-base"), + (true, "--model-a"), + (true, "--model-b"), + (false, "--fixture-only"), + ], + "bundle" => &[(true, "--dest"), (false, "--gitignore")], + "ast-skeleton" => &[(true, "--path")], + "symbol-lookup" => &[(true, "--query"), (true, "--k"), (true, "--scope")], + "mutate-plan" => &[ + (true, "--graph"), + (true, "--mutation-file"), + (true, "--mutation"), + (false, "--require-review"), + (true, "--config"), + (true, "--db"), + (true, "--worktree-base"), + ], + _ => return None, + }) +} + +fn looks_like_flag(token: &str) -> bool { + token.starts_with("--") + || token + .strip_prefix('-') + .and_then(|r| r.chars().next()) + .is_some_and(|c| c.is_alphabetic()) +} + +/// Reject any flag-like token the verb does not accept. The token after a value flag is +/// that flag's value and is never inspected, even when it starts with `-`. +fn reject_unknown_flags(verb: &str, rest: &[String]) -> Result<(), String> { + let Some(own) = verb_flags(verb) else { + return Ok(()); + }; + let mut tokens = rest.iter(); + while let Some(token) = tokens.next() { + if !looks_like_flag(token) { + continue; + } + match GLOBAL_FLAGS + .iter() + .chain(own.iter()) + .find(|(_, name)| name == token) + { + Some((true, _)) => { + tokens.next(); + } + Some((false, _)) => {} + None => return Err(format!("meshloop:{verb} does not accept {token}")), + } + } + Ok(()) +} + fn parse_command(args: &[String]) -> Result { + if let Some(first) = args.first() { + reject_unknown_flags(&verb(first), &args[1..])?; + } match args.first().map(|s| verb(s)) { None => Ok(Command::Status { graph: None, @@ -490,4 +651,85 @@ mod tests { _ => panic!("expected status"), } } + + fn argv(s: &str) -> Vec { + s.split_whitespace().map(ToString::to_string).collect() + } + + /// Every documented flag of every verb, with the required ones present. + const DOCUMENTED: &[&str] = &[ + "doctor --config c --db d", + "plan --objective o --config c --out x --scope s --db d --intent-file i", + "review-plan --accept --plan p --reason r --as me --objective o --intent-file i --config c --db d --out x --scope s --fixture-only", + "review-plan --decline --plan p", + "review-plan --adjust --reason r", + "run --plan p --accept-plan --reset --config c --worktree-base w --db d --fixture-only --detach", + "status --graph g --config c --db d", + "resume --graph g --retry --config c --db d --worktree-base w --fixture-only", + "resume --restart", + "cancel --session-id s --graph g --task 1 --config c --db d --worktree-base w", + "inspect --session-id s --graph g --task 1 --config c --db d --attempts", + "watch --session-id s --graph g --interval 2 --timeout 9 --config c --db d", + "accept --task 1 --as me --graph g --config c --db d --worktree-base w", + "integrate --graph g --into main --accept-integrate --config c --db d --worktree-base w", + "orchestrate --task 1 --graph g --config c --db d --worktree-base w --model-a a --model-b b --fixture-only", + "bundle --dest d --gitignore", + "ast-skeleton --path p", + "symbol-lookup --query q --k 3 --scope s", + "mutate-plan --graph g --mutation-file f --mutation {} --require-review --config c --db d --worktree-base w", + "mcp", + "roles", + ]; + + #[test] + fn documented_flags_still_parse() { + for line in DOCUMENTED { + parse(&argv(line)).unwrap_or_else(|e| panic!("`{line}` rejected: {e}")); + } + } + + #[test] + fn global_flags_accepted_on_every_subcommand() { + for line in DOCUMENTED { + let with_globals = + format!("{line} --json --origin-harness h --origin-session s --help -h"); + parse(&argv(&with_globals)).unwrap_or_else(|e| panic!("`{with_globals}`: {e}")); + } + } + + #[test] + fn unknown_flag_is_rejected_on_every_subcommand() { + for line in DOCUMENTED { + let verb = line.split_whitespace().next().unwrap(); + let err = parse(&argv(&format!("{line} --bogus"))).expect_err(line); + assert_eq!(err, format!("meshloop:{verb} does not accept --bogus")); + } + } + + #[test] + fn status_rejects_bogus_flag() { + let err = parse(&argv("status --bogus")).expect_err("must reject"); + assert!(err.contains("--bogus") && err.contains("status"), "{err}"); + let err = parse(&argv("status -z")).expect_err("must reject short flag"); + assert!(err.contains("-z"), "{err}"); + // A flag valid for another verb is still unknown here. + assert!(parse(&argv("status --attempts")).is_err()); + } + + #[test] + fn value_starting_with_dash_is_not_a_flag() { + let inv = parse(&argv("review-plan --adjust --reason -x")).expect("parse"); + match inv.command { + Command::ReviewPlan { reason, .. } => assert_eq!(reason.as_deref(), Some("-x")), + _ => panic!("expected review-plan"), + } + assert!(parse(&argv("status --graph --not-a-flag")).is_ok()); + // The skipped value does not shield the token after it. + assert!(parse(&argv("status --graph g --bogus")).is_err()); + } + + #[test] + fn prefixed_verb_form_is_validated_too() { + assert!(parse(&argv("meshloop:status --bogus")).is_err()); + } } diff --git a/crates/meshloop-cli/tests/unknown_flags.rs b/crates/meshloop-cli/tests/unknown_flags.rs new file mode 100644 index 0000000..e18d412 --- /dev/null +++ b/crates/meshloop-cli/tests/unknown_flags.rs @@ -0,0 +1,36 @@ +//! An unsupported flag is a non-zero exit, with an `ok:false` envelope under `--json`. + +use std::process::Command; + +use serde_json::Value; + +fn meshloop(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_meshloop")) + .args(args) + .output() + .expect("run meshloop") +} + +#[test] +fn unknown_flag_exits_non_zero_with_message() { + let out = meshloop(&["status", "--bogus"]); + assert_eq!(out.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("meshloop:status does not accept --bogus"), + "{stderr}" + ); +} + +#[test] +fn unknown_flag_with_json_reports_ok_false() { + let out = meshloop(&["status", "--bogus", "--json"]); + assert_eq!(out.status.code(), Some(2)); + let v: Value = serde_json::from_slice(&out.stdout).expect("json envelope"); + assert_eq!(v["ok"], false); + let error = v["error"].as_str().unwrap_or_default(); + assert!( + error.contains("--bogus") && error.contains("status"), + "{error}" + ); +} From bad9d725ad4335ba0b700f7cb0239a366fcf171b Mon Sep 17 00:00:00 2001 From: Samuel <184254+smota@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:33:31 +0200 Subject: [PATCH 8/8] test: drop --fixture-only from plan, which never read it plan has no fixture_only field; the flag was silently ignored and the test already uses a fixture harness config. #59 now rejects it. Co-Authored-By: Claude Opus 5.5 --- crates/meshloop-cli/tests/store_gitignore.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meshloop-cli/tests/store_gitignore.rs b/crates/meshloop-cli/tests/store_gitignore.rs index 8edd28d..fc855de 100644 --- a/crates/meshloop-cli/tests/store_gitignore.rs +++ b/crates/meshloop-cli/tests/store_gitignore.rs @@ -95,7 +95,7 @@ fn plan_in_fresh_repo_never_shows_store_as_untracked() { let dir = temp_repo("plan"); let cfg = write_config(&dir); let out = meshloop(&dir) - .args(["plan", "--fixture-only", "--objective", "x", "--config"]) + .args(["plan", "--objective", "x", "--config"]) .arg(&cfg) .output() .unwrap();