1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use async_trait::async_trait;
6use mana::commands::agents::{agents_file_path, load_agents};
7use mana::commands::logs::find_all_logs;
8use mana::commands::next::ScoredUnit;
9use mana::commands::run::{NativeRunParams, RunSummary, RunTarget, RunUnitStatus, RunView};
10use mana::stream::{self, StreamEvent};
11use mana_core::ops::claim::ClaimParams;
12use mana_core::ops::run as mana_run_core;
13use mana_core::unit::{OnFailAction, UnitType};
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16
17use super::{truncate_head, Tool, ToolContext, ToolOutput};
18use crate::error::Result;
19use crate::mana_worker::{self, WorkerRunOptions, WorkerStatus};
20use crate::ui::{NotifyLevel, WidgetContent};
21const MAX_OUTPUT_LINES: usize = 2000;
22const MAX_OUTPUT_BYTES: usize = 50 * 1024;
23const DEFAULT_LIST_LIMIT: usize = 20;
24const MAX_LIST_LIMIT: usize = 50;
25const MAX_STORED_RUN_EVENTS: usize = 64;
26const MAX_PERSISTED_RUN_LOG_LINES: usize = 50;
27const FINISHED_RUN_TTL_MS: u128 = 60 * 60 * 1000;
28const INTERRUPTED_RUN_STALE_MS: u128 = 6 * 60 * 60 * 1000;
29
30fn find_mana_dir(cwd: &Path) -> std::result::Result<std::path::PathBuf, String> {
31 mana_core::discovery::find_mana_dir(cwd).map_err(|e| e.to_string())
32}
33
34fn resolve_mana_dir(
35 cwd: &Path,
36 params: &serde_json::Value,
37) -> std::result::Result<std::path::PathBuf, String> {
38 let scope = params
41 .get("scope")
42 .and_then(|v| v.as_str())
43 .or_else(|| params.get("mana_scope").and_then(|v| v.as_str()))
44 .unwrap_or("auto");
45
46 if let Some(explicit) = params
47 .get("path")
48 .and_then(|v| v.as_str())
49 .or_else(|| params.get("mana_dir").and_then(|v| v.as_str()))
50 {
51 let path = Path::new(explicit);
52 let resolved = if path.is_absolute() {
53 path.to_path_buf()
54 } else {
55 cwd.join(path)
56 };
57 return Ok(
58 if resolved.file_name().and_then(|name| name.to_str()) == Some(".mana") {
59 resolved
60 } else {
61 resolved.join(".mana")
62 },
63 );
64 }
65
66 match scope {
67 "auto" | "project" => find_mana_dir(cwd),
68 "root" => mana_core::discovery::find_outermost_mana_dir(cwd).map_err(|e| e.to_string()),
69 other => Err(format!(
70 "Unknown mana scope '{other}'. Use auto, project, or root."
71 )),
72 }
73}
74
75fn normalize_runtime_value(mut runtime: serde_json::Value) -> serde_json::Value {
76 if runtime
77 .get("direct_agent")
78 .and_then(serde_json::Value::as_str)
79 == Some("pi")
80 {
81 if let Some(map) = runtime.as_object_mut() {
82 map.insert("direct_agent".to_string(), json!("imp"));
83 }
84 }
85 runtime
86}
87
88fn json_output(value: &impl serde::Serialize) -> ToolOutput {
89 match serde_json::to_string_pretty(value) {
90 Ok(json) => ToolOutput {
91 content: vec![imp_llm::ContentBlock::Text { text: json }],
92 details: serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
93 is_error: false,
94 },
95 Err(e) => ToolOutput::error(format!("Failed to serialize: {e}")),
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100struct NativeRunParamsView {
101 target: serde_json::Value,
102 jobs: u32,
103 dry_run: bool,
104 loop_mode: bool,
105 keep_going: bool,
106 timeout: u32,
107 idle_timeout: u32,
108 review: bool,
109}
110
111impl From<&NativeRunParams> for NativeRunParamsView {
112 fn from(args: &NativeRunParams) -> Self {
113 let target = match &args.target {
114 RunTarget::AllReady => json!({"kind": "all_ready"}),
115 RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
116 RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
117 };
118 Self {
119 target,
120 jobs: args.jobs,
121 dry_run: args.dry_run,
122 loop_mode: args.loop_mode,
123 keep_going: args.keep_going,
124 timeout: args.timeout,
125 idle_timeout: args.idle_timeout,
126 review: args.review,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132struct NativeRunState {
133 run_id: String,
134 scope: String,
135 status: String,
136 error: Option<String>,
137 started_at_ms: u128,
138 finished_at_ms: Option<u128>,
139 #[serde(default)]
140 last_event_at_ms: u128,
141 args: NativeRunParamsView,
142 runtime: Option<serde_json::Value>,
143 summary: RunSummary,
144 units: Vec<RunUnitStatus>,
145 log_lines: Vec<String>,
146 event_count: usize,
147}
148
149impl NativeRunState {
150 fn new(run_id: String, scope: String, args: &NativeRunParams) -> Self {
151 Self {
152 run_id,
153 scope,
154 status: "starting".to_string(),
155 error: None,
156 started_at_ms: unix_time_ms(),
157 finished_at_ms: None,
158 last_event_at_ms: unix_time_ms(),
159 args: NativeRunParamsView::from(args),
160 runtime: None,
161 summary: RunSummary {
162 total_units: 0,
163 total_rounds: 0,
164 total_closed: 0,
165 total_failed: 0,
166 total_abandoned: 0,
167 total_awaiting_verify: 0,
168 total_skipped: 0,
169 duration_secs: 0,
170 },
171 units: Vec::new(),
172 log_lines: Vec::new(),
173 event_count: 0,
174 }
175 }
176
177 fn apply_event(&mut self, event: &StreamEvent) {
178 self.event_count += 1;
179 self.last_event_at_ms = unix_time_ms();
180 if let Some(line) = stream_event_line(event) {
181 self.log_lines.push(line);
182 trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
183 }
184
185 match event {
186 StreamEvent::RunStart {
187 total_units,
188 total_rounds,
189 units,
190 runtime,
191 ..
192 } => {
193 self.status = "running".to_string();
194 self.summary.total_units = *total_units;
195 self.summary.total_rounds = *total_rounds;
196 self.runtime = runtime
197 .as_ref()
198 .and_then(|value| serde_json::to_value(value).ok())
199 .map(normalize_runtime_value);
200 self.units = units
201 .iter()
202 .map(|info| RunUnitStatus {
203 id: info.id.clone(),
204 title: info.title.clone(),
205 status: "queued".to_string(),
206 round: Some(info.round),
207 agent: None,
208 model: None,
209 duration_secs: None,
210 tool_count: None,
211 turns: None,
212 failure_summary: None,
213 error: None,
214 })
215 .collect();
216 self.units.sort_by(|a, b| a.id.cmp(&b.id));
217 }
218 StreamEvent::RunPlan {
219 total_units,
220 runtime,
221 ..
222 } => {
223 self.status = "running".to_string();
224 self.summary.total_units = (*total_units).max(self.summary.total_units);
225 if runtime.is_some() {
226 self.runtime = runtime
227 .as_ref()
228 .and_then(|value| serde_json::to_value(value).ok())
229 .map(normalize_runtime_value);
230 }
231 }
232 StreamEvent::RoundStart { total_rounds, .. } => {
233 self.status = "running".to_string();
234 self.summary.total_rounds = (*total_rounds).max(self.summary.total_rounds);
235 }
236 StreamEvent::UnitReady { id, title, .. } => {
237 let unit = ensure_unit_status(&mut self.units, id, title);
238 unit.status = "queued".to_string();
239 }
240 StreamEvent::UnitStart {
241 id, title, round, ..
242 } => {
243 self.status = "running".to_string();
244 let unit = ensure_unit_status(&mut self.units, id, title);
245 unit.title = title.clone();
246 unit.round = Some(*round);
247 unit.status = "running".to_string();
248 }
249 StreamEvent::UnitDone {
250 id,
251 success,
252 duration_secs,
253 error,
254 tool_count,
255 turns,
256 failure_summary,
257 ..
258 } => {
259 let unit = ensure_unit_status(&mut self.units, id, id);
260 unit.status = if *success { "done" } else { "failed" }.to_string();
261 unit.duration_secs = Some(*duration_secs);
262 unit.tool_count = *tool_count;
263 unit.turns = *turns;
264 unit.failure_summary = failure_summary.clone();
265 unit.error = error.clone();
266 }
267 StreamEvent::BatchVerify { passed, failed, .. } => {
268 for id in passed {
269 let unit = ensure_unit_status(&mut self.units, id, id);
270 unit.status = "done".to_string();
271 }
272 for id in failed {
273 let unit = ensure_unit_status(&mut self.units, id, id);
274 unit.status = "failed".to_string();
275 }
276 }
277 StreamEvent::RunEnd {
278 total_closed,
279 total_failed,
280 total_abandoned,
281 total_awaiting_verify,
282 total_skipped,
283 duration_secs,
284 ..
285 } => {
286 self.summary.total_closed = *total_closed;
287 self.summary.total_failed = *total_failed;
288 self.summary.total_abandoned = *total_abandoned;
289 self.summary.total_awaiting_verify = *total_awaiting_verify;
290 self.summary.total_skipped = *total_skipped;
291 self.summary.duration_secs = *duration_secs;
292 self.status = "finished".to_string();
293 self.finished_at_ms = Some(unix_time_ms());
294 }
295 StreamEvent::DryRun { runtime, .. } => {
296 self.status = "finished".to_string();
297 if runtime.is_some() {
298 self.runtime = runtime
299 .as_ref()
300 .and_then(|value| serde_json::to_value(value).ok());
301 }
302 self.finished_at_ms = Some(unix_time_ms());
303 }
304 StreamEvent::Error { message } => {
305 self.status = "failed".to_string();
306 self.error = Some(message.clone());
307 self.finished_at_ms = Some(unix_time_ms());
308 }
309 _ => {}
310 }
311 }
312
313 fn finish_with_view(&mut self, view: &RunView) {
314 let now = unix_time_ms();
315 self.summary = view.summary.clone();
316 self.units = view.units.clone();
317 self.runtime = view
318 .runtime
319 .as_ref()
320 .and_then(|value| serde_json::to_value(value).ok())
321 .map(normalize_runtime_value);
322 self.status = "finished".to_string();
323 self.error = None;
324 self.finished_at_ms = Some(now);
325 self.last_event_at_ms = now;
326 }
327
328 fn fail(&mut self, error: String) {
329 let now = unix_time_ms();
330 self.status = "failed".to_string();
331 self.error = Some(error.clone());
332 self.finished_at_ms = Some(now);
333 self.last_event_at_ms = now;
334 self.log_lines.push(error);
335 trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
336 }
337
338 fn persisted(&self) -> Self {
339 let mut state = self.clone();
340 trim_log_lines(&mut state.log_lines, MAX_PERSISTED_RUN_LOG_LINES);
341 state
342 }
343}
344
345#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346struct ManaRunStore {
347 next_id: u64,
348 runs: Vec<NativeRunState>,
349}
350
351impl ManaRunStore {
352 fn start_run(&mut self, scope: String, args: &NativeRunParams) -> String {
353 self.next_id += 1;
354 let run_id = format!("run-{}", self.next_id);
355 self.runs
356 .push(NativeRunState::new(run_id.clone(), scope, args));
357 self.trim_history();
358 run_id
359 }
360
361 fn persist(&self) {
362 let path = run_state_file();
363 let persisted = Self {
364 next_id: self.next_id,
365 runs: self.runs.iter().map(NativeRunState::persisted).collect(),
366 };
367 if let Ok(json) = serde_json::to_string_pretty(&persisted) {
368 let _ = std::fs::write(path, json);
369 }
370 }
371
372 fn load_persisted() -> Self {
373 let path = run_state_file();
374 if !path.exists() {
375 return Self::default();
376 }
377
378 let Ok(contents) = std::fs::read_to_string(path) else {
379 return Self::default();
380 };
381 if contents.trim().is_empty() {
382 return Self::default();
383 }
384
385 let Ok(mut store) = serde_json::from_str::<Self>(&contents) else {
386 return Self::default();
387 };
388
389 store.discard_expired_finished_runs();
390 store.classify_stale_unfinished_runs();
391 store.trim_history();
392 store
393 }
394
395 fn discard_expired_finished_runs(&mut self) {
396 let cutoff = unix_time_ms().saturating_sub(FINISHED_RUN_TTL_MS);
397 self.runs.retain(|run| match run.finished_at_ms {
398 Some(finished_at_ms) => finished_at_ms >= cutoff,
399 None => true,
400 });
401 }
402
403 fn classify_stale_unfinished_runs(&mut self) {
404 let cutoff = unix_time_ms().saturating_sub(INTERRUPTED_RUN_STALE_MS);
405 for run in &mut self.runs {
406 if (run.status == "starting" || run.status == "running")
407 && run.finished_at_ms.is_none()
408 && run.last_event_at_ms > 0
409 && run.last_event_at_ms < cutoff
410 {
411 run.status = "interrupted".to_string();
412 run.error = Some(
413 "Run state is stale after process restart or lost background worker; inspect logs before rerun".to_string(),
414 );
415 run.finished_at_ms = Some(run.last_event_at_ms);
416 run.log_lines.push(
417 "Run marked interrupted: stale persisted running state after reload"
418 .to_string(),
419 );
420 trim_log_lines(&mut run.log_lines, MAX_STORED_RUN_EVENTS);
421 }
422 }
423 }
424
425 fn update_with_event(&mut self, run_id: &str, event: &StreamEvent) {
426 if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
427 run.apply_event(event);
428 }
429 }
430
431 fn finish_run(&mut self, run_id: &str, view: &RunView) {
432 if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
433 run.finish_with_view(view);
434 }
435 self.trim_history();
436 }
437
438 fn fail_run(&mut self, run_id: &str, error: String) {
439 if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
440 run.fail(error);
441 }
442 self.trim_history();
443 }
444
445 fn snapshot(&self, run_id: Option<&str>) -> Option<NativeRunState> {
446 if let Some(run_id) = run_id {
447 return self.runs.iter().find(|run| run.run_id == run_id).cloned();
448 }
449
450 self.runs
451 .iter()
452 .rev()
453 .find(|run| run.status == "starting" || run.status == "running")
454 .cloned()
455 .or_else(|| self.runs.last().cloned())
456 }
457
458 fn trim_history(&mut self) {
459 while self.runs.len() > 8 {
460 let newest_index = self.runs.len().saturating_sub(1);
461 if let Some(index) =
462 self.runs
463 .iter()
464 .enumerate()
465 .take(newest_index)
466 .find_map(|(index, run)| {
467 (run.status != "starting" && run.status != "running").then_some(index)
468 })
469 {
470 self.runs.remove(index);
471 } else if !self.runs.is_empty() {
472 self.runs.remove(0);
473 } else {
474 break;
475 }
476 }
477 }
478}
479
480fn trim_log_lines(log_lines: &mut Vec<String>, max_lines: usize) {
481 if log_lines.len() > max_lines {
482 let overflow = log_lines.len() - max_lines;
483 log_lines.drain(0..overflow);
484 }
485}
486
487fn run_state_file() -> std::path::PathBuf {
488 if let Ok(path) = agents_file_path() {
489 if let Some(dir) = path.parent() {
490 std::fs::create_dir_all(dir).ok();
491 return dir.join("run_state.json");
492 }
493 }
494
495 let dir = std::env::var("HOME")
496 .map(|h| {
497 std::path::PathBuf::from(h)
498 .join(".local")
499 .join("share")
500 .join("units")
501 })
502 .unwrap_or_else(|_| std::path::PathBuf::from("/tmp").join("mana"));
503 std::fs::create_dir_all(&dir).ok();
504 dir.join("run_state.json")
505}
506
507fn unix_time_ms() -> u128 {
508 std::time::SystemTime::now()
509 .duration_since(std::time::UNIX_EPOCH)
510 .unwrap_or_default()
511 .as_millis()
512}
513
514fn ensure_unit_status<'a>(
515 units: &'a mut Vec<RunUnitStatus>,
516 id: &str,
517 title: &str,
518) -> &'a mut RunUnitStatus {
519 if let Some(index) = units.iter().position(|unit| unit.id == id) {
520 return &mut units[index];
521 }
522
523 units.push(RunUnitStatus {
524 id: id.to_string(),
525 title: title.to_string(),
526 status: "queued".to_string(),
527 round: None,
528 agent: None,
529 model: None,
530 duration_secs: None,
531 tool_count: None,
532 turns: None,
533 failure_summary: None,
534 error: None,
535 });
536 let index = units.len() - 1;
537 &mut units[index]
538}
539
540fn stream_event_line(event: &StreamEvent) -> Option<String> {
541 match event {
542 StreamEvent::RunStart {
543 total_units,
544 total_rounds,
545 ..
546 } => Some(format!(
547 "Mana run started: {total_units} jobs across {total_rounds} waves"
548 )),
549 StreamEvent::RunPlan {
550 waves,
551 file_overlaps,
552 ..
553 } => Some(format!(
554 "Plan ready: {} waves · {} overlapping file groups",
555 waves.len(),
556 file_overlaps.len()
557 )),
558 StreamEvent::RoundStart {
559 round,
560 total_rounds,
561 unit_count,
562 } => Some(format!(
563 "Round {round}/{total_rounds}: {unit_count} unit(s)"
564 )),
565 StreamEvent::UnitReady {
566 id,
567 title,
568 unblocked_by,
569 } => Some(format!("Ready: {id} {title} (unblocked by {unblocked_by})")),
570 StreamEvent::UnitStart {
571 id, title, round, ..
572 } => Some(format!("▶ {id} {title} wave {round}")),
573 StreamEvent::UnitThinking { id, text } => {
574 Some(format!("… {id} {}", truncate_line_for_log(text)))
575 }
576 StreamEvent::UnitTool {
577 id,
578 tool_name,
579 tool_count,
580 file_path,
581 } => Some(match file_path {
582 Some(path) => format!("⚙ {id} #{tool_count} {tool_name} {path}"),
583 None => format!("⚙ {id} #{tool_count} {tool_name}"),
584 }),
585 StreamEvent::UnitTokens {
586 id,
587 input_tokens,
588 output_tokens,
589 cost,
590 ..
591 } => Some(format!(
592 "$ {id} in {input_tokens} · out {output_tokens} · ${cost:.4}"
593 )),
594 StreamEvent::UnitDone {
595 id,
596 success,
597 duration_secs,
598 error,
599 ..
600 } => Some(if *success {
601 format!("✓ {id} done {duration_secs}s")
602 } else {
603 format!(
604 "✗ {id} failed {}",
605 error.clone().unwrap_or_else(|| "error".to_string())
606 )
607 }),
608 StreamEvent::RoundEnd {
609 round,
610 success_count,
611 failed_count,
612 } => Some(format!(
613 "Round {round} complete: {success_count} done · {failed_count} failed"
614 )),
615 StreamEvent::RunEnd {
616 total_closed,
617 total_failed,
618 duration_secs,
619 ..
620 } => Some(format!(
621 "Mana run finished: {total_closed} done · {total_failed} failed · {duration_secs}s"
622 )),
623 StreamEvent::BatchVerify {
624 commands_run,
625 passed,
626 failed,
627 } => Some(format!(
628 "Batch verify: {commands_run} command(s) · {} passed · {} failed",
629 passed.len(),
630 failed.len()
631 )),
632 StreamEvent::VerifyGroupRun {
633 command,
634 unit_ids,
635 success,
636 } => Some(format!(
637 "Verify command: {} · {} unit(s) · {}",
638 truncate_line_for_log(command),
639 unit_ids.len(),
640 if *success { "passed" } else { "failed" }
641 )),
642 StreamEvent::DryRun { rounds, .. } => {
643 Some(format!("Dry run: {} planned wave(s)", rounds.len()))
644 }
645 StreamEvent::Error { message } => Some(format!("Run error: {message}")),
646 }
647}
648
649fn truncate_line_for_log(text: &str) -> String {
650 const MAX_CHARS: usize = 160;
651 let mut out = String::new();
652 let mut chars = text.chars();
653 for _ in 0..MAX_CHARS {
654 if let Some(ch) = chars.next() {
655 out.push(ch);
656 } else {
657 return out;
658 }
659 }
660 if chars.next().is_some() {
661 out.push('…');
662 }
663 out
664}
665
666fn update_run_store_with_event(
667 store: &std::sync::Mutex<ManaRunStore>,
668 run_id: &str,
669 event: &StreamEvent,
670) {
671 if let Ok(mut store) = store.lock() {
672 store.update_with_event(run_id, event);
673 store.persist();
674 }
675}
676
677fn finish_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, view: &RunView) {
678 if let Ok(mut store) = store.lock() {
679 store.finish_run(run_id, view);
680 store.persist();
681 }
682}
683
684fn fail_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, error: String) {
685 if let Ok(mut store) = store.lock() {
686 store.fail_run(run_id, error);
687 store.persist();
688 }
689}
690
691fn mana_widget_lines(summary: impl Into<String>, detail: Option<String>) -> WidgetContent {
692 let mut lines = vec![summary.into()];
693 if let Some(detail) = detail {
694 lines.push(detail);
695 }
696 WidgetContent::Lines(lines)
697}
698
699fn mana_run_widget_lines(
700 run_id: &str,
701 scope: &str,
702 state: Option<&NativeRunState>,
703) -> WidgetContent {
704 let Some(state) = state else {
705 return mana_widget_lines(
706 format!("mana {run_id}: starting"),
707 Some(format!("{scope} · waiting for first event")),
708 );
709 };
710
711 let summary = format!(
712 "mana {}: {} · {}/{} done · {} failed",
713 state.run_id,
714 state.status,
715 state.summary.total_closed,
716 state.summary.total_units,
717 state.summary.total_failed
718 );
719 let mut detail = vec![format!(
720 "{} · {} events · {}s elapsed",
721 state.scope,
722 state.event_count,
723 unix_time_ms().saturating_sub(state.started_at_ms) / 1000
724 )];
725 if let Some(active) = state.units.iter().find(|unit| unit.status == "running") {
726 detail.push(format!("running {} {}", active.id, active.title));
727 } else if let Some(queued) = state.units.iter().find(|unit| unit.status == "queued") {
728 detail.push(format!("queued {} {}", queued.id, queued.title));
729 }
730 if let Some(last) = state.log_lines.last() {
731 detail.push(last.clone());
732 }
733 mana_widget_lines(summary, Some(detail.join(" · ")))
734}
735
736async fn set_mana_delta_widget(
737 ctx: &ToolContext,
738 summary: impl Into<String>,
739 detail: Option<String>,
740) {
741 ctx.ui
742 .set_widget("mana", Some(mana_widget_lines(summary, detail)))
743 .await;
744}
745
746fn unit_delta_label(unit: &serde_json::Value) -> Option<String> {
747 let id = unit.get("id").and_then(|v| v.as_str())?;
748 let title = unit
749 .get("title")
750 .and_then(|v| v.as_str())
751 .unwrap_or("(untitled)");
752 Some(format!("{id} · {title}"))
753}
754
755fn target_from_params(params: &serde_json::Value) -> Result<RunTarget> {
756 if let Some(values) = params["targets"].as_array() {
757 let ids: Vec<String> = values
758 .iter()
759 .filter_map(|value| value.as_str().map(|s| s.to_string()))
760 .collect();
761 if ids.is_empty() {
762 return Err(crate::error::Error::Tool(
763 "mana run targets must contain at least one string id".into(),
764 ));
765 }
766 return Ok(RunTarget::Explicit(ids));
767 }
768
769 if let Some(id) = params["id"].as_str() {
770 return Ok(RunTarget::Unit(id.to_string()));
771 }
772
773 Ok(RunTarget::AllReady)
774}
775
776fn target_ids_from_run_target(target: &RunTarget) -> Vec<String> {
777 match target {
778 RunTarget::Unit(id) => vec![id.clone()],
779 RunTarget::Explicit(ids) => ids.clone(),
780 RunTarget::AllReady => Vec::new(),
781 }
782}
783
784fn scope_from_target(target: &RunTarget) -> String {
785 match target {
786 RunTarget::AllReady => "all ready units".to_string(),
787 RunTarget::Unit(id) => format!("unit {id}"),
788 RunTarget::Explicit(ids) => format!("targets {}", ids.join(", ")),
789 }
790}
791
792fn make_follow_up_summary(scope: &str, view: &RunView) -> String {
793 let mut summary = if view.summary.total_failed > 0 {
794 format!(
795 "Native mana orchestration finished for {scope}: {} done, {} failed, {} candidate complete / awaiting verify.",
796 view.summary.total_closed,
797 view.summary.total_failed,
798 view.summary.total_awaiting_verify
799 )
800 } else if view.summary.total_awaiting_verify > 0 {
801 format!(
802 "Native mana orchestration finished for {scope}: {} done, {} candidate complete / awaiting verify.",
803 view.summary.total_closed, view.summary.total_awaiting_verify
804 )
805 } else {
806 format!(
807 "Native mana orchestration finished for {scope}: {} done, 0 failed.",
808 view.summary.total_closed
809 )
810 };
811
812 if let Some(runtime) = &view.runtime {
813 let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
814 let model = runtime.model.as_deref().unwrap_or("default-model");
815 summary.push_str(&format!(
816 " Orchestration ran through mana; worker runtime: {agent} · {model}."
817 ));
818 }
819
820 summary.push_str(" Inspect with mana(action=\"run_state\") or mana(action=\"evaluate\").");
821 summary
822}
823
824fn parse_csv_strings(value: &serde_json::Value, field_name: &str) -> Result<Vec<String>> {
825 if let Some(values) = value.as_array() {
826 let parsed = values
827 .iter()
828 .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
829 .filter(|s| !s.is_empty())
830 .collect();
831 return Ok(parsed);
832 }
833
834 if let Some(raw) = value.as_str() {
835 return Ok(raw
836 .split(',')
837 .map(|s| s.trim().to_string())
838 .filter(|s| !s.is_empty())
839 .collect());
840 }
841
842 if value.is_null() {
843 return Ok(Vec::new());
844 }
845
846 Err(crate::error::Error::Tool(format!(
847 "{field_name} must be a comma-separated string or array of strings"
848 )))
849}
850
851fn parse_optional_string(value: &serde_json::Value) -> Option<String> {
852 value
853 .as_str()
854 .map(str::trim)
855 .filter(|s| !s.is_empty())
856 .map(|s| s.to_string())
857}
858
859fn parse_on_fail(value: &serde_json::Value) -> Result<Option<OnFailAction>> {
860 if value.is_null() {
861 return Ok(None);
862 }
863
864 if let Some(raw) = value.as_str() {
865 return mana_core::ops::create::parse_on_fail(raw)
866 .map(Some)
867 .map_err(|e| crate::error::Error::Tool(e.to_string()));
868 }
869
870 let Some(obj) = value.as_object() else {
871 return Err(crate::error::Error::Tool(
872 "on_fail must be a string like 'retry:3'/'escalate:P1' or an object".into(),
873 ));
874 };
875
876 let action = obj
877 .get("action")
878 .and_then(|v| v.as_str())
879 .ok_or_else(|| crate::error::Error::Tool("on_fail object requires 'action'".into()))?;
880
881 match action {
882 "retry" => Ok(Some(OnFailAction::Retry {
883 max: obj.get("max").and_then(|v| v.as_u64()).map(|v| v as u32),
884 delay_secs: obj.get("delay_secs").and_then(|v| v.as_u64()),
885 })),
886 "escalate" => Ok(Some(OnFailAction::Escalate {
887 priority: obj
888 .get("priority")
889 .and_then(|v| v.as_u64())
890 .map(|v| v as u8),
891 message: obj
892 .get("message")
893 .and_then(|v| v.as_str())
894 .map(|s| s.trim().to_string())
895 .filter(|s| !s.is_empty()),
896 })),
897 other => Err(crate::error::Error::Tool(format!(
898 "unsupported on_fail action: {other}"
899 ))),
900 }
901}
902
903fn parent_placement_details(
904 parent: Option<&str>,
905 parent_reason: Option<&str>,
906) -> serde_json::Value {
907 match (parent, parent_reason) {
908 (Some(parent), Some(reason)) if !reason.trim().is_empty() => json!({
909 "parent": parent,
910 "parent_reason": reason,
911 "warning": null,
912 "hint": "Parent placement was explained explicitly.",
913 }),
914 (Some(parent), _) => json!({
915 "parent": parent,
916 "parent_reason": null,
917 "warning": "parent_reason_missing",
918 "hint": "Before creating follow-up work under the active epic, confirm it belongs to that product/scope. If this is a workflow, reliability, or cross-cutting issue, attach it to a matching epic or create one instead.",
919 }),
920 (None, _) => json!({
921 "parent": null,
922 "parent_reason": null,
923 "warning": null,
924 "hint": "No parent selected. For durable multi-step work, choose or create the matching epic before adding child tasks.",
925 }),
926 }
927}
928
929fn mana_close_force_reason_error(id: &str) -> ToolOutput {
930 ToolOutput {
931 content: vec![imp_llm::ContentBlock::Text {
932 text: format!(
933 "mana close {id} with force=true requires reason with equivalent verify evidence"
934 ),
935 }],
936 details: json!({
937 "action": "close",
938 "id": id,
939 "ok": false,
940 "force": true,
941 "missing": ["reason"],
942 "hint": "When stored verify is stale or invalid, rerun equivalent checks and close with force=true plus a reason that names the passing commands/evidence.",
943 "example": {
944 "action": "close",
945 "id": id,
946 "force": true,
947 "reason": "Equivalent verify passed: cargo test -p imp-core mana -- --nocapture; commit abc123"
948 }
949 }),
950 is_error: true,
951 }
952}
953
954fn mana_close_error_output(id: &str, error: String) -> ToolOutput {
955 let verify_related = is_close_verify_error(&error);
956 let hint = if verify_related {
957 Some(
958 "If the stored verify command is stale or invalid, run equivalent focused checks, then close with force=true and reason containing the passing commands/evidence.",
959 )
960 } else {
961 None
962 };
963 let text = match hint {
964 Some(hint) => format!("{error}\n\nRecovery: {hint}"),
965 None => error.clone(),
966 };
967 ToolOutput {
968 content: vec![imp_llm::ContentBlock::Text { text }],
969 details: json!({
970 "action": "close",
971 "id": id,
972 "ok": false,
973 "error": error,
974 "verify_related": verify_related,
975 "recovery_hint": hint,
976 "force_requires_reason": true,
977 }),
978 is_error: true,
979 }
980}
981
982fn is_close_verify_error(error: &str) -> bool {
983 let lower = error.to_ascii_lowercase();
984 lower.contains("verify")
985 || lower.contains("verification")
986 || lower.contains("exit")
987 || lower.contains("command")
988 || lower.contains("timed out")
989}
990
991fn mana_validation_error(
992 action: &str,
993 missing: Vec<&'static str>,
994 invalid: Vec<&'static str>,
995 hint: &'static str,
996 canonical_fields: Vec<&'static str>,
997) -> ToolOutput {
998 ToolOutput {
999 content: vec![imp_llm::ContentBlock::Text {
1000 text: format!("mana {action} validation failed: {hint}"),
1001 }],
1002 details: json!({
1003 "action": action,
1004 "ok": false,
1005 "missing": missing,
1006 "invalid": invalid,
1007 "hint": hint,
1008 "canonical_fields": canonical_fields,
1009 }),
1010 is_error: true,
1011 }
1012}
1013
1014fn has_text(params: &serde_json::Value, field: &str) -> bool {
1015 parse_optional_string(¶ms[field]).is_some()
1016}
1017
1018fn has_nonempty_csv(params: &serde_json::Value, field: &str) -> bool {
1019 parse_csv_strings(¶ms[field], field)
1020 .map(|values| !values.is_empty())
1021 .unwrap_or(false)
1022}
1023
1024fn validate_mana_action(action: &str, params: &serde_json::Value) -> Option<ToolOutput> {
1025 let missing = |fields: Vec<&'static str>, hint: &'static str, canonical: Vec<&'static str>| {
1026 Some(mana_validation_error(
1027 action,
1028 fields,
1029 Vec::new(),
1030 hint,
1031 canonical,
1032 ))
1033 };
1034
1035 if params.get("path").is_some() && params.get("paths").is_none() {
1036 match action {
1037 "create" | "update" | "fact_create" => {
1038 return Some(mana_validation_error(
1039 action,
1040 Vec::new(),
1041 vec!["path"],
1042 "Use path for project/.mana location; use paths to attach relevant files to units.",
1043 vec!["path", "paths"],
1044 ));
1045 }
1046 _ => {}
1047 }
1048 }
1049
1050 match action {
1051 "show" | "claim" | "release" | "close" | "reopen" | "verify" | "fail" | "delete" => {
1052 if !has_text(params, "id") {
1053 return missing(vec!["id"], "Provide the unit id.", vec!["id"]);
1054 }
1055 }
1056 "create" => {
1057 if !has_text(params, "title") {
1058 return missing(
1059 vec!["title"],
1060 "create requires title. Before creating, check list/show for an existing relevant unit; update or notes_append when the durable state belongs there. For executable tasks, include description, acceptance, and verify.",
1061 vec!["title", "description", "acceptance", "verify", "paths"],
1062 );
1063 }
1064 }
1065 "update" => {
1066 if !has_text(params, "id") {
1067 return missing(
1068 vec!["id"],
1069 "update requires the unit id to modify.",
1070 vec!["id"],
1071 );
1072 }
1073 }
1074 "notes_append" => {
1075 let mut fields = Vec::new();
1076 if !has_text(params, "id") {
1077 fields.push("id");
1078 }
1079 if !has_text(params, "notes") {
1080 fields.push("notes");
1081 }
1082 if !fields.is_empty() {
1083 return missing(
1084 fields,
1085 "notes_append requires id and notes; use notes for durable progress/context.",
1086 vec!["id", "notes"],
1087 );
1088 }
1089 }
1090 "decision_add" => {
1091 let mut fields = Vec::new();
1092 if !has_text(params, "id") {
1093 fields.push("id");
1094 }
1095 if !has_text(params, "description") && !has_nonempty_csv(params, "decisions") {
1096 fields.push("description");
1097 }
1098 if !fields.is_empty() {
1099 return missing(
1100 fields,
1101 "decision_add requires id and description/decisions for scope, architecture, or sequencing choices.",
1102 vec!["id", "description", "decisions"],
1103 );
1104 }
1105 }
1106 "decision_resolve" => {
1107 let mut fields = Vec::new();
1108 if !has_text(params, "id") {
1109 fields.push("id");
1110 }
1111 if !has_nonempty_csv(params, "resolve_decisions") {
1112 fields.push("resolve_decisions");
1113 }
1114 if !fields.is_empty() {
1115 return missing(
1116 fields,
1117 "decision_resolve requires id and resolve_decisions.",
1118 vec!["id", "resolve_decisions"],
1119 );
1120 }
1121 }
1122 "dep_add" | "dep_remove" => {
1123 let mut fields = Vec::new();
1124 if !has_text(params, "from_id") {
1125 fields.push("from_id");
1126 }
1127 if !has_text(params, "dep_id") {
1128 fields.push("dep_id");
1129 }
1130 if !fields.is_empty() {
1131 return missing(
1132 fields,
1133 "Dependency edits require from_id and dep_id.",
1134 vec!["from_id", "dep_id"],
1135 );
1136 }
1137 }
1138 "fact_create" => {
1139 let mut fields = Vec::new();
1140 if !has_text(params, "title") {
1141 fields.push("title");
1142 }
1143 if !has_text(params, "verify") {
1144 fields.push("verify");
1145 }
1146 if !fields.is_empty() {
1147 return missing(
1148 fields,
1149 "fact_create requires title and verify so the fact is re-checkable.",
1150 vec!["title", "verify", "paths"],
1151 );
1152 }
1153 }
1154 "logs" => {
1155 if !has_text(params, "id") && !has_text(params, "run_id") {
1156 return missing(
1157 vec!["id"],
1158 "logs requires id or run_id.",
1159 vec!["id", "run_id"],
1160 );
1161 }
1162 }
1163 "reparent" => {
1164 if !has_text(params, "id") {
1165 return missing(
1166 vec!["id"],
1167 "reparent requires id and parent.",
1168 vec!["id", "parent", "reason"],
1169 );
1170 }
1171 if params.get("parent").is_none() {
1172 return missing(
1173 vec!["parent"],
1174 "reparent requires the new parent id. Root detach can be added as a separate explicit action later.",
1175 vec!["id", "parent", "reason"],
1176 );
1177 }
1178 }
1179 "run_state" | "evaluate" => {
1180 if !has_text(params, "run_id") {
1181 return missing(
1182 vec!["run_id"],
1183 "run_state/evaluate requires run_id from mana action=run.",
1184 vec!["run_id"],
1185 );
1186 }
1187 }
1188 "run" => {
1189 if params.get("target").is_some() && params.get("targets").is_none() {
1190 return Some(mana_validation_error(
1191 action,
1192 Vec::new(),
1193 vec!["target"],
1194 "Use targets for explicit unit ids; target is an internal run concept.",
1195 vec!["targets", "id"],
1196 ));
1197 }
1198 }
1199 _ => {}
1200 }
1201 None
1202}
1203
1204fn parse_unit_kind(value: &serde_json::Value) -> Result<Option<UnitType>> {
1205 let Some(raw) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) else {
1206 return Ok(None);
1207 };
1208
1209 match raw {
1210 "epic" => Ok(Some(UnitType::Epic)),
1211 "task" => Ok(Some(UnitType::Task)),
1212 "job" => Ok(Some(UnitType::Task)),
1215 "fact" => Ok(Some(UnitType::Fact)),
1216 other => Err(crate::error::Error::Tool(format!(
1217 "kind must be one of: epic, task, fact (legacy runtime alias: job; got {other})"
1218 ))),
1219 }
1220}
1221
1222fn run_started_output(scope: &str, run_id: &str, run_args: &NativeRunParams) -> ToolOutput {
1223 let text = format!("Started native mana orchestration for {scope} as {run_id}.");
1224 ToolOutput {
1225 content: vec![imp_llm::ContentBlock::Text { text }],
1226 details: json!({
1227 "action": "run",
1228 "run_id": run_id,
1229 "scope": scope,
1230 "target": match &run_args.target {
1231 RunTarget::AllReady => json!({"kind": "all_ready"}),
1232 RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
1233 RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
1234 },
1235 "jobs": run_args.jobs,
1236 "loop": run_args.loop_mode,
1237 "dry_run": run_args.dry_run,
1238 "review": run_args.review,
1239 "timeout": run_args.timeout,
1240 "idle_timeout": run_args.idle_timeout,
1241 "status": "started",
1242 "next": {
1243 "state": format!("mana(action=\\\"run_state\\\", run_id=\\\"{run_id}\\\")"),
1244 "logs": format!("mana(action=\\\"logs\\\", run_id=\\\"{run_id}\\\")")
1245 }
1246 }),
1247 is_error: false,
1248 }
1249}
1250
1251fn runtime_info_for_run(
1252 _args: &NativeRunParams,
1253 ctx: &ToolContext,
1254) -> mana::commands::run::RunRuntimeInfo {
1255 mana::commands::run::RunRuntimeInfo {
1256 direct_agent: Some("imp".to_string()),
1257 model: ctx.config.model.clone(),
1258 }
1259}
1260
1261fn stream_runtime_info_for_run(
1262 args: &NativeRunParams,
1263 ctx: &ToolContext,
1264) -> stream::RunRuntimeInfo {
1265 runtime_info_for_run(args, ctx).into()
1266}
1267
1268fn core_target_from_native(target: &RunTarget) -> mana_core::api::RunTarget {
1269 match target {
1270 RunTarget::AllReady => mana_core::api::RunTarget::AllReady,
1271 RunTarget::Unit(id) => mana_core::api::RunTarget::Unit(id.clone()),
1272 RunTarget::Explicit(ids) => mana_core::api::RunTarget::Explicit(ids.clone()),
1273 }
1274}
1275
1276fn validate_native_run_target(target: &RunTarget) -> std::result::Result<(), String> {
1277 match target {
1278 RunTarget::Explicit(ids) if ids.len() > 1 => Err(
1279 "native mana run does not yet support multiple explicit targets; run one target at a time"
1280 .to_string(),
1281 ),
1282 _ => Ok(()),
1283 }
1284}
1285
1286fn mana_workspace_root(mana_dir: &Path, fallback: &Path) -> PathBuf {
1287 mana_dir
1288 .parent()
1289 .map(Path::to_path_buf)
1290 .unwrap_or_else(|| fallback.to_path_buf())
1291}
1292
1293#[derive(Debug)]
1294struct DirectUnitOutcome {
1295 event: StreamEvent,
1296 status: RunUnitStatus,
1297}
1298
1299fn worker_options_for_native_unit(
1300 unit: &mana_run_core::ReadyUnit,
1301 run_args: &NativeRunParams,
1302 workspace_root: PathBuf,
1303 mana_dir: PathBuf,
1304 ctx: &ToolContext,
1305) -> WorkerRunOptions {
1306 WorkerRunOptions {
1307 cwd: workspace_root,
1308 model_override: None,
1309 model: unit.model.clone().or_else(|| ctx.config.model.clone()),
1310 provider: None,
1311 api_key: None,
1312 thinking: ctx.config.thinking,
1313 max_turns: Some(run_args.timeout),
1314 autonomy_mode: None,
1315 verification_gates: Vec::new(),
1316 max_tokens: None,
1317 system_prompt: None,
1318 no_tools: false,
1319 mana_dir_override: Some(mana_dir),
1320 defer_verify: false,
1321 lua_loader: ctx.lua_tool_loader.clone(),
1322 }
1323}
1324
1325async fn run_direct_unit(
1326 unit: mana_run_core::ReadyUnit,
1327 round: usize,
1328 mana_dir: PathBuf,
1329 workspace_root: PathBuf,
1330 run_args: NativeRunParams,
1331 ctx: ToolContext,
1332) -> DirectUnitOutcome {
1333 let started = Instant::now();
1334 let assignment = match mana_worker::load_assignment_with_mana_dir(
1335 &workspace_root,
1336 &unit.id,
1337 Some(&mana_dir),
1338 ) {
1339 Ok(assignment) => assignment,
1340 Err(error) => {
1341 let error = error.to_string();
1342 return DirectUnitOutcome {
1343 event: StreamEvent::UnitDone {
1344 id: unit.id.clone(),
1345 success: false,
1346 duration_secs: started.elapsed().as_secs(),
1347 error: Some(error.clone()),
1348 total_tokens: None,
1349 total_cost: None,
1350 tool_count: None,
1351 turns: None,
1352 failure_summary: Some(error.clone()),
1353 },
1354 status: RunUnitStatus {
1355 id: unit.id,
1356 title: unit.title,
1357 status: "failed".to_string(),
1358 round: Some(round),
1359 agent: Some("imp".to_string()),
1360 model: None,
1361 duration_secs: Some(started.elapsed().as_secs()),
1362 tool_count: None,
1363 turns: None,
1364 failure_summary: Some(error.clone()),
1365 error: Some(error),
1366 },
1367 };
1368 }
1369 };
1370 let options = worker_options_for_native_unit(&unit, &run_args, workspace_root, mana_dir, &ctx);
1371 let outcome = mana_worker::run_worker_assignment(assignment, options).await;
1372 let duration_secs = started.elapsed().as_secs();
1373 let (success, error, tool_count, turns, failure_summary, model) = match outcome {
1374 Ok(outcome) => {
1375 let success = matches!(
1376 outcome.result.status,
1377 WorkerStatus::Completed | WorkerStatus::AwaitingVerify
1378 );
1379 (
1380 success,
1381 outcome.result.error.clone(),
1382 Some(outcome.result.tool_count),
1383 Some(outcome.result.turns),
1384 outcome.result.summary.clone(),
1385 outcome.result.model.clone(),
1386 )
1387 }
1388 Err(error) => {
1389 let error = error.to_string();
1390 (false, Some(error.clone()), None, None, Some(error), None)
1391 }
1392 };
1393 let status = RunUnitStatus {
1394 id: unit.id.clone(),
1395 title: unit.title,
1396 status: if success { "done" } else { "failed" }.to_string(),
1397 round: Some(round),
1398 agent: Some("imp".to_string()),
1399 model,
1400 duration_secs: Some(duration_secs),
1401 tool_count,
1402 turns,
1403 failure_summary: failure_summary.clone(),
1404 error: error.clone(),
1405 };
1406 DirectUnitOutcome {
1407 event: StreamEvent::UnitDone {
1408 id: unit.id,
1409 success,
1410 duration_secs,
1411 error,
1412 total_tokens: None,
1413 total_cost: None,
1414 tool_count,
1415 turns,
1416 failure_summary,
1417 },
1418 status,
1419 }
1420}
1421
1422async fn run_native_imp_orchestration(
1423 mana_dir: PathBuf,
1424 run_args: NativeRunParams,
1425 ctx: ToolContext,
1426 run_store: Arc<std::sync::Mutex<ManaRunStore>>,
1427 run_id: String,
1428) -> std::result::Result<RunView, String> {
1429 let started = Instant::now();
1430 let runtime = runtime_info_for_run(&run_args, &ctx);
1431 let stream_runtime = stream_runtime_info_for_run(&run_args, &ctx);
1432 validate_native_run_target(&run_args.target)?;
1433 let core_target = core_target_from_native(&run_args.target);
1434 let plan = mana_run_core::compute_run_plan(&mana_dir, &core_target, run_args.dry_run)
1435 .map_err(|error| error.to_string())?;
1436 let total_rounds = plan.waves.len();
1437 let all_units: Vec<_> = plan
1438 .waves
1439 .iter()
1440 .enumerate()
1441 .flat_map(|(wave_index, wave)| {
1442 wave.units.iter().map(move |unit| stream::UnitInfo {
1443 id: unit.id.clone(),
1444 title: unit.title.clone(),
1445 round: wave_index + 1,
1446 })
1447 })
1448 .collect();
1449 let run_start = StreamEvent::RunStart {
1450 parent_id: scope_from_target(&run_args.target),
1451 total_units: all_units.len(),
1452 total_rounds,
1453 units: all_units,
1454 runtime: Some(stream_runtime.clone()),
1455 };
1456 update_run_store_with_event(&run_store, &run_id, &run_start);
1457
1458 if run_args.dry_run {
1459 let summary = RunSummary {
1460 total_units: plan.total_units,
1461 total_rounds,
1462 duration_secs: started.elapsed().as_secs(),
1463 ..RunSummary::default()
1464 };
1465 let view = RunView {
1466 summary,
1467 units: run_state_snapshot(&run_store, Some(&run_id))
1468 .map(|state| state.units)
1469 .unwrap_or_default(),
1470 events: vec![run_start],
1471 runtime: Some(runtime),
1472 };
1473 finish_run_in_store(&run_store, &run_id, &view);
1474 return Ok(view);
1475 }
1476
1477 let workspace_root = mana_workspace_root(&mana_dir, &ctx.cwd);
1478 let mut events = vec![run_start];
1479 let mut total_closed = 0usize;
1480 let mut total_failed = 0usize;
1481 let mut final_units = Vec::new();
1482 let max_jobs = run_args.jobs.max(1) as usize;
1483
1484 for (wave_index, wave) in plan.waves.into_iter().enumerate() {
1485 let round = wave_index + 1;
1486 let round_start = StreamEvent::RoundStart {
1487 round,
1488 total_rounds,
1489 unit_count: wave.units.len(),
1490 };
1491 update_run_store_with_event(&run_store, &run_id, &round_start);
1492 events.push(round_start);
1493
1494 let mut success_count = 0usize;
1495 let mut failed_count = 0usize;
1496 let mut pending = wave.units.into_iter();
1497 loop {
1498 if ctx.cancelled.load(std::sync::atomic::Ordering::SeqCst) {
1499 return Err("native mana run cancelled".to_string());
1500 }
1501 let batch: Vec<_> = pending.by_ref().take(max_jobs).collect();
1502 if batch.is_empty() {
1503 break;
1504 }
1505 let mut handles = Vec::with_capacity(batch.len());
1506 for unit in batch {
1507 let unit_start = StreamEvent::UnitStart {
1508 id: unit.id.clone(),
1509 title: unit.title.clone(),
1510 round,
1511 file_overlaps: None,
1512 attempt: None,
1513 priority: Some(unit.priority),
1514 };
1515 update_run_store_with_event(&run_store, &run_id, &unit_start);
1516 events.push(unit_start);
1517 handles.push(tokio::spawn(run_direct_unit(
1518 unit,
1519 round,
1520 mana_dir.clone(),
1521 workspace_root.clone(),
1522 run_args.clone(),
1523 ctx.clone(),
1524 )));
1525 }
1526 for handle in handles {
1527 let outcome = handle
1528 .await
1529 .map_err(|error| format!("native mana worker task failed: {error}"))?;
1530 let success = matches!(outcome.status.status.as_str(), "done");
1531 if success {
1532 success_count += 1;
1533 total_closed += 1;
1534 } else {
1535 failed_count += 1;
1536 total_failed += 1;
1537 }
1538 update_run_store_with_event(&run_store, &run_id, &outcome.event);
1539 events.push(outcome.event);
1540 final_units.push(outcome.status);
1541 }
1542 if failed_count > 0 && !run_args.keep_going {
1543 break;
1544 }
1545 }
1546 let round_end = StreamEvent::RoundEnd {
1547 round,
1548 success_count,
1549 failed_count,
1550 };
1551 update_run_store_with_event(&run_store, &run_id, &round_end);
1552 events.push(round_end);
1553 if failed_count > 0 && !run_args.keep_going {
1554 break;
1555 }
1556 }
1557
1558 let duration_secs = started.elapsed().as_secs();
1559 let run_end = StreamEvent::RunEnd {
1560 total_success: total_closed,
1561 total_closed,
1562 total_failed,
1563 total_abandoned: 0,
1564 total_awaiting_verify: 0,
1565 total_skipped: plan.blocked.len(),
1566 duration_secs,
1567 };
1568 update_run_store_with_event(&run_store, &run_id, &run_end);
1569 events.push(run_end);
1570
1571 let summary = RunSummary {
1572 total_units: final_units.len(),
1573 total_rounds,
1574 total_closed,
1575 total_failed,
1576 total_abandoned: 0,
1577 total_awaiting_verify: 0,
1578 total_skipped: plan.blocked.len(),
1579 duration_secs,
1580 };
1581 let view = RunView {
1582 summary,
1583 units: final_units,
1584 events,
1585 runtime: Some(runtime),
1586 };
1587 finish_run_in_store(&run_store, &run_id, &view);
1588 Ok(view)
1589}
1590
1591fn spawn_background_run(
1592 mana_dir: std::path::PathBuf,
1593 run_args: NativeRunParams,
1594 ctx: ToolContext,
1595 run_store: Arc<std::sync::Mutex<ManaRunStore>>,
1596 run_id: String,
1597) {
1598 let ui = ctx.ui.clone();
1599 let command_tx = ctx.command_tx.clone();
1600 let scope = scope_from_target(&run_args.target);
1601
1602 tokio::spawn(async move {
1603 ui.set_status(
1604 "mana",
1605 Some(&format!("mana orchestration: running {scope}")),
1606 )
1607 .await;
1608 ui.set_widget("mana", Some(mana_run_widget_lines(&run_id, &scope, None)))
1609 .await;
1610
1611 let run_store_for_progress = run_store.clone();
1612 let run_id_for_progress = run_id.clone();
1613 let scope_for_progress = scope.clone();
1614 let ui_for_progress = ui.clone();
1615 let progress_task = tokio::spawn(async move {
1616 let started = Instant::now();
1617 let mut interval = tokio::time::interval(Duration::from_millis(750));
1618 loop {
1619 interval.tick().await;
1620 let state = run_state_snapshot(&run_store_for_progress, Some(&run_id_for_progress));
1621 if let Some(state) = state.as_ref() {
1622 let status = format!(
1623 "mana {}: {} · {}/{} done · {} failed",
1624 run_id_for_progress,
1625 state.status,
1626 state.summary.total_closed,
1627 state.summary.total_units,
1628 state.summary.total_failed
1629 );
1630 ui_for_progress.set_status("mana", Some(&status)).await;
1631 ui_for_progress
1632 .set_widget(
1633 "mana",
1634 Some(mana_run_widget_lines(
1635 &run_id_for_progress,
1636 &scope_for_progress,
1637 Some(state),
1638 )),
1639 )
1640 .await;
1641 if state.finished_at_ms.is_some()
1642 || matches!(state.status.as_str(), "finished" | "failed" | "interrupted")
1643 {
1644 break;
1645 }
1646 } else if started.elapsed() > Duration::from_secs(5) {
1647 ui_for_progress
1648 .set_status(
1649 "mana",
1650 Some(&format!("mana {run_id_for_progress}: waiting for events")),
1651 )
1652 .await;
1653 }
1654 }
1655 });
1656
1657 let result = run_native_imp_orchestration(
1658 mana_dir,
1659 run_args,
1660 ctx,
1661 run_store.clone(),
1662 run_id.clone(),
1663 )
1664 .await;
1665
1666 progress_task.abort();
1667
1668 match result {
1669 Ok(view) => {
1670 let summary = format!(
1671 "mana orchestration: {scope} finished · {} done · {} failed",
1672 view.summary.total_closed, view.summary.total_failed
1673 );
1674 let runtime_detail = view
1675 .runtime
1676 .as_ref()
1677 .map(|runtime| {
1678 let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
1679 let model = runtime.model.as_deref().unwrap_or("default-model");
1680 format!(
1681 "native mana tool → mana orchestration → {agent} workers · {scope} · {model}"
1682 )
1683 })
1684 .unwrap_or_else(|| scope.clone());
1685 ui.set_status("mana", Some(&summary)).await;
1686 ui.set_widget(
1687 "mana",
1688 Some(mana_widget_lines(summary.clone(), Some(runtime_detail))),
1689 )
1690 .await;
1691 ui.notify(&summary, NotifyLevel::Info).await;
1692 if !ui.has_ui() {
1693 let _ = command_tx
1694 .send(crate::agent::AgentCommand::FollowUp(
1695 make_follow_up_summary(&scope, &view),
1696 ))
1697 .await;
1698 }
1699 let ui_clear = ui.clone();
1700 tokio::spawn(async move {
1701 tokio::time::sleep(std::time::Duration::from_secs(12)).await;
1702 ui_clear.set_widget("mana", None).await;
1703 ui_clear.set_status("mana", None).await;
1704 });
1705 }
1706 Err(err) => {
1707 let message = format!("mana orchestration: {scope} failed: {err}");
1708 fail_run_in_store(&run_store, &run_id, message.clone());
1709 ui.set_status("mana", Some(&message)).await;
1710 ui.set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
1711 .await;
1712 ui.notify(&message, NotifyLevel::Error).await;
1713 if !ui.has_ui() {
1714 let _ = command_tx
1715 .send(crate::agent::AgentCommand::FollowUp(format!(
1716 "Native mana orchestration failed for {scope}: {err}. Inspect with mana(action=\"run_state\") or mana(action=\"logs\", run_id=\"{run_id}\")."
1717 )))
1718 .await;
1719 }
1720 }
1721 }
1722 });
1723}
1724
1725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1726enum GuideTopic {
1727 Overview,
1728 Task,
1729 Epic,
1730 Decision,
1731 Notes,
1732 Verify,
1733 Orchestrate,
1734 WorkerContext,
1735}
1736
1737impl GuideTopic {
1738 fn as_str(self) -> &'static str {
1739 match self {
1740 Self::Overview => "overview",
1741 Self::Task => "task",
1742 Self::Epic => "epic",
1743 Self::Decision => "decision",
1744 Self::Notes => "notes",
1745 Self::Verify => "verify",
1746 Self::Orchestrate => "orchestrate",
1747 Self::WorkerContext => "worker_context",
1748 }
1749 }
1750
1751 fn parse(raw: &str) -> Option<Self> {
1752 match raw {
1753 "overview" => Some(Self::Overview),
1754 "task" => Some(Self::Task),
1755 "epic" => Some(Self::Epic),
1756 "decision" => Some(Self::Decision),
1757 "notes" => Some(Self::Notes),
1758 "verify" => Some(Self::Verify),
1759 "orchestrate" => Some(Self::Orchestrate),
1760 "worker_context" => Some(Self::WorkerContext),
1761 _ => None,
1762 }
1763 }
1764}
1765
1766#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1767enum TemplateKind {
1768 Epic,
1769 Task,
1770 Fact,
1771}
1772
1773impl TemplateKind {
1774 fn as_str(self) -> &'static str {
1775 match self {
1776 Self::Epic => "epic",
1777 Self::Task => "task",
1778 Self::Fact => "fact",
1779 }
1780 }
1781
1782 fn parse(raw: &str) -> Option<Self> {
1783 match raw {
1784 "epic" => Some(Self::Epic),
1785 "task" => Some(Self::Task),
1786 "fact" => Some(Self::Fact),
1787 _ => None,
1788 }
1789 }
1790}
1791
1792fn parse_guide_topic(params: &serde_json::Value) -> Result<GuideTopic> {
1793 let topic = parse_optional_string(¶ms["topic"]).unwrap_or_else(|| "overview".to_string());
1794 GuideTopic::parse(&topic).ok_or_else(|| {
1795 crate::error::Error::Tool(format!(
1796 "Invalid mana guide topic '{topic}'. Use overview, task, epic, decision, notes, verify, orchestrate, or worker_context."
1797 ))
1798 })
1799}
1800
1801fn parse_optional_guide_topic(params: &serde_json::Value) -> Result<Option<GuideTopic>> {
1802 match parse_optional_string(¶ms["topic"]) {
1803 Some(topic) => GuideTopic::parse(&topic).map(Some).ok_or_else(|| {
1804 crate::error::Error::Tool(format!(
1805 "Invalid mana template topic '{topic}'. Use overview, task, epic, decision, notes, verify, orchestrate, or worker_context."
1806 ))
1807 }),
1808 None => Ok(None),
1809 }
1810}
1811
1812fn parse_template_kind(params: &serde_json::Value) -> Result<TemplateKind> {
1813 let kind = parse_optional_string(¶ms["kind"]).unwrap_or_else(|| "task".to_string());
1814 TemplateKind::parse(&kind).ok_or_else(|| {
1815 crate::error::Error::Tool(format!(
1816 "Invalid mana template kind '{kind}'. Use epic, task, or fact."
1817 ))
1818 })
1819}
1820
1821fn topic_guidance(topic: GuideTopic) -> (&'static str, Vec<&'static str>, Vec<&'static str>) {
1822 match topic {
1823 GuideTopic::Overview => (
1824 "Use mana when work needs durable scope, verification, dependencies, retries, or handoff; use direct edits for small one-pass changes.",
1825 vec![
1826 "Before creating, inspect existing relevant units with list/show and update or append notes when the new state belongs there.",
1827 "Create epics for new durable goals and tasks for new executable units, not for routine progress on existing work.",
1828 "Record decisions/notes when context should survive the turn.",
1829 "Close only after the verify command or equivalent evidence passes.",
1830 ],
1831 vec![
1832 "list status=in_progress",
1833 "show id=...",
1834 "update id=...",
1835 "template kind=task",
1836 ],
1837 ),
1838 GuideTopic::Task => (
1839 "A task is a worker-ready executable spec with clear scope, acceptance, files, and a verify gate.",
1840 vec![
1841 "Title the outcome, not the activity.",
1842 "Description should include current state, exact steps, edge cases, and non-goals.",
1843 "Acceptance and verify define done.",
1844 ],
1845 vec![
1846 "template kind=task",
1847 "create kind=task title=... verify=...",
1848 ],
1849 ),
1850 GuideTopic::Epic => (
1851 "An epic is a durable feature/spec container that decomposes into executable child tasks.",
1852 vec![
1853 "Capture goal, constraints, architecture direction, and sequencing.",
1854 "Keep implementation in child tasks with verify commands.",
1855 ],
1856 vec![
1857 "template kind=epic",
1858 "create kind=epic title=... feature=true",
1859 ],
1860 ),
1861 GuideTopic::Decision => (
1862 "Use decisions for scope, architecture, sequencing, and tradeoffs future workers should not relitigate.",
1863 vec![
1864 "Add decisions when a choice changes implementation direction.",
1865 "Resolve decisions when the blocker is answered or superseded.",
1866 ],
1867 vec![
1868 "decision_add id=... description=...",
1869 "decision_resolve id=... resolve_decisions=...",
1870 ],
1871 ),
1872 GuideTopic::Notes => (
1873 "Use notes for durable progress, diagnosis, blockers, failed attempts, and retry changes.",
1874 vec![
1875 "Append concrete evidence, commands, files, and observed errors.",
1876 "After failures, update notes before retrying with a changed plan.",
1877 ],
1878 vec!["notes_append id=... notes=..."],
1879 ),
1880 GuideTopic::Verify => (
1881 "Verification is first-class: acceptance says what must be true; verify is the command/evidence that proves it.",
1882 vec![
1883 "Use fail_first for regression tasks where the check should fail before implementation.",
1884 "Prefer narrow, repeatable commands over broad expensive checks.",
1885 "If verify is wrong, record why and use equivalent evidence explicitly.",
1886 ],
1887 vec![
1888 "create title=... acceptance=... verify=...",
1889 "verify id=...",
1890 ],
1891 ),
1892 GuideTopic::Orchestrate => (
1893 "Orchestration runs ready tasks in dependency waves and returns run_id for state/log inspection.",
1894 vec![
1895 "Create dependencies before running parallel waves.",
1896 "Use run_state/logs/agents to inspect active work.",
1897 "Update failed units with new context before retrying.",
1898 ],
1899 vec![
1900 "run targets=[...]",
1901 "run_state run_id=...",
1902 "logs run_id=...",
1903 ],
1904 ),
1905 GuideTopic::WorkerContext => (
1906 "Worker context is assembled from unit fields: title, description, acceptance, verify, paths, dependencies, notes, and decisions.",
1907 vec![
1908 "Write units so another agent can execute cold without guessing.",
1909 "Put architecture or scope choices in decisions/notes, not transient chat.",
1910 ],
1911 vec!["show id=...", "notes_append id=... notes=..."],
1912 ),
1913 }
1914}
1915
1916fn mana_guide_output(topic: GuideTopic) -> ToolOutput {
1917 let (summary, guidance, next_actions) = topic_guidance(topic);
1918 text_output(
1919 format!(
1920 "mana guide: {}\n{}\n- {}\nnext: {}",
1921 topic.as_str(),
1922 summary,
1923 guidance.join("\n- "),
1924 next_actions.join("; ")
1925 ),
1926 json!({
1927 "action": "guide",
1928 "topic": topic.as_str(),
1929 "summary": summary,
1930 "guidance": guidance,
1931 "next_actions": next_actions,
1932 }),
1933 )
1934}
1935
1936fn template_body(kind: TemplateKind, topic: Option<GuideTopic>) -> serde_json::Value {
1937 match kind {
1938 TemplateKind::Epic => json!({
1939 "kind": "epic",
1940 "title": "Outcome-oriented goal",
1941 "description": "Goal, users, constraints, architecture direction, decomposition plan, and non-goals.",
1942 "acceptance": "Child tasks cover implementation, verification, docs, and rollout risks.",
1943 "feature": true,
1944 "labels": ["feature"],
1945 }),
1946 TemplateKind::Task => json!({
1947 "kind": "task",
1948 "title": "Implement/fix concrete outcome",
1949 "description": "Current state, exact steps, relevant files, edge cases, and scope boundaries. Include enough context for a cold worker.",
1950 "acceptance": "Observable behavior or artifact that defines done.",
1951 "verify": "targeted command that proves the task",
1952 "paths": ["path/to/file"],
1953 "fail_first": topic == Some(GuideTopic::Verify),
1954 }),
1955 TemplateKind::Fact => json!({
1956 "kind": "fact",
1957 "title": "Verifiable project claim",
1958 "verify": "command that exits 0 while the claim remains true",
1959 "ttl_days": 30,
1960 "paths": ["path/to/evidence"],
1961 }),
1962 }
1963}
1964
1965fn mana_template_output(kind: TemplateKind, topic: Option<GuideTopic>) -> ToolOutput {
1966 let template = template_body(kind, topic);
1967 let topic_text = topic.map(|topic| topic.as_str()).unwrap_or("general");
1968 let summary = match kind {
1969 TemplateKind::Epic => "Epic template for durable feature/spec containers.",
1970 TemplateKind::Task => "Task template for worker-ready executable specs.",
1971 TemplateKind::Fact => "Fact template for re-checkable project claims.",
1972 };
1973 text_output(
1974 format!(
1975 "mana template: {} ({})\n{}\n{}",
1976 kind.as_str(),
1977 topic_text,
1978 summary,
1979 serde_json::to_string_pretty(&template).unwrap_or_else(|_| template.to_string())
1980 ),
1981 json!({
1982 "action": "template",
1983 "kind": kind.as_str(),
1984 "topic": topic_text,
1985 "summary": summary,
1986 "template": template,
1987 "next_actions": ["create", "update", "notes_append"],
1988 }),
1989 )
1990}
1991
1992fn text_output(text: String, details: serde_json::Value) -> ToolOutput {
1993 ToolOutput {
1994 content: vec![imp_llm::ContentBlock::Text { text }],
1995 details,
1996 is_error: false,
1997 }
1998}
1999
2000fn run_state_snapshot(
2001 run_store: &Arc<std::sync::Mutex<ManaRunStore>>,
2002 run_id: Option<&str>,
2003) -> Option<NativeRunState> {
2004 run_store
2005 .lock()
2006 .ok()
2007 .and_then(|store| store.snapshot(run_id))
2008}
2009
2010fn retry_guardrail_for_targets(
2011 mana_dir: &Path,
2012 target_ids: &[String],
2013) -> Result<Option<serde_json::Value>> {
2014 let mut blocked_units = Vec::new();
2015 for id in target_ids {
2016 let Ok(result) = mana_core::ops::show::get(mana_dir, id) else {
2017 continue;
2018 };
2019 let unit = result.unit;
2020 let Some(attempt) = unit.attempt_log.last() else {
2021 continue;
2022 };
2023 if !matches!(
2024 attempt.outcome,
2025 mana_core::unit::types::AttemptOutcome::Failed
2026 ) {
2027 continue;
2028 }
2029 let Some(finished_at) = attempt.finished_at else {
2030 continue;
2031 };
2032 if unit.updated_at <= finished_at {
2033 blocked_units.push(json!({
2034 "id": unit.id,
2035 "title": unit.title,
2036 "failed_at": finished_at,
2037 "updated_at": unit.updated_at,
2038 "last_failure": attempt.notes,
2039 }));
2040 }
2041 }
2042
2043 if blocked_units.is_empty() {
2044 return Ok(None);
2045 }
2046
2047 Ok(Some(json!({
2048 "retry_requires_unit_update": true,
2049 "blocked_units": blocked_units,
2050 "next_actions": [
2051 "Inspect failed unit with mana action=show id=<unit>",
2052 "Append notes with failure evidence and a changed retry plan",
2053 "Retry only after updating the failed unit"
2054 ],
2055 })))
2056}
2057
2058fn run_recovery_details(state: &NativeRunState) -> serde_json::Value {
2059 let failed_units: Vec<_> = state
2060 .units
2061 .iter()
2062 .filter(|unit| unit.status == "failed")
2063 .map(|unit| {
2064 json!({
2065 "id": unit.id,
2066 "title": unit.title,
2067 "failure_summary": unit.failure_summary,
2068 "error": unit.error,
2069 })
2070 })
2071 .collect();
2072 let running_units: Vec<_> = state
2073 .units
2074 .iter()
2075 .filter(|unit| unit.status == "running")
2076 .map(|unit| json!({ "id": unit.id, "title": unit.title, "agent": unit.agent }))
2077 .collect();
2078 let awaiting_verify_units: Vec<_> = state
2079 .units
2080 .iter()
2081 .filter(|unit| unit.status == "awaiting_verify")
2082 .map(|unit| json!({ "id": unit.id, "title": unit.title }))
2083 .collect();
2084 let interrupted = state.status == "interrupted";
2085 let last_event_at_ms = if state.last_event_at_ms > 0 {
2086 state.last_event_at_ms
2087 } else {
2088 state.finished_at_ms.unwrap_or(state.started_at_ms)
2089 };
2090 let mut next_actions = Vec::new();
2091 let mut retry_requires_unit_update = false;
2092
2093 if interrupted {
2094 next_actions.push(
2095 "Run was marked interrupted/stale after reload; inspect logs before rerun".to_string(),
2096 );
2097 next_actions.push("Do not assume in-flight workers or tools are still running".to_string());
2098 }
2099 if !failed_units.is_empty() || state.status == "failed" || state.summary.total_failed > 0 {
2100 retry_requires_unit_update = true;
2101 next_actions.push("Inspect failed units with mana action=show id=<unit>".to_string());
2102 next_actions.push(
2103 "Append notes with the failure evidence and changed retry plan before rerun"
2104 .to_string(),
2105 );
2106 next_actions
2107 .push("Retry only after updating failed units; do not rerun unchanged".to_string());
2108 }
2109 if !awaiting_verify_units.is_empty() || state.summary.total_awaiting_verify > 0 {
2110 next_actions.push("Verify candidate-complete units or close with equivalent evidence if stored verify is stale".to_string());
2111 }
2112 if state.status == "running" || state.status == "starting" || !running_units.is_empty() {
2113 next_actions.push("Inspect logs/agents before assuming the run is stale".to_string());
2114 }
2115 if next_actions.is_empty() {
2116 next_actions.push("No recovery action required".to_string());
2117 }
2118
2119 json!({
2120 "status": state.status,
2121 "failed_units": failed_units,
2122 "running_units": running_units,
2123 "awaiting_verify_units": awaiting_verify_units,
2124 "stale_workers": if interrupted { json!([{"run_id": state.run_id, "status": state.status}]) } else { json!([]) },
2125 "last_event_at_ms": last_event_at_ms,
2126 "next_actions": next_actions,
2127 "retry_requires_unit_update": retry_requires_unit_update,
2128 })
2129}
2130
2131fn run_state_details(state: &NativeRunState) -> serde_json::Value {
2132 let mut details = serde_json::to_value(state).unwrap_or(serde_json::Value::Null);
2133 if let Some(object) = details.as_object_mut() {
2134 object.insert("recovery".to_string(), run_recovery_details(state));
2135 }
2136 details
2137}
2138
2139fn run_state_output(state: &NativeRunState) -> ToolOutput {
2140 let mut lines = vec![format!(
2141 "Native mana orchestration {}: {} · {}",
2142 state.run_id, state.scope, state.status
2143 )];
2144 if let Some(runtime) = &state.runtime {
2145 let agent = runtime["direct_agent"].as_str().unwrap_or("imp-worker");
2146 let model = runtime["model"].as_str().unwrap_or("default-model");
2147 lines.push(format!("Worker runtime: {agent} · {model}"));
2148 }
2149 lines.push(format!(
2150 "{} total · {} done · {} failed · {} candidate complete / awaiting verify · {} skipped",
2151 state.summary.total_units,
2152 state.summary.total_closed,
2153 state.summary.total_failed,
2154 state.summary.total_awaiting_verify,
2155 state.summary.total_skipped
2156 ));
2157
2158 if state.status == "interrupted" {
2159 lines.push(
2160 "Interrupted: persisted running state is stale; inspect logs before rerun".to_string(),
2161 );
2162 }
2163
2164 if !state.units.is_empty() {
2165 let preview = state
2166 .units
2167 .iter()
2168 .take(3)
2169 .map(|unit| format!("{}:{}", unit.id, unit.status))
2170 .collect::<Vec<_>>()
2171 .join(", ");
2172 lines.push(format!("Units: {preview}"));
2173 }
2174
2175 if let Some(last) = state.log_lines.last() {
2176 lines.push(format!("Latest: {last}"));
2177 }
2178 let recovery = run_recovery_details(state);
2179 if let Some(actions) = recovery["next_actions"].as_array() {
2180 if let Some(first) = actions.first().and_then(|value| value.as_str()) {
2181 lines.push(format!("Next: {first}"));
2182 }
2183 }
2184 text_output(lines.join("\n"), run_state_details(state))
2185}
2186
2187fn evaluate_run_output(state: &NativeRunState) -> ToolOutput {
2188 let headline = match state.status.as_str() {
2189 "starting" | "running" => {
2190 format!(
2191 "Native mana orchestration run {} is still running for {}.",
2192 state.run_id, state.scope
2193 )
2194 }
2195 "failed" => format!(
2196 "Native mana orchestration run {} failed for {}.",
2197 state.run_id, state.scope
2198 ),
2199 _ if state.summary.total_failed > 0 => format!(
2200 "Native mana orchestration run {} finished with {} failed unit(s).",
2201 state.run_id, state.summary.total_failed
2202 ),
2203 _ if state.summary.total_awaiting_verify > 0 => format!(
2204 "Native mana orchestration run {} finished with {} unit(s) candidate complete / awaiting verify.",
2205 state.run_id, state.summary.total_awaiting_verify
2206 ),
2207 _ => format!(
2208 "Native mana orchestration run {} finished successfully: {} unit(s) done.",
2209 state.run_id, state.summary.total_closed
2210 ),
2211 };
2212
2213 let runtime = state
2214 .runtime
2215 .as_ref()
2216 .map(|runtime| {
2217 format!(
2218 "Worker runtime: {} · {}",
2219 runtime["direct_agent"].as_str().unwrap_or("imp-worker"),
2220 runtime["model"].as_str().unwrap_or("default-model")
2221 )
2222 })
2223 .unwrap_or_else(|| "Runtime: unknown".to_string());
2224
2225 let latest = state
2226 .log_lines
2227 .last()
2228 .map(|line| format!("Latest: {line}"))
2229 .unwrap_or_else(|| "Latest: (no stream events captured yet)".to_string());
2230
2231 let recovery = run_recovery_details(state);
2232 let next = recovery["next_actions"]
2233 .as_array()
2234 .and_then(|actions| actions.first())
2235 .and_then(|value| value.as_str())
2236 .map(|action| format!("Next: {action}"))
2237 .unwrap_or_else(|| "Next: No recovery action required".to_string());
2238
2239 text_output(
2240 format!("{headline}\n{runtime}\n{latest}\n{next}"),
2241 run_state_details(state),
2242 )
2243}
2244
2245fn claim_output(result: &mana_core::ops::claim::ClaimResult) -> ToolOutput {
2246 let text = format!(
2247 "Claimed unit {} ({}) by {}",
2248 result.unit.id, result.unit.title, result.claimer
2249 );
2250 ToolOutput {
2251 content: vec![imp_llm::ContentBlock::Text { text }],
2252 details: json!({
2253 "unit": {
2254 "id": result.unit.id,
2255 "title": result.unit.title,
2256 "status": result.unit.status,
2257 "claimed_by": result.unit.claimed_by,
2258 },
2259 "claimer": result.claimer,
2260 "is_goal": result.is_goal,
2261 "path": result.path,
2262 }),
2263 is_error: false,
2264 }
2265}
2266
2267fn release_output(result: &mana_core::ops::claim::ReleaseResult) -> ToolOutput {
2268 let text = format!(
2269 "Released unit {} ({}) back to {}",
2270 result.unit.id, result.unit.title, result.unit.status
2271 );
2272 ToolOutput {
2273 content: vec![imp_llm::ContentBlock::Text { text }],
2274 details: json!({
2275 "unit": {
2276 "id": result.unit.id,
2277 "title": result.unit.title,
2278 "status": result.unit.status,
2279 "claimed_by": result.unit.claimed_by,
2280 },
2281 "path": result.path,
2282 }),
2283 is_error: false,
2284 }
2285}
2286
2287fn truncate_with_note(text: &str) -> String {
2288 let result = truncate_head(text, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
2289 if !result.truncated {
2290 return result.content;
2291 }
2292
2293 let mut output = result.content;
2294 output.push_str(&format!(
2295 "\n[Output truncated: showing first {} of {} lines{}]",
2296 result.output_lines,
2297 result.total_lines,
2298 result
2299 .temp_file
2300 .as_ref()
2301 .map(|p| format!(". Full output saved to {}", p.display()))
2302 .unwrap_or_default()
2303 ));
2304 output
2305}
2306
2307fn compact_list_output(
2308 entries: &[mana_core::index::IndexEntry],
2309 requested_limit: Option<usize>,
2310) -> ToolOutput {
2311 let limit = requested_limit
2312 .unwrap_or(DEFAULT_LIST_LIMIT)
2313 .clamp(1, MAX_LIST_LIMIT);
2314 let shown = entries.len().min(limit);
2315 let units: Vec<_> = entries
2316 .iter()
2317 .take(limit)
2318 .map(|entry| {
2319 json!({
2320 "id": entry.id,
2321 "title": entry.title,
2322 "status": entry.status,
2323 "priority": entry.priority,
2324 "kind": entry.kind,
2325 "parent": entry.parent,
2326 "labels": entry.labels,
2327 "updated_at": entry.updated_at,
2328 "claimed_by": entry.claimed_by,
2329 "has_verify": entry.has_verify,
2330 "attempts": entry.attempts,
2331 "paths": entry.paths,
2332 })
2333 })
2334 .collect();
2335
2336 let mut lines = vec![format!(
2337 "mana list: showing {shown} of {} units. Prefer `show` + `update`/`notes_append` on an existing relevant unit before `create`.",
2338 entries.len()
2339 )];
2340 for entry in entries.iter().take(limit) {
2341 let parent = entry
2342 .parent
2343 .as_ref()
2344 .map(|parent| format!(" parent={parent}"))
2345 .unwrap_or_default();
2346 let claimed = entry
2347 .claimed_by
2348 .as_ref()
2349 .map(|claimed_by| format!(" claimed={claimed_by}"))
2350 .unwrap_or_default();
2351 lines.push(format!(
2352 "- {} P{} {:?} {:?}{}{} — {}",
2353 entry.id, entry.priority, entry.status, entry.kind, parent, claimed, entry.title
2354 ));
2355 }
2356 if entries.len() > shown {
2357 lines.push(format!(
2358 "[{} more omitted; narrow with status/parent/label/priority or request all only when needed]",
2359 entries.len() - shown
2360 ));
2361 }
2362
2363 text_output(
2364 lines.join("\n"),
2365 json!({
2366 "action": "list",
2367 "total": entries.len(),
2368 "shown": shown,
2369 "limit": limit,
2370 "truncated": entries.len() > shown,
2371 "hint": "Prefer updating existing relevant units; use show id=... before create when unsure.",
2372 "units": units,
2373 }),
2374 )
2375}
2376
2377fn scored_units_to_text(units: &[ScoredUnit]) -> String {
2378 if units.is_empty() {
2379 return "No ready units. Create one with: mana create \"task\" --verify \"cmd\""
2380 .to_string();
2381 }
2382
2383 let mut lines = Vec::new();
2384 for unit in units {
2385 lines.push(format!(
2386 "P{} {:.1} {}",
2387 unit.priority, unit.score, unit.title
2388 ));
2389 if !unit.unblocks.is_empty() {
2390 lines.push(format!(" Unblocks: {}", unit.unblocks.join(", ")));
2391 }
2392 let attempts = if unit.attempts > 0 {
2393 format!(" | Attempts: {}", unit.attempts)
2394 } else {
2395 String::new()
2396 };
2397 lines.push(format!(
2398 " ID: {} | Age: {} days{}",
2399 unit.id, unit.age_days, attempts
2400 ));
2401 lines.push(String::new());
2402 }
2403 lines.join("\n")
2404}
2405
2406fn tree_lines(node: &mana_core::api::TreeNode, indent: usize, out: &mut Vec<String>) {
2407 let prefix = " ".repeat(indent);
2408 let verify = if node.has_verify { "spec" } else { "goal" };
2409 out.push(format!(
2410 "{}{} {} [{} P{} · {}]",
2411 prefix, node.id, node.title, node.status, node.priority, verify
2412 ));
2413 for child in &node.children {
2414 tree_lines(child, indent + 1, out);
2415 }
2416}
2417
2418pub struct ManaTool {
2419 run_store: Arc<std::sync::Mutex<ManaRunStore>>,
2420}
2421
2422impl Default for ManaTool {
2423 fn default() -> Self {
2424 Self {
2425 run_store: Arc::new(std::sync::Mutex::new(ManaRunStore::load_persisted())),
2426 }
2427 }
2428}
2429
2430#[async_trait]
2431impl Tool for ManaTool {
2432 fn name(&self) -> &str {
2433 "mana"
2434 }
2435 fn label(&self) -> &str {
2436 "Mana"
2437 }
2438 fn description(&self) -> &str {
2439 "Mana work graph operations. Use guide/template for detailed usage."
2440 }
2441 fn parameters(&self) -> serde_json::Value {
2442 let string_or_array = || {
2443 json!({
2444 "oneOf": [
2445 { "type": "string" },
2446 { "type": "array", "items": { "type": "string" } }
2447 ]
2448 })
2449 };
2450
2451 let mut properties = serde_json::Map::new();
2452 properties.insert(
2453 "action".into(),
2454 json!({ "type": "string", "enum": ["status", "list", "show", "create", "close", "update", "run", "run_state", "evaluate", "claim", "release", "logs", "agents", "next", "tree", "reopen", "verify", "fail", "delete", "dep_add", "dep_remove", "fact_create", "fact_verify", "notes_append", "decision_add", "decision_resolve", "reparent", "guide", "template"] }),
2455 );
2456 properties.insert("id".into(), json!({ "type": "string" }));
2457 properties.insert(
2458 "scope".into(),
2459 json!({ "type": "string", "enum": ["auto", "project", "root"], "description": "auto|project|root" }),
2460 );
2461 properties.insert(
2462 "path".into(),
2463 json!({ "type": "string", "description": "Project/.mana path" }),
2464 );
2465 properties.insert(
2466 "from_id".into(),
2467 json!({ "type": "string", "description": "Source unit ID" }),
2468 );
2469 properties.insert(
2470 "dep_id".into(),
2471 json!({ "type": "string", "description": "Dependency unit ID" }),
2472 );
2473 properties.insert(
2474 "run_id".into(),
2475 json!({ "type": "string", "description": "Run ID from action=run" }),
2476 );
2477 properties.insert("title".into(), json!({ "type": "string" }));
2478 properties.insert(
2479 "topic".into(),
2480 json!({ "type": "string", "enum": ["overview", "task", "epic", "decision", "notes", "verify", "orchestrate", "worker_context"], "description": "guide/template topic" }),
2481 );
2482 properties.insert(
2483 "verify".into(),
2484 json!({ "type": "string", "description": "Verify shell command" }),
2485 );
2486 properties.insert("description".into(), json!({ "type": "string" }));
2487 properties.insert(
2488 "acceptance".into(),
2489 json!({ "type": "string", "description": "Acceptance criteria" }),
2490 );
2491 properties.insert(
2492 "notes".into(),
2493 json!({ "type": "string", "description": "Progress notes" }),
2494 );
2495 properties.insert(
2496 "design".into(),
2497 json!({ "type": "string", "description": "Design context" }),
2498 );
2499 properties.insert(
2500 "assignee".into(),
2501 json!({ "type": "string", "description": "Assignee/owner" }),
2502 );
2503 properties.insert("parent".into(), json!({ "type": "string" }));
2504 properties.insert(
2505 "parent_reason".into(),
2506 json!({ "type": "string", "description": "Why this parent" }),
2507 );
2508 let mut deps = string_or_array();
2509 deps["description"] = json!("Dependency IDs");
2510 properties.insert("deps".into(), deps);
2511 let mut produces = string_or_array();
2512 produces["description"] = json!("Produced artifacts");
2513 properties.insert("produces".into(), produces);
2514 let mut requires = string_or_array();
2515 requires["description"] = json!("Required artifacts");
2516 properties.insert("requires".into(), requires);
2517 let mut paths = string_or_array();
2518 paths["description"] = json!("Relevant paths");
2519 properties.insert("paths".into(), paths);
2520 let mut decisions = string_or_array();
2521 decisions["description"] = json!("Blocking decisions");
2522 properties.insert("decisions".into(), decisions);
2523 let mut resolve_decisions = string_or_array();
2524 resolve_decisions["description"] = json!("Decision IDs/indexes to resolve");
2525 properties.insert("resolve_decisions".into(), resolve_decisions);
2526 properties.insert("status".into(), json!({ "type": "string" }));
2527 properties.insert("priority".into(), json!({ "type": "integer" }));
2528 let mut labels = string_or_array();
2529 labels["description"] = json!("Labels");
2530 properties.insert("labels".into(), labels);
2531 properties.insert(
2532 "kind".into(),
2533 json!({ "type": "string", "enum": ["epic", "task", "fact"], "description": "Unit type" }),
2534 );
2535 properties.insert(
2536 "feature".into(),
2537 json!({ "type": "boolean", "description": "Feature-level goal" }),
2538 );
2539 properties.insert(
2540 "fail_first".into(),
2541 json!({ "type": "boolean", "description": "Verify should fail first" }),
2542 );
2543 properties.insert(
2544 "verify_timeout".into(),
2545 json!({ "type": "integer", "description": "Verify timeout seconds" }),
2546 );
2547 properties.insert(
2548 "on_fail".into(),
2549 json!({ "description": "retry:3 or escalate:P1" }),
2550 );
2551 properties.insert(
2552 "ttl_days".into(),
2553 json!({ "type": "integer", "description": "Fact TTL days" }),
2554 );
2555 properties.insert(
2556 "pass_ok".into(),
2557 json!({ "type": "boolean", "description": "Allow passing fact verify" }),
2558 );
2559 properties.insert(
2560 "force".into(),
2561 json!({ "type": "boolean", "description": "Bypass close verify with reason" }),
2562 );
2563 properties.insert("reason".into(), json!({ "type": "string" }));
2564 properties.insert("all".into(), json!({ "type": "boolean" }));
2565 properties.insert(
2566 "by".into(),
2567 json!({ "type": "string", "description": "Claimer" }),
2568 );
2569 properties.insert(
2570 "count".into(),
2571 json!({ "type": "integer", "description": "Max results" }),
2572 );
2573 properties.insert(
2574 "targets".into(),
2575 json!({ "type": "array", "items": { "type": "string" }, "description": "Target unit IDs" }),
2576 );
2577 properties.insert("jobs".into(), json!({ "type": "integer" }));
2578 properties.insert("dry_run".into(), json!({ "type": "boolean" }));
2579 properties.insert("loop".into(), json!({ "type": "boolean" }));
2580 properties.insert("keep_going".into(), json!({ "type": "boolean" }));
2581 properties.insert("timeout".into(), json!({ "type": "integer" }));
2582 properties.insert("idle_timeout".into(), json!({ "type": "integer" }));
2583 properties.insert("review".into(), json!({ "type": "boolean" }));
2584
2585 serde_json::Value::Object(serde_json::Map::from_iter([
2586 ("type".into(), json!("object")),
2587 ("properties".into(), serde_json::Value::Object(properties)),
2588 ("required".into(), json!(["action"])),
2589 ]))
2590 }
2591 fn is_readonly(&self) -> bool {
2592 false
2593 }
2594
2595 async fn execute(
2596 &self,
2597 _call_id: &str,
2598 params: serde_json::Value,
2599 ctx: ToolContext,
2600 ) -> Result<ToolOutput> {
2601 let action = params["action"]
2602 .as_str()
2603 .ok_or_else(|| crate::error::Error::Tool("missing 'action' parameter".into()))?;
2604
2605 let mode = ctx.mode;
2606
2607 match action {
2608 "guide" => return Ok(mana_guide_output(parse_guide_topic(¶ms)?)),
2609 "template" => {
2610 return Ok(mana_template_output(
2611 parse_template_kind(¶ms)?,
2612 parse_optional_guide_topic(¶ms)?,
2613 ));
2614 }
2615 _ => {}
2616 }
2617
2618 if !mode.allows_mana_action(action) {
2619 let mode_name = format!("{mode:?}").to_lowercase();
2620 return Ok(ToolOutput::error(format!(
2621 "Mana action '{action}' is not available in {mode_name} mode"
2622 )));
2623 }
2624
2625 if let Some(validation_error) = validate_mana_action(action, ¶ms) {
2626 return Ok(validation_error);
2627 }
2628
2629 let mana_dir = resolve_mana_dir(&ctx.cwd, ¶ms).map_err(crate::error::Error::Tool)?;
2630
2631 match action {
2632 "status" => match mana_core::api::get_status(&mana_dir) {
2633 Ok(status) => Ok(json_output(&status)),
2634 Err(e) => Ok(ToolOutput::error(e.to_string())),
2635 },
2636 "list" => {
2637 let list_params = mana_core::ops::list::ListParams {
2638 status: params["status"].as_str().map(|s| s.to_string()),
2639 priority: params["priority"].as_u64().map(|p| p as u8),
2640 parent: params["parent"].as_str().map(|s| s.to_string()),
2641 label: params["label"].as_str().map(|s| s.to_string()),
2642 assignee: None,
2643 current_user: None,
2644 include_closed: params["all"].as_bool().unwrap_or(false),
2645 };
2646 match mana_core::api::list_units(&mana_dir, &list_params) {
2647 Ok(entries) => Ok(compact_list_output(
2648 &entries,
2649 params["count"].as_u64().map(|count| count as usize),
2650 )),
2651 Err(e) => {
2652 let message = format!("mana run failed: {e}");
2653 ctx.ui
2654 .set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
2655 .await;
2656 ctx.ui.set_status("mana", Some(&message)).await;
2657 Ok(ToolOutput::error(e.to_string()))
2658 }
2659 }
2660 }
2661 "show" => {
2662 let id = params["id"]
2663 .as_str()
2664 .ok_or_else(|| crate::error::Error::Tool("show requires 'id'".into()))?;
2665 match mana_core::ops::show::get(&mana_dir, id) {
2666 Ok(result) => Ok(json_output(&result.unit)),
2667 Err(e) => Ok(ToolOutput::error(e.to_string())),
2668 }
2669 }
2670 "create" => {
2671 let title = params["title"]
2672 .as_str()
2673 .ok_or_else(|| crate::error::Error::Tool("create requires 'title'".into()))?;
2674 let dependencies = parse_csv_strings(¶ms["deps"], "deps")?;
2675 let labels = parse_csv_strings(¶ms["labels"], "labels")?;
2676 let produces = parse_csv_strings(¶ms["produces"], "produces")?;
2677 let requires = parse_csv_strings(¶ms["requires"], "requires")?;
2678 let paths = parse_csv_strings(¶ms["paths"], "paths")?;
2679 let decisions = parse_csv_strings(¶ms["decisions"], "decisions")?;
2680 let on_fail = parse_on_fail(¶ms["on_fail"])?;
2681 let kind = parse_unit_kind(¶ms["kind"])?;
2682
2683 let create_params = mana_core::ops::create::CreateParams {
2684 title: title.to_string(),
2685 handle: None,
2686 description: parse_optional_string(¶ms["description"]),
2687 acceptance: parse_optional_string(¶ms["acceptance"]),
2688 notes: parse_optional_string(¶ms["notes"]),
2689 design: parse_optional_string(¶ms["design"]),
2690 verify: parse_optional_string(¶ms["verify"]),
2691 priority: params["priority"].as_u64().map(|p| p as u8),
2692 labels,
2693 assignee: parse_optional_string(¶ms["assignee"]),
2694 dependencies,
2695 parent: parse_optional_string(¶ms["parent"]),
2696 produces,
2697 requires,
2698 paths,
2699 on_fail,
2700 fail_first: params["fail_first"].as_bool().unwrap_or(false),
2701 feature: params["feature"].as_bool().unwrap_or(false),
2702 kind,
2703 verify_timeout: params["verify_timeout"].as_u64(),
2704 decisions,
2705 force: params["force"].as_bool().unwrap_or(false),
2706 };
2707 match mana_core::api::create_unit(&mana_dir, create_params) {
2708 Ok(result) => {
2709 let unit_value =
2710 serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2711 let summary = unit_delta_label(&unit_value)
2712 .map(|label| format!("mana delta: created {label}"))
2713 .unwrap_or_else(|| "mana delta: created unit".to_string());
2714 let parent = parse_optional_string(¶ms["parent"]);
2715 let parent_reason = parse_optional_string(¶ms["parent_reason"]);
2716 let detail = parent
2717 .as_ref()
2718 .map(|parent| match parent_reason.as_deref() {
2719 Some(reason) => format!("parent {parent}: {reason}"),
2720 None => format!("parent {parent}; parent_reason missing"),
2721 });
2722 set_mana_delta_widget(&ctx, summary.clone(), detail).await;
2723 Ok(text_output(
2724 summary,
2725 json!({
2726 "action": "create",
2727 "title": title,
2728 "description": params["description"],
2729 "verify": params["verify"],
2730 "priority": params["priority"],
2731 "parent": params["parent"],
2732 "parent_reason": params["parent_reason"],
2733 "placement": parent_placement_details(parent.as_deref(), parent_reason.as_deref()),
2734 "deps": params["deps"],
2735 "labels": params["labels"],
2736 "unit": unit_value,
2737 "path": result.path,
2738 }),
2739 ))
2740 }
2741 Err(e) => Ok(ToolOutput::error(e.to_string())),
2742 }
2743 }
2744 "claim" => {
2745 let id = params["id"]
2746 .as_str()
2747 .ok_or_else(|| crate::error::Error::Tool("claim requires 'id'".into()))?;
2748 let claim_params = ClaimParams {
2749 by: params["by"].as_str().map(|s| s.to_string()),
2750 force: params["force"].as_bool().unwrap_or(true),
2751 };
2752 match mana_core::api::claim_unit(&mana_dir, id, claim_params) {
2753 Ok(result) => Ok(claim_output(&result)),
2754 Err(e) => Ok(ToolOutput::error(e.to_string())),
2755 }
2756 }
2757 "release" => {
2758 let id = params["id"]
2759 .as_str()
2760 .ok_or_else(|| crate::error::Error::Tool("release requires 'id'".into()))?;
2761 match mana_core::api::release_unit(&mana_dir, id) {
2762 Ok(result) => Ok(release_output(&result)),
2763 Err(e) => Ok(ToolOutput::error(e.to_string())),
2764 }
2765 }
2766 "close" => {
2767 let id = params["id"]
2768 .as_str()
2769 .ok_or_else(|| crate::error::Error::Tool("close requires 'id'".into()))?;
2770 let force = params["force"].as_bool().unwrap_or(false);
2771 let reason = params["reason"].as_str().map(|s| s.to_string());
2772 if force
2773 && reason
2774 .as_deref()
2775 .map(str::trim)
2776 .unwrap_or_default()
2777 .is_empty()
2778 {
2779 return Ok(mana_close_force_reason_error(id));
2780 }
2781 let opts = mana_core::ops::close::CloseOpts {
2782 reason: reason.clone(),
2783 force,
2784 defer_verify: false,
2785 };
2786 match mana_core::api::close_unit(&mana_dir, id, opts) {
2787 Ok(outcome) => {
2788 let details =
2789 serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
2790 if let Some(unit) = details.get("unit") {
2791 let summary = unit_delta_label(unit)
2792 .map(|label| format!("mana delta: closed {label}"))
2793 .unwrap_or_else(|| format!("mana delta: closed {id}"));
2794 set_mana_delta_widget(&ctx, summary, reason.clone()).await;
2795 }
2796 let mut details_obj = details.as_object().cloned().unwrap_or_default();
2797 details_obj.insert("action".into(), json!("close"));
2798 details_obj.insert("force".into(), json!(force));
2799 if let Some(reason) = reason.as_deref() {
2800 details_obj.insert("reason".into(), json!(reason));
2801 if force {
2802 details_obj
2803 .insert("equivalent_verify_evidence".into(), json!(reason));
2804 }
2805 }
2806 Ok(text_output(
2807 format!("Closed unit {id}"),
2808 serde_json::Value::Object(details_obj),
2809 ))
2810 }
2811 Err(e) => Ok(mana_close_error_output(id, e.to_string())),
2812 }
2813 }
2814 "update" => {
2815 let id = params["id"]
2816 .as_str()
2817 .ok_or_else(|| crate::error::Error::Tool("update requires 'id'".into()))?;
2818 let decisions = parse_csv_strings(¶ms["decisions"], "decisions")?;
2819 let resolve_decisions =
2820 parse_csv_strings(¶ms["resolve_decisions"], "resolve_decisions")?;
2821 let labels = parse_csv_strings(¶ms["labels"], "labels")?;
2822 let update_params = mana_core::ops::update::UpdateParams {
2823 title: parse_optional_string(¶ms["title"]),
2824 description: parse_optional_string(¶ms["description"]),
2825 acceptance: parse_optional_string(¶ms["acceptance"]),
2826 notes: parse_optional_string(¶ms["notes"]),
2827 design: parse_optional_string(¶ms["design"]),
2828 status: parse_optional_string(¶ms["status"]),
2829 priority: params["priority"].as_u64().map(|p| p as u8),
2830 assignee: parse_optional_string(¶ms["assignee"]),
2831 add_label: labels
2832 .into_iter()
2833 .next()
2834 .or_else(|| parse_optional_string(¶ms["add_label"])),
2835 remove_label: parse_optional_string(¶ms["remove_label"]),
2836 decisions,
2837 resolve_decisions,
2838 };
2839 match mana_core::api::update_unit(&mana_dir, id, update_params) {
2840 Ok(result) => {
2841 let unit_value =
2842 serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2843 let summary = unit_delta_label(&unit_value)
2844 .map(|label| format!("mana delta: updated {label}"))
2845 .unwrap_or_else(|| format!("mana delta: updated {id}"));
2846 set_mana_delta_widget(&ctx, summary.clone(), None).await;
2847 Ok(text_output(
2848 summary,
2849 json!({
2850 "action": "update",
2851 "id": id,
2852 "status": params["status"],
2853 "title": params["title"],
2854 "description": params["description"],
2855 "priority": params["priority"],
2856 "notes": params["notes"],
2857 "acceptance": params["acceptance"],
2858 "add_label": params["add_label"],
2859 "remove_label": params["remove_label"],
2860 "decisions": params["decisions"],
2861 "resolve_decisions": params["resolve_decisions"],
2862 "unit": unit_value,
2863 "path": result.path,
2864 }),
2865 ))
2866 }
2867 Err(e) => Ok(ToolOutput::error(e.to_string())),
2868 }
2869 }
2870 "notes_append" => {
2871 let id = params["id"].as_str().ok_or_else(|| {
2872 crate::error::Error::Tool("notes_append requires 'id'".into())
2873 })?;
2874 let note = parse_optional_string(¶ms["notes"]).ok_or_else(|| {
2875 crate::error::Error::Tool("notes_append requires 'notes'".into())
2876 })?;
2877 let update_params = mana_core::ops::update::UpdateParams {
2878 title: None,
2879 description: None,
2880 acceptance: None,
2881 notes: Some(note),
2882 design: None,
2883 status: None,
2884 priority: None,
2885 assignee: None,
2886 add_label: None,
2887 remove_label: None,
2888 decisions: Vec::new(),
2889 resolve_decisions: Vec::new(),
2890 };
2891 match mana_core::api::update_unit(&mana_dir, id, update_params) {
2892 Ok(result) => {
2893 let unit_value =
2894 serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2895 let summary = unit_delta_label(&unit_value)
2896 .map(|label| format!("mana delta: notes appended on {label}"))
2897 .unwrap_or_else(|| format!("mana delta: notes appended on {id}"));
2898 set_mana_delta_widget(&ctx, summary.clone(), Some("notes appended".into()))
2899 .await;
2900 Ok(text_output(
2901 summary,
2902 json!({
2903 "action": "notes_append",
2904 "id": id,
2905 "notes": params["notes"],
2906 "unit": unit_value,
2907 "path": result.path,
2908 }),
2909 ))
2910 }
2911 Err(e) => Ok(ToolOutput::error(e.to_string())),
2912 }
2913 }
2914 "decision_add" => {
2915 let id = params["id"].as_str().ok_or_else(|| {
2916 crate::error::Error::Tool("decision_add requires 'id'".into())
2917 })?;
2918 let decision = parse_optional_string(¶ms["description"])
2919 .or_else(|| {
2920 parse_csv_strings(¶ms["decisions"], "decisions")
2921 .ok()
2922 .and_then(|mut decisions| decisions.drain(..).next())
2923 })
2924 .or_else(|| parse_optional_string(¶ms["notes"]))
2925 .ok_or_else(|| {
2926 crate::error::Error::Tool(
2927 "decision_add requires 'description' or 'decisions'".into(),
2928 )
2929 })?;
2930 let update_params = mana_core::ops::update::UpdateParams {
2931 title: None,
2932 description: None,
2933 acceptance: None,
2934 notes: None,
2935 design: None,
2936 status: None,
2937 priority: None,
2938 assignee: None,
2939 add_label: None,
2940 remove_label: None,
2941 decisions: vec![decision],
2942 resolve_decisions: Vec::new(),
2943 };
2944 match mana_core::api::update_unit(&mana_dir, id, update_params) {
2945 Ok(result) => {
2946 let unit_value =
2947 serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2948 let summary = unit_delta_label(&unit_value)
2949 .map(|label| format!("mana delta: decision added on {label}"))
2950 .unwrap_or_else(|| format!("mana delta: decision added on {id}"));
2951 set_mana_delta_widget(&ctx, summary.clone(), Some("decision added".into()))
2952 .await;
2953 Ok(text_output(
2954 summary,
2955 json!({
2956 "action": "decision_add",
2957 "id": id,
2958 "description": params["description"],
2959 "unit": unit_value,
2960 "path": result.path,
2961 }),
2962 ))
2963 }
2964 Err(e) => Ok(ToolOutput::error(e.to_string())),
2965 }
2966 }
2967 "decision_resolve" => {
2968 let id = params["id"].as_str().ok_or_else(|| {
2969 crate::error::Error::Tool("decision_resolve requires 'id'".into())
2970 })?;
2971 let resolve_decisions =
2972 parse_csv_strings(¶ms["resolve_decisions"], "resolve_decisions")?;
2973 if resolve_decisions.is_empty() {
2974 return Ok(ToolOutput::error(
2975 "decision_resolve requires 'resolve_decisions'",
2976 ));
2977 }
2978 let update_params = mana_core::ops::update::UpdateParams {
2979 title: None,
2980 description: None,
2981 acceptance: None,
2982 notes: None,
2983 design: None,
2984 status: None,
2985 priority: None,
2986 assignee: None,
2987 add_label: None,
2988 remove_label: None,
2989 decisions: Vec::new(),
2990 resolve_decisions,
2991 };
2992 match mana_core::api::update_unit(&mana_dir, id, update_params) {
2993 Ok(result) => {
2994 let unit_value =
2995 serde_json::to_value(&result.unit).unwrap_or(serde_json::Value::Null);
2996 let summary = unit_delta_label(&unit_value)
2997 .map(|label| format!("mana delta: decision resolved on {label}"))
2998 .unwrap_or_else(|| format!("mana delta: decision resolved on {id}"));
2999 set_mana_delta_widget(
3000 &ctx,
3001 summary.clone(),
3002 Some("decision resolved".into()),
3003 )
3004 .await;
3005 Ok(text_output(
3006 summary,
3007 json!({
3008 "action": "decision_resolve",
3009 "id": id,
3010 "resolve_decisions": params["resolve_decisions"],
3011 "unit": unit_value,
3012 "path": result.path,
3013 }),
3014 ))
3015 }
3016 Err(e) => Ok(ToolOutput::error(e.to_string())),
3017 }
3018 }
3019 "reopen" => {
3020 let id = params["id"]
3021 .as_str()
3022 .ok_or_else(|| crate::error::Error::Tool("reopen requires 'id'".into()))?;
3023 match mana_core::api::reopen_unit(&mana_dir, id) {
3024 Ok(result) => {
3025 let summary = format!(
3026 "mana delta: reopened {} ({})",
3027 result.unit.id, result.unit.title
3028 );
3029 set_mana_delta_widget(&ctx, summary, Some("status=open".into())).await;
3030 Ok(text_output(
3031 format!("Reopened unit {} ({})", result.unit.id, result.unit.title),
3032 json!({
3033 "action": "reopen",
3034 "unit": {
3035 "id": result.unit.id,
3036 "title": result.unit.title,
3037 "status": result.unit.status,
3038 },
3039 "path": result.path,
3040 }),
3041 ))
3042 }
3043 Err(e) => Ok(ToolOutput::error(e.to_string())),
3044 }
3045 }
3046 "verify" => {
3047 let id = params["id"]
3048 .as_str()
3049 .ok_or_else(|| crate::error::Error::Tool("verify requires 'id'".into()))?;
3050 match mana_core::api::run_verify(&mana_dir, id) {
3051 Ok(Some(result)) => Ok(text_output(
3052 format!(
3053 "Verify {} for unit {id}{}",
3054 if result.passed { "passed" } else { "failed" },
3055 result
3056 .exit_code
3057 .map(|code| format!(" (exit {code})"))
3058 .unwrap_or_default()
3059 ),
3060 json!({
3061 "passed": result.passed,
3062 "exit_code": result.exit_code,
3063 "stdout": result.stdout,
3064 "stderr": result.stderr,
3065 "timed_out": result.timed_out,
3066 "command": result.command,
3067 "timeout_secs": result.timeout_secs,
3068 "unit_id": id,
3069 }),
3070 )),
3071 Ok(None) => Ok(ToolOutput::text(format!(
3072 "Unit {id} has no verify command."
3073 ))),
3074 Err(e) => Ok(ToolOutput::error(e.to_string())),
3075 }
3076 }
3077 "fail" => {
3078 let id = params["id"]
3079 .as_str()
3080 .ok_or_else(|| crate::error::Error::Tool("fail requires 'id'".into()))?;
3081 match mana_core::api::fail_unit(
3082 &mana_dir,
3083 id,
3084 parse_optional_string(¶ms["reason"]),
3085 ) {
3086 Ok(unit) => {
3087 let unit_value =
3088 serde_json::to_value(&unit).unwrap_or(serde_json::Value::Null);
3089 let summary = unit_delta_label(&unit_value)
3090 .map(|label| format!("mana delta: marked failed {label}"))
3091 .unwrap_or_else(|| format!("mana delta: marked failed {id}"));
3092 set_mana_delta_widget(
3093 &ctx,
3094 summary,
3095 params["reason"].as_str().map(|s| s.to_string()),
3096 )
3097 .await;
3098 Ok(text_output(
3099 format!("Marked unit {id} as failed"),
3100 json!({
3101 "action": "fail",
3102 "id": id,
3103 "reason": params["reason"],
3104 "unit": unit_value,
3105 }),
3106 ))
3107 }
3108 Err(e) => Ok(ToolOutput::error(e.to_string())),
3109 }
3110 }
3111 "delete" => {
3112 let id = params["id"]
3113 .as_str()
3114 .ok_or_else(|| crate::error::Error::Tool("delete requires 'id'".into()))?;
3115 match mana_core::api::delete_unit(&mana_dir, id) {
3116 Ok(result) => {
3117 let summary =
3118 format!("mana delta: deleted {} ({})", result.id, result.title);
3119 set_mana_delta_widget(&ctx, summary.clone(), None).await;
3120 Ok(text_output(
3121 format!("Deleted unit {} ({})", result.id, result.title),
3122 json!({ "action": "delete", "id": result.id, "title": result.title }),
3123 ))
3124 }
3125 Err(e) => Ok(ToolOutput::error(e.to_string())),
3126 }
3127 }
3128 "dep_add" => {
3129 let from_id = params["from_id"].as_str().ok_or_else(|| {
3130 crate::error::Error::Tool("dep_add requires 'from_id'".into())
3131 })?;
3132 let dep_id = params["dep_id"]
3133 .as_str()
3134 .ok_or_else(|| crate::error::Error::Tool("dep_add requires 'dep_id'".into()))?;
3135 match mana_core::api::add_dep(&mana_dir, from_id, dep_id) {
3136 Ok(result) => {
3137 let summary = format!(
3138 "mana delta: dependency added {} -> {}",
3139 result.from_id, result.to_id
3140 );
3141 set_mana_delta_widget(&ctx, summary.clone(), None).await;
3142 Ok(text_output(
3143 format!(
3144 "Added dependency: {} depends on {}",
3145 result.from_id, result.to_id
3146 ),
3147 json!({ "action": "dep_add", "from_id": result.from_id, "dep_id": result.to_id }),
3148 ))
3149 }
3150 Err(e) => Ok(ToolOutput::error(e.to_string())),
3151 }
3152 }
3153 "dep_remove" => {
3154 let from_id = params["from_id"].as_str().ok_or_else(|| {
3155 crate::error::Error::Tool("dep_remove requires 'from_id'".into())
3156 })?;
3157 let dep_id = params["dep_id"].as_str().ok_or_else(|| {
3158 crate::error::Error::Tool("dep_remove requires 'dep_id'".into())
3159 })?;
3160 match mana_core::api::remove_dep(&mana_dir, from_id, dep_id) {
3161 Ok(result) => {
3162 let summary = format!(
3163 "mana delta: dependency removed {} -> {}",
3164 result.from_id, result.to_id
3165 );
3166 set_mana_delta_widget(&ctx, summary.clone(), None).await;
3167 Ok(text_output(
3168 format!(
3169 "Removed dependency: {} no longer depends on {}",
3170 result.from_id, result.to_id
3171 ),
3172 json!({ "action": "dep_remove", "from_id": result.from_id, "dep_id": result.to_id }),
3173 ))
3174 }
3175 Err(e) => Ok(ToolOutput::error(e.to_string())),
3176 }
3177 }
3178 "fact_create" => {
3179 let title = parse_optional_string(¶ms["title"])
3180 .or_else(|| parse_optional_string(¶ms["fact_title"]))
3181 .ok_or_else(|| {
3182 crate::error::Error::Tool("fact_create requires 'title'".into())
3183 })?;
3184 let verify = parse_optional_string(¶ms["verify"]).ok_or_else(|| {
3185 crate::error::Error::Tool("fact_create requires 'verify'".into())
3186 })?;
3187 let fact_paths = parse_optional_string(¶ms["paths_csv"]).or_else(|| {
3190 let paths = parse_csv_strings(¶ms["paths"], "paths").ok()?;
3191 if paths.is_empty() {
3192 None
3193 } else {
3194 Some(paths.join(","))
3195 }
3196 });
3197 let fact_params = mana_core::ops::fact::FactParams {
3198 title,
3199 verify,
3200 description: parse_optional_string(¶ms["description"]),
3201 paths: fact_paths,
3202 ttl_days: params["ttl_days"].as_i64(),
3203 pass_ok: params["pass_ok"].as_bool().unwrap_or(true),
3204 };
3205 match mana_core::api::create_fact(&mana_dir, fact_params) {
3206 Ok(result) => {
3207 let summary = format!(
3208 "mana delta: created fact {} ({})",
3209 result.unit_id, result.unit.title
3210 );
3211 set_mana_delta_widget(&ctx, summary.clone(), Some("fact".into())).await;
3212 Ok(text_output(
3213 format!("Created fact {} ({})", result.unit_id, result.unit.title),
3214 json!({
3215 "action": "fact_create",
3216 "unit_id": result.unit_id,
3217 "unit": {
3218 "id": result.unit.id,
3219 "title": result.unit.title,
3220 "unit_type": result.unit.unit_type,
3221 "verify": result.unit.verify,
3222 "paths": result.unit.paths,
3223 "stale_after": result.unit.stale_after,
3224 }
3225 }),
3226 ))
3227 }
3228 Err(e) => Ok(ToolOutput::error(e.to_string())),
3229 }
3230 }
3231 "fact_verify" => match mana_core::api::verify_facts(&mana_dir) {
3232 Ok(result) => Ok(text_output(
3233 format!(
3234 "Verified {}/{} facts · {} stale · {} failing · {} suspect",
3235 result.verified_count,
3236 result.total_facts,
3237 result.stale_count,
3238 result.failing_count,
3239 result.suspect_count
3240 ),
3241 json!({
3242 "total_facts": result.total_facts,
3243 "verified_count": result.verified_count,
3244 "stale_count": result.stale_count,
3245 "failing_count": result.failing_count,
3246 "suspect_count": result.suspect_count,
3247 }),
3248 )),
3249 Err(e) => Ok(ToolOutput::error(e.to_string())),
3250 },
3251 "logs" => {
3252 if let Some(run_id) = params["run_id"].as_str() {
3253 if let Some(state) = run_state_snapshot(&self.run_store, Some(run_id)) {
3254 let text = if state.log_lines.is_empty() {
3255 format!(
3256 "No native stream events captured yet for run {}.",
3257 state.run_id
3258 )
3259 } else {
3260 truncate_with_note(&state.log_lines.join("\n"))
3261 };
3262 return Ok(text_output(
3263 text,
3264 serde_json::to_value(&state).unwrap_or(serde_json::Value::Null),
3265 ));
3266 }
3267 return Ok(ToolOutput::error(format!(
3268 "Unknown native mana run_id: {run_id}"
3269 )));
3270 }
3271
3272 let id = params["id"].as_str().ok_or_else(|| {
3273 crate::error::Error::Tool("logs requires 'id' or 'run_id'".into())
3274 })?;
3275 match find_all_logs(id) {
3276 Ok(paths) if paths.is_empty() => Ok(ToolOutput::text(format!(
3277 "No logs for unit {id}. Has it been dispatched with mana run?"
3278 ))),
3279 Ok(paths) => {
3280 let mut sections = Vec::new();
3281 for path in &paths {
3282 let filename = path
3283 .file_name()
3284 .and_then(|n| n.to_str())
3285 .unwrap_or("unknown");
3286 let body = std::fs::read_to_string(path).unwrap_or_else(|e| {
3287 format!("(error reading {}: {e})", path.display())
3288 });
3289 sections.push(format!("═══ {filename} ═══\n\n{body}"));
3290 }
3291 let text = truncate_with_note(§ions.join("\n\n"));
3292 Ok(text_output(text, json!({ "unit_id": id, "logs": paths })))
3293 }
3294 Err(e) => Ok(ToolOutput::error(e.to_string())),
3295 }
3296 }
3297 "agents" => match load_agents() {
3298 Ok(agents) => Ok(json_output(&agents)),
3299 Err(e) => Ok(ToolOutput::error(e.to_string())),
3300 },
3301 "reparent" => {
3302 let id = params["id"]
3303 .as_str()
3304 .ok_or_else(|| crate::error::Error::Tool("reparent requires 'id'".into()))?;
3305 let parent = parse_optional_string(¶ms["parent"]);
3306 let reason = parse_optional_string(¶ms["reason"])
3307 .or_else(|| parse_optional_string(¶ms["parent_reason"]));
3308 let unit_path = mana_core::discovery::find_unit_file(&mana_dir, id)
3309 .map_err(|error| crate::error::Error::Tool(error.to_string()))?;
3310 let mut unit = mana_core::unit::Unit::from_file(&unit_path)
3311 .map_err(|error| crate::error::Error::Tool(error.to_string()))?;
3312 let old_parent = unit.parent.clone();
3313 unit.parent = parent.clone();
3314 unit.updated_at = chrono::Utc::now();
3315 unit.to_file(&unit_path)
3316 .map_err(|error| crate::error::Error::Tool(error.to_string()))?;
3317 let summary = format!(
3318 "mana delta: reparented {id} from {} to {}",
3319 old_parent.as_deref().unwrap_or("<root>"),
3320 parent.as_deref().unwrap_or("<root>")
3321 );
3322 set_mana_delta_widget(&ctx, summary.clone(), reason.clone()).await;
3323 Ok(text_output(
3324 summary,
3325 json!({
3326 "action": "reparent",
3327 "id": id,
3328 "old_parent": old_parent,
3329 "new_parent": parent,
3330 "reason": reason,
3331 }),
3332 ))
3333 }
3334 "run_state" | "evaluate" => {
3335 let run_id = params["run_id"].as_str();
3336 match run_state_snapshot(&self.run_store, run_id) {
3337 Some(state) => {
3338 if action == "evaluate" {
3339 Ok(evaluate_run_output(&state))
3340 } else {
3341 Ok(run_state_output(&state))
3342 }
3343 }
3344 None => {
3345 let which = run_id.unwrap_or("latest");
3346 Ok(ToolOutput::error(format!(
3347 "No native mana run state available for {which}. Start one with mana(action=\"run\")."
3348 )))
3349 }
3350 }
3351 }
3352 "next" => {
3353 let count = params["count"].as_u64().unwrap_or(1).max(1) as usize;
3354 match mana_core::api::load_index(&mana_dir) {
3355 Ok(index) => {
3356 let ready: Vec<&mana_core::index::IndexEntry> = index
3357 .units
3358 .iter()
3359 .filter(|e| {
3360 e.status == mana_core::unit::Status::Open
3361 && e.has_verify
3362 && !e.feature
3363 && mana_core::blocking::check_blocked(e, &index).is_none()
3364 && !index.units.iter().any(|child| {
3365 child.parent.as_deref() == Some(e.id.as_str())
3366 && child.status != mana_core::unit::Status::Closed
3367 })
3368 })
3369 .collect();
3370
3371 let mut reverse_deps: std::collections::HashMap<String, Vec<String>> =
3372 std::collections::HashMap::new();
3373 for entry in &index.units {
3374 for dep_id in &entry.dependencies {
3375 reverse_deps
3376 .entry(dep_id.clone())
3377 .or_default()
3378 .push(entry.id.clone());
3379 }
3380 }
3381
3382 fn count_transitive_unblocks(
3383 unit_id: &str,
3384 reverse_deps: &std::collections::HashMap<String, Vec<String>>,
3385 ) -> usize {
3386 let mut visited = std::collections::HashSet::new();
3387 let mut stack = vec![unit_id.to_string()];
3388 while let Some(current) = stack.pop() {
3389 if let Some(dependents) = reverse_deps.get(¤t) {
3390 for dep in dependents {
3391 if visited.insert(dep.clone()) {
3392 stack.push(dep.clone());
3393 }
3394 }
3395 }
3396 }
3397 visited.len()
3398 }
3399
3400 fn score_unit(
3401 entry: &mana_core::index::IndexEntry,
3402 unblock_count: usize,
3403 ) -> f64 {
3404 let priority_score = (5u8.saturating_sub(entry.priority)) as f64 * 10.0;
3405 let unblock_score = (unblock_count as f64 * 5.0).min(50.0);
3406 let age_days = std::time::SystemTime::now()
3407 .duration_since(std::time::UNIX_EPOCH)
3408 .unwrap_or_default()
3409 .as_secs()
3410 / 86_400;
3411 let created_days = entry.created_at.timestamp().max(0) as u64 / 86_400;
3412 let age_days = age_days.saturating_sub(created_days) as f64;
3413 let age_score = age_days.min(30.0);
3414 let attempt_penalty = (entry.attempts as f64 * 3.0).min(15.0);
3415 priority_score + unblock_score + age_score - attempt_penalty
3416 }
3417
3418 let mut scored: Vec<ScoredUnit> = ready
3419 .iter()
3420 .map(|entry| {
3421 let transitive_count =
3422 count_transitive_unblocks(&entry.id, &reverse_deps);
3423 let unblocks =
3424 reverse_deps.get(&entry.id).cloned().unwrap_or_default();
3425 let score = score_unit(entry, transitive_count);
3426 let now_days = std::time::SystemTime::now()
3427 .duration_since(std::time::UNIX_EPOCH)
3428 .unwrap_or_default()
3429 .as_secs()
3430 / 86_400;
3431 let created_days =
3432 entry.created_at.timestamp().max(0) as u64 / 86_400;
3433 let age_days = now_days.saturating_sub(created_days);
3434 ScoredUnit {
3435 id: entry.id.clone(),
3436 title: entry.title.clone(),
3437 priority: entry.priority,
3438 score,
3439 unblocks,
3440 age_days,
3441 attempts: entry.attempts,
3442 }
3443 })
3444 .collect();
3445
3446 scored.sort_by(|a, b| {
3447 b.score
3448 .partial_cmp(&a.score)
3449 .unwrap_or(std::cmp::Ordering::Equal)
3450 });
3451 scored.truncate(count);
3452 Ok(text_output(
3453 scored_units_to_text(&scored),
3454 serde_json::to_value(&scored).unwrap_or(serde_json::Value::Null),
3455 ))
3456 }
3457 Err(e) => Ok(ToolOutput::error(e.to_string())),
3458 }
3459 }
3460 "tree" => {
3461 let id = params["id"].as_str();
3462 let lines = if let Some(root_id) = id {
3463 match mana_core::api::get_tree(&mana_dir, root_id) {
3464 Ok(tree) => {
3465 let mut lines = Vec::new();
3466 tree_lines(&tree, 0, &mut lines);
3467 lines
3468 }
3469 Err(tree_err) => match mana_core::ops::show::get(&mana_dir, root_id) {
3470 Ok(result) if result.unit.is_archived => {
3471 return Ok(ToolOutput::error(format!(
3472 "Archived unit {root_id} can be shown but not rendered in tree view. Tree only includes active units."
3473 )));
3474 }
3475 Ok(_) | Err(_) => return Ok(ToolOutput::error(tree_err.to_string())),
3476 },
3477 }
3478 } else {
3479 match mana_core::api::load_index(&mana_dir) {
3480 Ok(index) => {
3481 let roots: Vec<_> = index
3482 .units
3483 .iter()
3484 .filter(|entry| entry.parent.is_none())
3485 .map(|entry| entry.id.clone())
3486 .collect();
3487 let mut lines = Vec::new();
3488 for (idx, root_id) in roots.iter().enumerate() {
3489 match mana_core::api::get_tree(&mana_dir, root_id) {
3490 Ok(tree) => {
3491 if idx > 0 {
3492 lines.push(String::new());
3493 }
3494 tree_lines(&tree, 0, &mut lines);
3495 }
3496 Err(e) => return Ok(ToolOutput::error(e.to_string())),
3497 }
3498 }
3499 lines
3500 }
3501 Err(e) => return Ok(ToolOutput::error(e.to_string())),
3502 }
3503 };
3504 let text = if lines.is_empty() {
3505 "(no units)".to_string()
3506 } else {
3507 truncate_with_note(&lines.join("\n"))
3508 };
3509 Ok(text_output(text, json!({ "root": id })))
3510 }
3511 "run" => {
3512 let requested_jobs = params["jobs"].as_u64().unwrap_or(4) as u32;
3513 let run_params = NativeRunParams {
3514 target: target_from_params(¶ms)?,
3515 jobs: requested_jobs.max(1),
3516 dry_run: params["dry_run"].as_bool().unwrap_or(false),
3517 loop_mode: params["loop"].as_bool().unwrap_or(false),
3518 keep_going: params["keep_going"].as_bool().unwrap_or(false),
3519 timeout: params["timeout"].as_u64().unwrap_or(30) as u32,
3520 idle_timeout: params["idle_timeout"].as_u64().unwrap_or(5) as u32,
3521 json_stream: true,
3522 review: params["review"].as_bool().unwrap_or(false),
3523 run_model: None,
3524 run_thinking: None,
3525 };
3526 let target_ids = target_ids_from_run_target(&run_params.target);
3527 if !target_ids.is_empty() {
3528 if let Some(guardrail) = retry_guardrail_for_targets(&mana_dir, &target_ids)? {
3529 return Ok(ToolOutput {
3530 content: vec![imp_llm::ContentBlock::Text {
3531 text: "mana run blocked: failed units must be updated before retry"
3532 .to_string(),
3533 }],
3534 details: guardrail,
3535 is_error: true,
3536 });
3537 }
3538 }
3539 let scope = scope_from_target(&run_params.target);
3540 let run_id = {
3541 let mut store = self.run_store.lock().map_err(|_| {
3542 crate::error::Error::Tool("mana run state lock poisoned".into())
3543 })?;
3544 let run_id = store.start_run(scope.clone(), &run_params);
3545 store.persist();
3546 run_id
3547 };
3548
3549 let started = run_started_output(&scope, &run_id, &run_params);
3550 spawn_background_run(
3551 mana_dir.clone(),
3552 run_params,
3553 ctx,
3554 self.run_store.clone(),
3555 run_id,
3556 );
3557 return Ok(started);
3558 }
3559 other => Ok(ToolOutput::error(format!(
3560 "Unknown action: {other}. Use: status, list, show, create, close, update, run, run_state, evaluate, claim, release, logs, agents, next, tree, reopen, verify, fail, delete, dep_add, dep_remove, fact_create, fact_verify, notes_append, decision_add, decision_resolve"
3561 ))),
3562 }
3563 }
3564}
3565
3566#[cfg(test)]
3567mod tests {
3568 use std::sync::Arc;
3569
3570 use async_trait::async_trait;
3571 use serde_json::json;
3572 use tokio::sync::mpsc;
3573
3574 use super::{
3575 compact_list_output, evaluate_run_output, mana_close_error_output,
3576 mana_close_force_reason_error, mana_guide_output, mana_run_core, mana_template_output,
3577 parent_placement_details, parse_guide_topic, parse_template_kind,
3578 retry_guardrail_for_targets, run_state_output, runtime_info_for_run, stream_event_line,
3579 target_ids_from_run_target, unix_time_ms, validate_mana_action, validate_native_run_target,
3580 worker_options_for_native_unit, GuideTopic, ManaRunStore, ManaTool, NativeRunState,
3581 RunTarget, RunUnitStatus, TemplateKind, INTERRUPTED_RUN_STALE_MS,
3582 };
3583 use crate::tools::{FileCache, FileTracker, Tool, ToolContext, ToolUpdate};
3584 use crate::ui::{NotifyLevel, NullInterface, WidgetContent};
3585
3586 enum ManaResult {
3587 ModeBlocked(String),
3588 Attempted(crate::tools::ToolOutput),
3589 }
3590
3591 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3592
3593 type TestWidgets = Arc<std::sync::Mutex<Vec<(String, Option<WidgetContent>)>>>;
3594
3595 struct TestUi {
3596 widgets: TestWidgets,
3597 }
3598
3599 #[async_trait]
3600 impl crate::ui::UserInterface for TestUi {
3601 fn has_ui(&self) -> bool {
3602 true
3603 }
3604
3605 async fn notify(&self, _message: &str, _level: NotifyLevel) {}
3606
3607 async fn confirm(&self, _title: &str, _message: &str) -> Option<bool> {
3608 None
3609 }
3610
3611 async fn select_with_context(
3612 &self,
3613 _title: &str,
3614 _context: &str,
3615 _options: &[crate::ui::SelectOption],
3616 ) -> Option<usize> {
3617 None
3618 }
3619
3620 async fn input_with_context(
3621 &self,
3622 _title: &str,
3623 _context: &str,
3624 _placeholder: &str,
3625 ) -> Option<String> {
3626 None
3627 }
3628
3629 async fn set_status(&self, _key: &str, _text: Option<&str>) {}
3630
3631 async fn set_widget(&self, key: &str, content: Option<WidgetContent>) {
3632 self.widgets
3633 .lock()
3634 .unwrap()
3635 .push((key.to_string(), content));
3636 }
3637
3638 async fn custom(&self, _component: crate::ui::ComponentSpec) -> Option<serde_json::Value> {
3639 None
3640 }
3641 }
3642
3643 async fn run_with_mode(mode_name: &str, action: &str) -> ManaResult {
3644 let prev = {
3645 let _guard = ENV_LOCK.lock().unwrap();
3646 let prev = std::env::var("IMP_MODE").ok();
3647 std::env::set_var("IMP_MODE", mode_name);
3648 prev
3649 };
3650
3651 let dir = tempfile::tempdir().unwrap();
3652 let mana_dir = dir.path().join(".mana");
3653 std::fs::create_dir_all(&mana_dir).unwrap();
3654 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3655 std::fs::write(
3656 mana_dir.join("1-test-unit.md"),
3657 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3658 )
3659 .unwrap();
3660 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3661 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3662 let ctx = ToolContext {
3663 cwd: dir.path().to_path_buf(),
3664 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3665 update_tx: tx,
3666 command_tx: cmd_tx,
3667 ui: Arc::new(NullInterface),
3668 file_cache: Arc::new(FileCache::new()),
3669 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3670 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3671 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3672 lua_tool_loader: None,
3673 mode: crate::config::AgentMode::from_name(mode_name)
3674 .unwrap_or(crate::config::AgentMode::Full),
3675 read_max_lines: 500,
3676 turn_mana_review: Arc::new(std::sync::Mutex::new(
3677 crate::mana_review::TurnManaReviewAccumulator::default(),
3678 )),
3679 config: Arc::new(crate::config::Config::default()),
3680 run_policy: Default::default(),
3681 supporting_provenance: Vec::new(),
3682 };
3683
3684 let tool = ManaTool::default();
3685 let outcome = tool
3686 .execute("call_1", json!({ "action": action, "id": "1" }), ctx)
3687 .await;
3688
3689 match prev {
3690 Some(v) => {
3691 let _guard = ENV_LOCK.lock().unwrap();
3692 std::env::set_var("IMP_MODE", v)
3693 }
3694 None => {
3695 let _guard = ENV_LOCK.lock().unwrap();
3696 std::env::remove_var("IMP_MODE")
3697 }
3698 }
3699
3700 match outcome {
3701 Err(crate::error::Error::Tool(msg)) => {
3702 ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
3703 }
3704 Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
3705 Ok(output) => {
3706 if output.is_error {
3707 if let Some(text) = output.text_content() {
3708 if text.contains("mode") && text.contains(action) {
3709 return ManaResult::ModeBlocked(text.to_string());
3710 }
3711 }
3712 }
3713 ManaResult::Attempted(output)
3714 }
3715 }
3716 }
3717
3718 fn ctx_with_mode(
3719 dir: &std::path::Path,
3720 mode: crate::config::AgentMode,
3721 ) -> (ToolContext, tempfile::TempDir) {
3722 let mana_dir = dir.join(".mana");
3723 std::fs::create_dir_all(&mana_dir).unwrap();
3724 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3725 std::fs::write(
3726 mana_dir.join("1-test-unit.md"),
3727 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3728 )
3729 .unwrap();
3730 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3731 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3732 let ctx = ToolContext {
3733 cwd: dir.to_path_buf(),
3734 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3735 update_tx: tx,
3736 command_tx: cmd_tx,
3737 ui: Arc::new(NullInterface),
3738 file_cache: Arc::new(FileCache::new()),
3739 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3740 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3741 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3742 lua_tool_loader: None,
3743 mode,
3744 read_max_lines: 500,
3745 turn_mana_review: Arc::new(std::sync::Mutex::new(
3746 crate::mana_review::TurnManaReviewAccumulator::default(),
3747 )),
3748 config: Arc::new(crate::config::Config::default()),
3749 run_policy: Default::default(),
3750 supporting_provenance: Vec::new(),
3751 };
3752 (ctx, tempfile::tempdir().unwrap())
3753 }
3754
3755 fn ctx_with_ui(
3756 dir: &std::path::Path,
3757 mode: crate::config::AgentMode,
3758 ) -> (ToolContext, tempfile::TempDir, TestWidgets) {
3759 let mana_dir = dir.join(".mana");
3760 std::fs::create_dir_all(&mana_dir).unwrap();
3761 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3762 std::fs::write(
3763 mana_dir.join("1-test-unit.md"),
3764 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3765 )
3766 .unwrap();
3767 let widgets = Arc::new(std::sync::Mutex::new(Vec::new()));
3768 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3769 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3770 let ctx = ToolContext {
3771 cwd: dir.to_path_buf(),
3772 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3773 update_tx: tx,
3774 command_tx: cmd_tx,
3775 ui: Arc::new(TestUi {
3776 widgets: widgets.clone(),
3777 }),
3778 file_cache: Arc::new(FileCache::new()),
3779 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3780 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3781 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3782 lua_tool_loader: None,
3783 mode,
3784 read_max_lines: 500,
3785 turn_mana_review: Arc::new(std::sync::Mutex::new(
3786 crate::mana_review::TurnManaReviewAccumulator::default(),
3787 )),
3788 config: Arc::new(crate::config::Config::default()),
3789 run_policy: Default::default(),
3790 supporting_provenance: Vec::new(),
3791 };
3792 (ctx, tempfile::tempdir().unwrap(), widgets)
3793 }
3794
3795 async fn run_with_ctx_mode(mode: crate::config::AgentMode, action: &str) -> ManaResult {
3796 let dir = tempfile::tempdir().unwrap();
3797 let (ctx, _keep) = ctx_with_mode(dir.path(), mode);
3798 let tool = ManaTool::default();
3799 let outcome = tool
3800 .execute("call_ctx", json!({ "action": action, "id": "1" }), ctx)
3801 .await;
3802 match outcome {
3803 Err(crate::error::Error::Tool(msg)) => {
3804 ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
3805 }
3806 Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
3807 Ok(output) => {
3808 if output.is_error {
3809 if let Some(text) = output.text_content() {
3810 if text.contains("mode") && text.contains(action) {
3811 return ManaResult::ModeBlocked(text.to_string());
3812 }
3813 }
3814 }
3815 ManaResult::Attempted(output)
3816 }
3817 }
3818 }
3819
3820 #[tokio::test]
3821 async fn create_sets_mana_delta_widget_and_action_details() {
3822 let dir = tempfile::tempdir().unwrap();
3823 let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
3824 let tool = ManaTool::default();
3825 let result = tool
3826 .execute(
3827 "call_create_widget",
3828 json!({ "action": "create", "title": "Widget unit", "verify": "test -n ok" }),
3829 ctx,
3830 )
3831 .await
3832 .unwrap();
3833
3834 assert_eq!(result.details["action"], "create");
3835 assert_eq!(result.details["unit"]["title"], "Widget unit");
3836 let widgets = widgets.lock().unwrap();
3837 assert!(widgets.iter().any(|(key, content)| {
3838 key == "mana"
3839 && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: created 2 · Widget unit")))
3840 }));
3841 }
3842
3843 #[tokio::test]
3844 async fn decision_add_sets_mana_delta_widget_and_action_details() {
3845 let dir = tempfile::tempdir().unwrap();
3846 let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
3847 let tool = ManaTool::default();
3848 let result = tool
3849 .execute(
3850 "call_decision_widget",
3851 json!({ "action": "decision_add", "id": "1", "description": "Choose retry limit" }),
3852 ctx,
3853 )
3854 .await
3855 .unwrap();
3856
3857 assert_eq!(result.details["action"], "decision_add");
3858 assert_eq!(result.details["unit"]["decisions"][0], "Choose retry limit");
3859 let widgets = widgets.lock().unwrap();
3860 assert!(widgets.iter().any(|(key, content)| {
3861 key == "mana"
3862 && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: decision added on 1 · Test unit")))
3863 }));
3864 }
3865
3866 #[tokio::test]
3867 async fn worker_blocks_create() {
3868 match run_with_mode("worker", "create").await {
3869 ManaResult::ModeBlocked(_) => {}
3870 ManaResult::Attempted(out) => {
3871 panic!(
3872 "worker should block 'create', got: {:?}",
3873 out.text_content()
3874 )
3875 }
3876 }
3877 }
3878
3879 #[tokio::test]
3880 async fn create_supports_rich_unit_fields() {
3881 let dir = tempfile::tempdir().unwrap();
3882 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3883 let tool = ManaTool::default();
3884 let result = tool
3885 .execute(
3886 "call_create_rich",
3887 json!({
3888 "action": "create",
3889 "title": "Rich unit",
3890 "description": "Implement the thing",
3891 "acceptance": "- works\n- tested",
3892 "notes": "start here",
3893 "design": "follow existing pattern",
3894 "verify": "test -n ok",
3895 "labels": ["feature", "backend"],
3896 "deps": ["1"],
3897 "paths": ["src/lib.rs", "src/auth.rs"],
3898 "requires": ["auth-api"],
3899 "produces": ["auth-fix"],
3900 "decisions": ["Confirm whether auth should stay sync"],
3901 "feature": true,
3902 "fail_first": true,
3903 "verify_timeout": 12,
3904 "force": false
3905 }),
3906 ctx,
3907 )
3908 .await
3909 .unwrap();
3910 let unit = &result.details["unit"];
3911 assert_eq!(unit["acceptance"], "- works\n- tested");
3912 assert_eq!(unit["labels"][0], "feature");
3913 assert_eq!(unit["dependencies"][0], "1");
3914 assert_eq!(unit["paths"][0], "src/lib.rs");
3915 assert_eq!(unit["requires"][0], "auth-api");
3916 assert_eq!(unit["produces"][0], "auth-fix");
3917 assert_eq!(
3918 unit["decisions"][0],
3919 "Confirm whether auth should stay sync"
3920 );
3921 assert_eq!(unit["feature"], true);
3922 assert_eq!(unit["fail_first"], true);
3923 assert_eq!(unit["verify_timeout"], 12);
3924 }
3925
3926 #[test]
3927 fn parent_placement_details_warns_when_parent_reason_missing() {
3928 let details = parent_placement_details(Some("304"), None);
3929
3930 assert_eq!(details["parent"], json!("304"));
3931 assert_eq!(details["warning"], json!("parent_reason_missing"));
3932 assert!(details["hint"]
3933 .as_str()
3934 .unwrap_or_default()
3935 .contains("confirm it belongs"));
3936 }
3937
3938 #[test]
3939 fn parent_placement_details_accepts_explicit_reason() {
3940 let details = parent_placement_details(
3941 Some("313"),
3942 Some("This is workflow reliability work, not tool schema audit."),
3943 );
3944
3945 assert_eq!(details["warning"], serde_json::Value::Null);
3946 assert_eq!(
3947 details["parent_reason"],
3948 json!("This is workflow reliability work, not tool schema audit.")
3949 );
3950 }
3951
3952 #[test]
3953 fn close_force_requires_reason_with_evidence() {
3954 let output = mana_close_force_reason_error("313.2");
3955
3956 assert!(output.is_error);
3957 assert_eq!(output.details["action"], json!("close"));
3958 assert_eq!(output.details["missing"], json!(["reason"]));
3959 assert!(output
3960 .text_content()
3961 .unwrap_or_default()
3962 .contains("equivalent verify evidence"));
3963 }
3964
3965 #[test]
3966 fn close_verify_errors_include_recovery_hint() {
3967 let output = mana_close_error_output(
3968 "313.2",
3969 "Verify command failed during close: cargo test -p imp-core one two --no-run"
3970 .to_string(),
3971 );
3972
3973 assert!(output.is_error);
3974 assert_eq!(output.details["verify_related"], json!(true));
3975 assert_eq!(output.details["force_requires_reason"], json!(true));
3976 let hint = output.details["recovery_hint"].as_str().unwrap_or_default();
3977 assert!(hint.contains("equivalent focused checks"));
3978 }
3979
3980 #[test]
3981 fn close_non_verify_errors_stay_plain() {
3982 let output = mana_close_error_output("313.2", "Unit not found".to_string());
3983
3984 assert!(output.is_error);
3985 assert_eq!(output.details["verify_related"], json!(false));
3986 assert!(output.details["recovery_hint"].is_null());
3987 assert_eq!(output.text_content().unwrap_or_default(), "Unit not found");
3988 }
3989
3990 #[test]
3991 fn mana_guide_outputs_concise_structured_topic() {
3992 let output = mana_guide_output(GuideTopic::Verify);
3993
3994 assert!(!output.is_error);
3995 assert_eq!(output.details["action"], json!("guide"));
3996 assert_eq!(output.details["topic"], json!("verify"));
3997 assert!(output.details["guidance"].as_array().unwrap().len() <= 3);
3998 assert!(output
3999 .text_content()
4000 .unwrap_or_default()
4001 .contains("mana guide: verify"));
4002 }
4003
4004 #[test]
4005 fn mana_overview_guide_prefers_update_before_create() {
4006 let output = mana_guide_output(GuideTopic::Overview);
4007 let text = output.text_content().unwrap_or_default();
4008
4009 assert!(text.contains("Before creating"));
4010 assert!(text.contains("update"));
4011 assert!(text.contains("notes"));
4012 }
4013
4014 #[test]
4015 fn compact_list_output_caps_and_guides_toward_existing_units() {
4016 let now = chrono::Utc::now();
4017 let entries: Vec<mana_core::index::IndexEntry> = (0..60)
4018 .map(|i| mana_core::index::IndexEntry {
4019 id: format!("330.{i}"),
4020 title: format!("Task {i}"),
4021 handle: None,
4022 status: mana_core::unit::Status::Open,
4023 priority: 1,
4024 parent: Some("330".to_string()),
4025 dependencies: Vec::new(),
4026 labels: vec!["mana".to_string()],
4027 assignee: None,
4028 updated_at: now,
4029 produces: Vec::new(),
4030 requires: Vec::new(),
4031 has_verify: true,
4032 verify: Some("cargo test".to_string()),
4033 created_at: now,
4034 claimed_by: None,
4035 attempts: 0,
4036 paths: vec!["crates/imp-core/src/tools/mana.rs".to_string()],
4037 kind: mana_core::unit::UnitType::Task,
4038 feature: false,
4039 has_decisions: false,
4040 })
4041 .collect();
4042
4043 let output = compact_list_output(&entries, Some(100));
4044 let text = output.text_content().unwrap_or_default();
4045
4046 assert!(!output.is_error);
4047 assert_eq!(output.details["total"], json!(60));
4048 assert_eq!(output.details["shown"], json!(50));
4049 assert_eq!(output.details["limit"], json!(50));
4050 assert_eq!(output.details["truncated"], json!(true));
4051 assert_eq!(output.details["units"].as_array().unwrap().len(), 50);
4052 assert!(text.contains("Prefer `show` + `update`/`notes_append`"));
4053 assert!(text.contains("10 more omitted"));
4054 assert!(!text.contains("verify\":\"cargo test"));
4055 }
4056
4057 #[test]
4058 fn create_validation_hint_prefers_existing_unit_when_title_missing() {
4059 let output = validate_mana_action("create", &json!({}))
4060 .expect("create without title should fail validation");
4061
4062 assert!(output.is_error);
4063 assert!(output
4064 .text_content()
4065 .unwrap_or_default()
4066 .contains("Before creating, check list/show"));
4067 }
4068
4069 #[test]
4070 fn mana_template_outputs_task_template() {
4071 let output = mana_template_output(TemplateKind::Task, Some(GuideTopic::Verify));
4072
4073 assert!(!output.is_error);
4074 assert_eq!(output.details["action"], json!("template"));
4075 assert_eq!(output.details["kind"], json!("task"));
4076 assert_eq!(output.details["template"]["fail_first"], json!(true));
4077 assert!(output.details["template"]["verify"].is_string());
4078 }
4079
4080 #[test]
4081 fn mana_guide_and_template_validate_topic_and_kind() {
4082 assert!(parse_guide_topic(&json!({ "topic": "orchestrate" })).is_ok());
4083 assert!(parse_guide_topic(&json!({ "topic": "bad" }))
4084 .unwrap_err()
4085 .to_string()
4086 .contains("Invalid mana guide topic"));
4087 assert!(parse_template_kind(&json!({ "kind": "fact" })).is_ok());
4088 assert!(parse_template_kind(&json!({ "kind": "job" }))
4089 .unwrap_err()
4090 .to_string()
4091 .contains("Invalid mana template kind"));
4092 }
4093
4094 #[test]
4095 fn mana_schema_uses_canonical_fields_only() {
4096 let schema = ManaTool::default().parameters();
4097 let properties = schema["properties"].as_object().unwrap();
4098
4099 assert!(properties.contains_key("action"));
4100 assert!(properties.contains_key("id"));
4101 assert!(properties.contains_key("run_id"));
4102 assert!(properties.contains_key("title"));
4103 assert!(properties.contains_key("description"));
4104 assert!(properties.contains_key("acceptance"));
4105 assert!(properties.contains_key("verify"));
4106 assert!(properties.contains_key("notes"));
4107 assert!(properties.contains_key("decisions"));
4108 assert!(properties.contains_key("paths"));
4109 assert!(properties.contains_key("deps"));
4110 assert!(properties.contains_key("labels"));
4111 assert!(properties.contains_key("targets"));
4112 assert!(properties.contains_key("scope"));
4113 assert!(properties.contains_key("path"));
4114 assert!(properties.contains_key("parent_reason"));
4115 let actions = properties["action"]["enum"].as_array().unwrap();
4116 assert!(actions.iter().any(|value| value == "reparent"));
4117
4118 assert!(!properties.contains_key("mana_scope"));
4119 assert!(!properties.contains_key("mana_dir"));
4120 assert!(!properties.contains_key("paths_csv"));
4121 assert!(!properties.contains_key("fact_title"));
4122 assert!(!properties.contains_key("add_label"));
4123 assert!(!properties.contains_key("remove_label"));
4124
4125 let kind_enum = properties["kind"]["enum"].as_array().unwrap();
4126 assert!(kind_enum.iter().any(|value| value == "task"));
4127 assert!(!kind_enum.iter().any(|value| value == "job"));
4128 }
4129
4130 #[test]
4131 fn mana_validation_teaches_required_fields() {
4132 let output = validate_mana_action("notes_append", &json!({ "id": "304" }))
4133 .expect("notes_append without notes should fail validation");
4134
4135 assert!(output.is_error);
4136 assert_eq!(output.details["action"], json!("notes_append"));
4137 assert_eq!(output.details["missing"], json!(["notes"]));
4138 assert_eq!(output.details["canonical_fields"], json!(["id", "notes"]));
4139 assert!(output
4140 .text_content()
4141 .unwrap_or_default()
4142 .contains("requires id and notes"));
4143 }
4144
4145 #[test]
4146 fn mana_validation_rejects_path_when_paths_is_intended() {
4147 let output = validate_mana_action(
4148 "create",
4149 &json!({ "title": "Doc task", "path": "src/lib.rs" }),
4150 )
4151 .expect("create with path attachment should teach paths");
4152
4153 assert!(output.is_error);
4154 assert_eq!(output.details["invalid"], json!(["path"]));
4155 assert!(output
4156 .text_content()
4157 .unwrap_or_default()
4158 .contains("Use path for project/.mana location"));
4159 }
4160
4161 #[test]
4162 fn mana_validation_allows_valid_create_and_decision_add() {
4163 assert!(validate_mana_action("create", &json!({ "title": "Build thing" })).is_none());
4164 assert!(validate_mana_action(
4165 "decision_add",
4166 &json!({ "id": "304", "decisions": ["Use canonical names"] }),
4167 )
4168 .is_none());
4169 }
4170
4171 #[test]
4172 fn mana_validation_requires_parent_for_reparent() {
4173 let output = validate_mana_action("reparent", &json!({ "id": "313.2" }))
4174 .expect("reparent without parent should fail validation");
4175
4176 assert!(output.is_error);
4177 assert_eq!(output.details["missing"], json!(["parent"]));
4178 assert_eq!(
4179 output.details["canonical_fields"],
4180 json!(["id", "parent", "reason"])
4181 );
4182 }
4183
4184 #[tokio::test]
4185 async fn reparent_moves_child_between_parents() {
4186 let dir = tempfile::tempdir().unwrap();
4187 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4188 let tool = ManaTool::default();
4189
4190 let old_parent = tool
4191 .execute(
4192 "old_parent",
4193 json!({ "action": "create", "title": "Old Parent" }),
4194 ctx.clone(),
4195 )
4196 .await
4197 .unwrap();
4198 let old_parent_id = old_parent.details["unit"]["id"]
4199 .as_str()
4200 .unwrap()
4201 .to_string();
4202 let new_parent = tool
4203 .execute(
4204 "new_parent",
4205 json!({ "action": "create", "title": "New Parent" }),
4206 ctx.clone(),
4207 )
4208 .await
4209 .unwrap();
4210 let new_parent_id = new_parent.details["unit"]["id"]
4211 .as_str()
4212 .unwrap()
4213 .to_string();
4214 let child = tool
4215 .execute(
4216 "child",
4217 json!({ "action": "create", "title": "Child", "parent": old_parent_id, "parent_reason": "Initial grouping" }),
4218 ctx.clone(),
4219 )
4220 .await
4221 .unwrap();
4222 let child_id = child.details["unit"]["id"].as_str().unwrap().to_string();
4223
4224 let moved = tool
4225 .execute(
4226 "move_child",
4227 json!({
4228 "action": "reparent",
4229 "id": child_id,
4230 "parent": new_parent_id,
4231 "reason": "Belongs under the new reliability epic"
4232 }),
4233 ctx.clone(),
4234 )
4235 .await
4236 .unwrap();
4237
4238 assert!(!moved.is_error);
4239 assert_eq!(moved.details["action"], json!("reparent"));
4240 assert_eq!(moved.details["old_parent"], json!(old_parent_id));
4241 assert_eq!(moved.details["new_parent"], json!(new_parent_id));
4242 assert!(moved
4243 .text_content()
4244 .unwrap_or_default()
4245 .contains("reparented"));
4246 }
4247
4248 #[tokio::test]
4249 async fn update_supports_acceptance_labels_and_decisions() {
4250 let dir = tempfile::tempdir().unwrap();
4251 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4252 let tool = ManaTool::default();
4253 let _created = tool
4254 .execute(
4255 "call_create_update_target",
4256 json!({ "action": "create", "title": "Update target", "verify": "test -n ok" }),
4257 ctx,
4258 )
4259 .await
4260 .unwrap();
4261
4262 let dir2 = tempfile::tempdir().unwrap();
4263 let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
4264 std::fs::write(
4265 dir2.path().join(".mana").join("1-test-unit.md"),
4266 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
4267 ).unwrap();
4268 let result = tool
4269 .execute(
4270 "call_update_rich",
4271 json!({
4272 "action": "update",
4273 "id": "1",
4274 "acceptance": "must pass auth flow",
4275 "labels": ["backend"],
4276 "decisions": ["Choose retry limit"],
4277 "resolve_decisions": []
4278 }),
4279 ctx2,
4280 )
4281 .await
4282 .unwrap();
4283 let unit = &result.details["unit"];
4284 assert_eq!(unit["acceptance"], "must pass auth flow");
4285 assert_eq!(
4286 unit["labels"].as_array().cloned().unwrap_or_default(),
4287 vec![json!("backend")]
4288 );
4289 assert_eq!(unit["decisions"][0], "Choose retry limit");
4290 }
4291
4292 #[tokio::test]
4293 async fn create_respects_verify_lint_by_default() {
4294 let dir = tempfile::tempdir().unwrap();
4295 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4296 let tool = ManaTool::default();
4297 let result = tool
4298 .execute(
4299 "call_create_lint",
4300 json!({ "action": "create", "title": "Weak verify", "verify": "echo done" }),
4301 ctx,
4302 )
4303 .await
4304 .unwrap();
4305 assert!(result.is_error, "weak verify should be rejected by default");
4306 let text = result.text_content().unwrap_or("");
4307 assert!(text.contains("Verify command has lint errors") || text.contains("verify"));
4308 }
4309
4310 #[tokio::test]
4311 async fn native_verify_reopen_and_fact_actions_work() {
4312 let dir = tempfile::tempdir().unwrap();
4313 let mana_dir = dir.path().join(".mana");
4314 std::fs::create_dir_all(&mana_dir).unwrap();
4315 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4316 std::fs::write(
4317 mana_dir.join("1-test-unit.md"),
4318 "---\nid: '1'\ntitle: Test unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\n---\n\nbody\n",
4319 ).unwrap();
4320 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4321 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4322 let ctx = ToolContext {
4323 cwd: dir.path().to_path_buf(),
4324 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4325 update_tx: tx,
4326 command_tx: cmd_tx,
4327 ui: Arc::new(NullInterface),
4328 file_cache: Arc::new(FileCache::new()),
4329 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4330 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4331 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4332 lua_tool_loader: None,
4333 mode: crate::config::AgentMode::Full,
4334 read_max_lines: 500,
4335 turn_mana_review: Arc::new(std::sync::Mutex::new(
4336 crate::mana_review::TurnManaReviewAccumulator::default(),
4337 )),
4338 config: Arc::new(crate::config::Config::default()),
4339 run_policy: Default::default(),
4340 supporting_provenance: Vec::new(),
4341 };
4342 let tool = ManaTool::default();
4343 let reopened = tool
4344 .execute("call_reopen", json!({ "action": "reopen", "id": "1" }), ctx)
4345 .await
4346 .unwrap();
4347 assert_eq!(reopened.details["unit"]["status"], "open");
4348
4349 let dir2 = tempfile::tempdir().unwrap();
4350 let mana_dir2 = dir2.path().join(".mana");
4351 std::fs::create_dir_all(&mana_dir2).unwrap();
4352 std::fs::write(mana_dir2.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4353 std::fs::write(
4354 mana_dir2.join("1-test-unit.md"),
4355 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
4356 ).unwrap();
4357 let (tx2, _rx2) = mpsc::channel::<ToolUpdate>(1);
4358 let (cmd_tx2, _cmd_rx2) = mpsc::channel(16);
4359 let ctx2 = ToolContext {
4360 cwd: dir2.path().to_path_buf(),
4361 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4362 update_tx: tx2,
4363 command_tx: cmd_tx2,
4364 ui: Arc::new(NullInterface),
4365 file_cache: Arc::new(FileCache::new()),
4366 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4367 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4368 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4369 lua_tool_loader: None,
4370 mode: crate::config::AgentMode::Full,
4371 read_max_lines: 500,
4372 turn_mana_review: Arc::new(std::sync::Mutex::new(
4373 crate::mana_review::TurnManaReviewAccumulator::default(),
4374 )),
4375 config: Arc::new(crate::config::Config::default()),
4376 run_policy: Default::default(),
4377 supporting_provenance: Vec::new(),
4378 };
4379 let verify = tool
4380 .execute(
4381 "call_verify",
4382 json!({ "action": "verify", "id": "1" }),
4383 ctx2,
4384 )
4385 .await
4386 .unwrap();
4387 assert_eq!(verify.details["passed"], true);
4388
4389 let dir3 = tempfile::tempdir().unwrap();
4390 let mana_dir3 = dir3.path().join(".mana");
4391 std::fs::create_dir_all(&mana_dir3).unwrap();
4392 std::fs::write(mana_dir3.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
4393 let (tx3, _rx3) = mpsc::channel::<ToolUpdate>(1);
4394 let (cmd_tx3, _cmd_rx3) = mpsc::channel(16);
4395 let ctx3 = ToolContext {
4396 cwd: dir3.path().to_path_buf(),
4397 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4398 update_tx: tx3,
4399 command_tx: cmd_tx3,
4400 ui: Arc::new(NullInterface),
4401 file_cache: Arc::new(FileCache::new()),
4402 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4403 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4404 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4405 lua_tool_loader: None,
4406 mode: crate::config::AgentMode::Full,
4407 read_max_lines: 500,
4408 turn_mana_review: Arc::new(std::sync::Mutex::new(
4409 crate::mana_review::TurnManaReviewAccumulator::default(),
4410 )),
4411 config: Arc::new(crate::config::Config::default()),
4412 run_policy: Default::default(),
4413 supporting_provenance: Vec::new(),
4414 };
4415 let fact = tool.execute("call_fact", json!({ "action": "fact_create", "title": "Auth fact", "verify": "test -d .mana", "description": "fact body", "ttl_days": 7 }), ctx3).await.unwrap();
4416 assert_eq!(fact.details["unit"]["unit_type"], "fact");
4417 }
4418
4419 #[tokio::test]
4420 async fn notes_append_is_safe_partial_update() {
4421 let dir = tempfile::tempdir().unwrap();
4422 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4423 let tool = ManaTool::default();
4424 let result = tool
4425 .execute(
4426 "call_notes_append",
4427 json!({
4428 "action": "notes_append",
4429 "id": "1",
4430 "notes": "diagnosis from turn 2"
4431 }),
4432 ctx,
4433 )
4434 .await
4435 .unwrap();
4436 let unit = &result.details["unit"];
4437 assert_eq!(unit["title"], "Test unit");
4438 assert!(unit["notes"]
4439 .as_str()
4440 .unwrap_or("")
4441 .contains("diagnosis from turn 2"));
4442 }
4443
4444 #[tokio::test]
4445 async fn decision_add_and_resolve_work() {
4446 let dir = tempfile::tempdir().unwrap();
4447 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4448 let tool = ManaTool::default();
4449 let added = tool
4450 .execute(
4451 "call_decision_add",
4452 json!({
4453 "action": "decision_add",
4454 "id": "1",
4455 "description": "Choose retry limit"
4456 }),
4457 ctx,
4458 )
4459 .await
4460 .unwrap();
4461 assert_eq!(added.details["unit"]["decisions"][0], "Choose retry limit");
4462
4463 let dir2 = tempfile::tempdir().unwrap();
4464 let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
4465 std::fs::write(
4466 dir2.path().join(".mana").join("1-test-unit.md"),
4467 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\ndecisions:\n - Choose retry limit\n---\n\nbody\n",
4468 ).unwrap();
4469 let resolved = tool
4470 .execute(
4471 "call_decision_resolve",
4472 json!({
4473 "action": "decision_resolve",
4474 "id": "1",
4475 "resolve_decisions": ["Choose retry limit"]
4476 }),
4477 ctx2,
4478 )
4479 .await
4480 .unwrap();
4481 let decisions = resolved.details["unit"]["decisions"]
4482 .as_array()
4483 .cloned()
4484 .unwrap_or_default();
4485 assert!(decisions.is_empty());
4486 }
4487
4488 #[tokio::test]
4489 async fn show_returns_archived_unit_when_active_missing() {
4490 let dir = tempfile::tempdir().unwrap();
4491 let mana_dir = dir.path().join(".mana");
4492 std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
4493 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4494 std::fs::write(
4495 mana_dir.join("archive/2026/04/1-archived-unit.md"),
4496 "---\nid: '1'\ntitle: Archived unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\nis_archived: true\n---\n\nbody\n",
4497 )
4498 .unwrap();
4499 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4500 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4501 let ctx = ToolContext {
4502 cwd: dir.path().to_path_buf(),
4503 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4504 update_tx: tx,
4505 command_tx: cmd_tx,
4506 ui: Arc::new(NullInterface),
4507 file_cache: Arc::new(FileCache::new()),
4508 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4509 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4510 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4511 lua_tool_loader: None,
4512 mode: crate::config::AgentMode::Full,
4513 read_max_lines: 500,
4514 turn_mana_review: Arc::new(std::sync::Mutex::new(
4515 crate::mana_review::TurnManaReviewAccumulator::default(),
4516 )),
4517 config: Arc::new(crate::config::Config::default()),
4518 run_policy: Default::default(),
4519 supporting_provenance: Vec::new(),
4520 };
4521 let tool = ManaTool::default();
4522 let result = tool
4523 .execute(
4524 "call_show_archived",
4525 json!({ "action": "show", "id": "1" }),
4526 ctx,
4527 )
4528 .await
4529 .unwrap();
4530
4531 assert!(!result.is_error);
4532 assert_eq!(result.details["title"], "Archived unit");
4533 assert_eq!(result.details["is_archived"], true);
4534 }
4535
4536 #[tokio::test]
4537 async fn tree_reports_archived_root_as_active_only_limitation() {
4538 let dir = tempfile::tempdir().unwrap();
4539 let mana_dir = dir.path().join(".mana");
4540 std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
4541 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4542 std::fs::write(
4543 mana_dir.join("archive/2026/04/1-archived-unit.md"),
4544 "---\nid: '1'\ntitle: Archived unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\nis_archived: true\n---\n\nbody\n",
4545 )
4546 .unwrap();
4547 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4548 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4549 let ctx = ToolContext {
4550 cwd: dir.path().to_path_buf(),
4551 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4552 update_tx: tx,
4553 command_tx: cmd_tx,
4554 ui: Arc::new(NullInterface),
4555 file_cache: Arc::new(FileCache::new()),
4556 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4557 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4558 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4559 lua_tool_loader: None,
4560 mode: crate::config::AgentMode::Full,
4561 read_max_lines: 500,
4562 turn_mana_review: Arc::new(std::sync::Mutex::new(
4563 crate::mana_review::TurnManaReviewAccumulator::default(),
4564 )),
4565 config: Arc::new(crate::config::Config::default()),
4566 run_policy: Default::default(),
4567 supporting_provenance: Vec::new(),
4568 };
4569 let tool = ManaTool::default();
4570 let result = tool
4571 .execute(
4572 "call_tree_archived",
4573 json!({ "action": "tree", "id": "1" }),
4574 ctx,
4575 )
4576 .await
4577 .unwrap();
4578
4579 assert!(result.is_error);
4580 let text = result.text_content().unwrap_or("");
4581 assert!(text.contains("Archived unit 1 can be shown but not rendered in tree view"));
4582 }
4583
4584 #[tokio::test]
4585 async fn root_scope_targets_outermost_mana() {
4586 let tower = tempfile::tempdir().unwrap();
4587 let root_mana = tower.path().join(".mana");
4588 std::fs::create_dir_all(&root_mana).unwrap();
4589 std::fs::write(root_mana.join("config.yaml"), "project: root\nnext_id: 2\n").unwrap();
4590 std::fs::write(
4591 root_mana.join("1-root-unit.md"),
4592 "---\nid: '1'\ntitle: Root unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
4593 ).unwrap();
4594 let project = tower.path().join("imp");
4595 let project_mana = project.join(".mana");
4596 std::fs::create_dir_all(&project_mana).unwrap();
4597 std::fs::write(
4598 project_mana.join("config.yaml"),
4599 "project: nested\nnext_id: 2\n",
4600 )
4601 .unwrap();
4602 std::fs::write(
4603 project_mana.join("1-project-unit.md"),
4604 "---\nid: '1'\ntitle: Project unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
4605 ).unwrap();
4606 let workdir = project.join("src");
4607 std::fs::create_dir_all(&workdir).unwrap();
4608 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4609 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4610 let ctx = ToolContext {
4611 cwd: workdir,
4612 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4613 update_tx: tx,
4614 command_tx: cmd_tx,
4615 ui: Arc::new(NullInterface),
4616 file_cache: Arc::new(FileCache::new()),
4617 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4618 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4619 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4620 lua_tool_loader: None,
4621 mode: crate::config::AgentMode::Full,
4622 read_max_lines: 500,
4623 turn_mana_review: Arc::new(std::sync::Mutex::new(
4624 crate::mana_review::TurnManaReviewAccumulator::default(),
4625 )),
4626 config: Arc::new(crate::config::Config::default()),
4627 run_policy: Default::default(),
4628 supporting_provenance: Vec::new(),
4629 };
4630 let tool = ManaTool::default();
4631 let result = tool
4632 .execute(
4633 "call_root_scope",
4634 json!({ "action": "show", "id": "1", "scope": "root" }),
4635 ctx,
4636 )
4637 .await
4638 .unwrap();
4639 assert_eq!(result.details["title"], "Root unit");
4640 }
4641
4642 #[tokio::test]
4643 async fn explicit_path_targets_project_outside_cwd_ancestry() {
4644 let outside = tempfile::tempdir().unwrap();
4645 let target_project = outside.path().join("other-project");
4646 let target_mana = target_project.join(".mana");
4647 std::fs::create_dir_all(&target_mana).unwrap();
4648 std::fs::write(
4649 target_mana.join("config.yaml"),
4650 "project: other\nnext_id: 2\n",
4651 )
4652 .unwrap();
4653 std::fs::write(
4654 target_mana.join("1-other-unit.md"),
4655 "---\nid: '1'\ntitle: Other unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
4656 )
4657 .unwrap();
4658
4659 let unrelated = tempfile::tempdir().unwrap();
4660 let workdir = unrelated.path().join("scratch");
4661 std::fs::create_dir_all(&workdir).unwrap();
4662 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
4663 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
4664 let ctx = ToolContext {
4665 cwd: workdir,
4666 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4667 update_tx: tx,
4668 command_tx: cmd_tx,
4669 ui: Arc::new(NullInterface),
4670 file_cache: Arc::new(FileCache::new()),
4671 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4672 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4673 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4674 lua_tool_loader: None,
4675 mode: crate::config::AgentMode::Full,
4676 read_max_lines: 500,
4677 turn_mana_review: Arc::new(std::sync::Mutex::new(
4678 crate::mana_review::TurnManaReviewAccumulator::default(),
4679 )),
4680 config: Arc::new(crate::config::Config::default()),
4681 run_policy: Default::default(),
4682 supporting_provenance: Vec::new(),
4683 };
4684 let tool = ManaTool::default();
4685 let result = tool
4686 .execute(
4687 "call_explicit_path",
4688 json!({ "action": "show", "id": "1", "path": target_project }),
4689 ctx,
4690 )
4691 .await
4692 .unwrap();
4693 assert_eq!(result.details["title"], "Other unit");
4694 }
4695
4696 #[tokio::test]
4697 async fn worker_blocks_fact_create() {
4698 match run_with_mode("worker", "fact_create").await {
4699 ManaResult::ModeBlocked(_) => {}
4700 ManaResult::Attempted(out) => {
4701 panic!(
4702 "worker should block 'fact_create', got: {:?}",
4703 out.text_content()
4704 )
4705 }
4706 }
4707 }
4708
4709 #[tokio::test]
4710 async fn worker_allows_verify() {
4711 match run_with_mode("worker", "verify").await {
4712 ManaResult::Attempted(_) => {}
4713 ManaResult::ModeBlocked(msg) => {
4714 panic!("worker should allow 'verify' but was blocked: {msg}")
4715 }
4716 }
4717 }
4718
4719 #[tokio::test]
4720 async fn auditor_allows_show() {
4721 match run_with_mode("auditor", "show").await {
4722 ManaResult::Attempted(_) => {}
4723 ManaResult::ModeBlocked(msg) => {
4724 panic!("auditor should allow 'show' but was blocked: {msg}")
4725 }
4726 }
4727 }
4728
4729 #[tokio::test]
4730 async fn auditor_blocks_update() {
4731 match run_with_mode("auditor", "update").await {
4732 ManaResult::ModeBlocked(_) => {}
4733 ManaResult::Attempted(out) => {
4734 panic!(
4735 "auditor should block 'update', got: {:?}",
4736 out.text_content()
4737 )
4738 }
4739 }
4740 }
4741
4742 #[tokio::test]
4743 async fn worker_allows_logs() {
4744 match run_with_mode("worker", "logs").await {
4745 ManaResult::Attempted(_) => {}
4746 ManaResult::ModeBlocked(msg) => {
4747 panic!("worker should allow 'logs' but was blocked: {msg}")
4748 }
4749 }
4750 }
4751
4752 #[tokio::test]
4753 async fn orchestrator_allows_extended_actions() {
4754 for action in &[
4755 "status",
4756 "list",
4757 "show",
4758 "create",
4759 "close",
4760 "update",
4761 "run",
4762 "run_state",
4763 "evaluate",
4764 "claim",
4765 "release",
4766 "logs",
4767 "agents",
4768 "next",
4769 ] {
4770 match run_with_mode("orchestrator", action).await {
4771 ManaResult::Attempted(_) => {}
4772 ManaResult::ModeBlocked(msg) => {
4773 panic!("orchestrator should allow '{action}' but was blocked: {msg}")
4774 }
4775 }
4776 }
4777 }
4778
4779 #[tokio::test]
4780 async fn ctx_mode_wins_over_env() {
4781 let prev = {
4782 let _guard = ENV_LOCK.lock().unwrap();
4783 let prev = std::env::var("IMP_MODE").ok();
4784 std::env::set_var("IMP_MODE", "full");
4785 prev
4786 };
4787
4788 let result = run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await;
4789
4790 match prev {
4791 Some(v) => {
4792 let _guard = ENV_LOCK.lock().unwrap();
4793 std::env::set_var("IMP_MODE", v)
4794 }
4795 None => {
4796 let _guard = ENV_LOCK.lock().unwrap();
4797 std::env::remove_var("IMP_MODE")
4798 }
4799 }
4800
4801 match result {
4802 ManaResult::ModeBlocked(_) => {}
4803 ManaResult::Attempted(out) => {
4804 panic!(
4805 "ctx.mode=Worker should block 'create' even when IMP_MODE=full, got: {:?}",
4806 out.text_content()
4807 )
4808 }
4809 }
4810 }
4811
4812 #[tokio::test]
4813 async fn ctx_worker_blocks_create() {
4814 match run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await {
4815 ManaResult::ModeBlocked(_) => {}
4816 ManaResult::Attempted(out) => {
4817 panic!(
4818 "ctx Worker mode should block 'create', got: {:?}",
4819 out.text_content()
4820 )
4821 }
4822 }
4823 }
4824
4825 #[tokio::test]
4826 async fn ctx_full_allows_extended_actions() {
4827 for action in &[
4828 "status",
4829 "list",
4830 "show",
4831 "create",
4832 "close",
4833 "update",
4834 "run",
4835 "run_state",
4836 "evaluate",
4837 "claim",
4838 "release",
4839 "logs",
4840 "agents",
4841 "next",
4842 "tree",
4843 ] {
4844 match run_with_ctx_mode(crate::config::AgentMode::Full, action).await {
4845 ManaResult::Attempted(_) => {}
4846 ManaResult::ModeBlocked(msg) => {
4847 panic!("ctx Full mode should allow '{action}' but was blocked: {msg}")
4848 }
4849 }
4850 }
4851 }
4852
4853 #[tokio::test]
4854 async fn next_returns_ranked_text() {
4855 let dir = tempfile::tempdir().unwrap();
4856 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4857 let tool = ManaTool::default();
4858 let result = tool
4859 .execute("call_next", json!({ "action": "next", "count": 1 }), ctx)
4860 .await
4861 .unwrap();
4862 let text = result.text_content().unwrap_or("");
4863 assert!(text.contains("Test unit") || text.contains("No ready units"));
4864 }
4865
4866 #[tokio::test]
4867 async fn run_returns_promptly() {
4868 let dir = tempfile::tempdir().unwrap();
4869 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4870 let tool = ManaTool::default();
4871 let result = tool
4872 .execute("call_run", json!({ "action": "run", "dry_run": true }), ctx)
4873 .await
4874 .unwrap();
4875 let text = result.text_content().unwrap_or("");
4876 assert!(text.contains("Started native mana orchestration"));
4877 assert!(result.details["run_id"].as_str().is_some());
4878 }
4879
4880 #[tokio::test]
4881 async fn run_enqueues_follow_up_on_completion_without_ui() {
4882 let dir = tempfile::tempdir().unwrap();
4883 let mana_dir = dir.path().join(".mana");
4884 std::fs::create_dir_all(&mana_dir).unwrap();
4885 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
4886 std::fs::write(
4887 mana_dir.join("1-test-unit.md"),
4888 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
4889 )
4890 .unwrap();
4891
4892 let (tx, _rx) = mpsc::channel::<ToolUpdate>(8);
4893 let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
4894 let ctx = ToolContext {
4895 cwd: dir.path().to_path_buf(),
4896 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
4897 update_tx: tx,
4898 command_tx: cmd_tx,
4899 ui: Arc::new(NullInterface),
4900 file_cache: Arc::new(FileCache::new()),
4901 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
4902 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
4903 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
4904 lua_tool_loader: None,
4905 mode: crate::config::AgentMode::Full,
4906 read_max_lines: 500,
4907 turn_mana_review: Arc::new(std::sync::Mutex::new(
4908 crate::mana_review::TurnManaReviewAccumulator::default(),
4909 )),
4910 config: Arc::new(crate::config::Config::default()),
4911 run_policy: Default::default(),
4912 supporting_provenance: Vec::new(),
4913 };
4914
4915 let tool = ManaTool::default();
4916 let _ = tool
4917 .execute(
4918 "call_bg_follow_up",
4919 json!({ "action": "run", "dry_run": true }),
4920 ctx,
4921 )
4922 .await
4923 .unwrap();
4924
4925 let follow_up = tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv())
4926 .await
4927 .expect("follow-up timeout")
4928 .expect("follow-up message");
4929
4930 match follow_up {
4931 crate::agent::AgentCommand::FollowUp(text) => {
4932 assert!(
4933 text.contains("Native mana orchestration finished"),
4934 "text was: {text}"
4935 );
4936 assert!(
4937 text.contains("Inspect with mana(action=\"run_state\")"),
4938 "text was: {text}"
4939 );
4940 }
4941 other => panic!("expected follow-up, got {other:?}"),
4942 }
4943 }
4944
4945 #[tokio::test]
4946 async fn run_with_ui_does_not_enqueue_follow_up_on_completion() {
4947 let dir = tempfile::tempdir().unwrap();
4948 let (ctx, _keep, _widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
4949 let tool = ManaTool::default();
4950 let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
4951 let ctx = ToolContext {
4952 command_tx: cmd_tx,
4953 ..ctx
4954 };
4955
4956 let _ = tool
4957 .execute(
4958 "call_bg_follow_up_ui",
4959 json!({ "action": "run", "dry_run": true }),
4960 ctx,
4961 )
4962 .await
4963 .unwrap();
4964
4965 let follow_up =
4966 tokio::time::timeout(std::time::Duration::from_millis(700), cmd_rx.recv()).await;
4967 match follow_up {
4968 Err(_) | Ok(None) => {}
4969 Ok(Some(msg)) => panic!(
4970 "UI mode should rely on widget/status instead of queueing duplicate follow-up chat text, got: {msg:?}"
4971 ),
4972 }
4973 }
4974
4975 #[tokio::test]
4976 async fn run_supports_explicit_targets() {
4977 let dir = tempfile::tempdir().unwrap();
4978 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4979 let tool = ManaTool::default();
4980 let result = tool
4981 .execute(
4982 "call_bg_targets",
4983 json!({ "action": "run", "dry_run": true, "targets": ["1", "2"] }),
4984 ctx,
4985 )
4986 .await
4987 .unwrap();
4988 assert_eq!(result.details["target"]["kind"], "explicit");
4989 assert_eq!(result.details["target"]["ids"][0], "1");
4990 assert_eq!(result.details["target"]["ids"][1], "2");
4991 }
4992
4993 #[tokio::test]
4994 async fn run_state_and_evaluate_report_native_run() {
4995 let dir = tempfile::tempdir().unwrap();
4996 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
4997 let tool = ManaTool::default();
4998
4999 let run_result = tool
5000 .execute("call_run", json!({ "action": "run", "dry_run": true }), ctx)
5001 .await
5002 .unwrap();
5003 let run_id = run_result.details["run_id"]
5004 .as_str()
5005 .expect("run_id")
5006 .to_string();
5007
5008 let dir2 = tempfile::tempdir().unwrap();
5009 let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
5010 let state = tool
5011 .execute(
5012 "call_state",
5013 json!({ "action": "run_state", "run_id": run_id.as_str() }),
5014 ctx2,
5015 )
5016 .await
5017 .unwrap();
5018 let state_text = state.text_content().unwrap_or("");
5019 assert!(
5020 state_text.contains("Native mana orchestration "),
5021 "state_text was: {state_text}"
5022 );
5023 assert!(
5024 state_text.contains("Worker runtime:")
5025 || state_text.contains("Next: Inspect logs/agents"),
5026 "state_text was: {state_text}"
5027 );
5028 assert!(
5029 state_text.contains("Units:")
5030 || state_text.contains("Latest: Dry run:")
5031 || state_text.contains("0 total"),
5032 "state_text was: {state_text}"
5033 );
5034 assert!(
5035 state_text.contains("all ready units") || state_text.contains("unit"),
5036 "state_text was: {state_text}"
5037 );
5038
5039 let dir3 = tempfile::tempdir().unwrap();
5040 let (ctx3, _keep3) = ctx_with_mode(dir3.path(), crate::config::AgentMode::Full);
5041 let evaluation = tool
5042 .execute(
5043 "call_eval",
5044 json!({ "action": "evaluate", "run_id": run_result.details["run_id"] }),
5045 ctx3,
5046 )
5047 .await
5048 .unwrap();
5049 let eval_text = evaluation.text_content().unwrap_or("");
5050 assert!(
5051 eval_text.contains("Native mana orchestration run ")
5052 && (eval_text.contains("finished") || eval_text.contains("still running")),
5053 "eval_text was: {eval_text}"
5054 );
5055 assert!(
5056 eval_text.contains("Worker runtime:") || eval_text.contains("Runtime:"),
5057 "eval_text was: {eval_text}"
5058 );
5059 }
5060
5061 #[test]
5062 fn run_store_prefers_active_run_snapshot() {
5063 let mut store = ManaRunStore::default();
5064 let active_id = store.start_run(
5065 "all ready units".to_string(),
5066 &mana::commands::run::NativeRunParams {
5067 target: mana::commands::run::RunTarget::AllReady,
5068 jobs: 2,
5069 dry_run: false,
5070 loop_mode: false,
5071 keep_going: false,
5072 timeout: 30,
5073 idle_timeout: 5,
5074 json_stream: true,
5075 review: false,
5076 run_model: None,
5077 run_thinking: None,
5078 },
5079 );
5080 let finished_id = store.start_run(
5081 "unit 1".to_string(),
5082 &mana::commands::run::NativeRunParams {
5083 target: mana::commands::run::RunTarget::Unit("1".to_string()),
5084 jobs: 1,
5085 dry_run: true,
5086 loop_mode: false,
5087 keep_going: false,
5088 timeout: 30,
5089 idle_timeout: 5,
5090 json_stream: true,
5091 review: false,
5092 run_model: None,
5093 run_thinking: None,
5094 },
5095 );
5096 store.fail_run(&finished_id, "done".to_string());
5097
5098 let latest = store.snapshot(None).expect("snapshot");
5099 assert_eq!(latest.run_id, active_id);
5100 assert_eq!(latest.status, "starting");
5101 }
5102
5103 #[test]
5104 fn stream_event_line_formats_tool_activity() {
5105 let line = stream_event_line(&mana::stream::StreamEvent::UnitTool {
5106 id: "1".to_string(),
5107 tool_name: "read".to_string(),
5108 tool_count: 3,
5109 file_path: Some("src/lib.rs".to_string()),
5110 })
5111 .expect("line");
5112 assert!(line.contains("#3 read"));
5113 assert!(line.contains("src/lib.rs"));
5114 }
5115
5116 #[test]
5117 fn evaluate_output_reports_failures() {
5118 let mut state = NativeRunState::new(
5119 "run-7".to_string(),
5120 "unit 7".to_string(),
5121 &mana::commands::run::NativeRunParams {
5122 target: mana::commands::run::RunTarget::Unit("7".to_string()),
5123 jobs: 1,
5124 dry_run: false,
5125 loop_mode: false,
5126 keep_going: false,
5127 timeout: 30,
5128 idle_timeout: 5,
5129 json_stream: true,
5130 review: false,
5131 run_model: None,
5132 run_thinking: None,
5133 },
5134 );
5135 state.status = "finished".to_string();
5136 state.summary.total_failed = 2;
5137 state.log_lines.push("✗ 7 failed verify".to_string());
5138
5139 let output = evaluate_run_output(&state);
5140 let text = output.text_content().unwrap_or("");
5141 assert!(text.contains("2 failed unit"));
5142 assert!(text.contains("Latest: ✗ 7 failed verify"));
5143 assert!(text.contains("Next: Inspect failed units"));
5144 assert_eq!(
5145 output.details["recovery"]["retry_requires_unit_update"],
5146 json!(true)
5147 );
5148 assert!(output.details["recovery"]["next_actions"]
5149 .as_array()
5150 .unwrap()
5151 .iter()
5152 .any(|action| action
5153 .as_str()
5154 .unwrap_or_default()
5155 .contains("do not rerun unchanged")));
5156 }
5157
5158 #[test]
5159 fn run_state_output_includes_recovery_details() {
5160 let mut state = NativeRunState::new(
5161 "run-8".to_string(),
5162 "unit 8".to_string(),
5163 &mana::commands::run::NativeRunParams {
5164 target: mana::commands::run::RunTarget::Unit("8".to_string()),
5165 jobs: 1,
5166 dry_run: false,
5167 loop_mode: false,
5168 keep_going: false,
5169 timeout: 30,
5170 idle_timeout: 5,
5171 json_stream: true,
5172 review: false,
5173 run_model: None,
5174 run_thinking: None,
5175 },
5176 );
5177 state.status = "failed".to_string();
5178 state.summary.total_failed = 1;
5179 state.units.push(RunUnitStatus {
5180 id: "8".to_string(),
5181 title: "Failed unit".to_string(),
5182 status: "failed".to_string(),
5183 round: Some(1),
5184 agent: Some("imp-worker".to_string()),
5185 model: None,
5186 duration_secs: Some(3),
5187 tool_count: Some(2),
5188 turns: Some(1),
5189 failure_summary: Some("verify failed".to_string()),
5190 error: Some("exit 1".to_string()),
5191 });
5192
5193 let output = run_state_output(&state);
5194 let text = output.text_content().unwrap_or_default();
5195
5196 assert!(text.contains("Next: Inspect failed units"));
5197 assert_eq!(
5198 output.details["recovery"]["failed_units"][0]["id"],
5199 json!("8")
5200 );
5201 assert_eq!(
5202 output.details["recovery"]["retry_requires_unit_update"],
5203 json!(true)
5204 );
5205 }
5206 #[test]
5207 fn validate_native_run_target_rejects_multi_explicit_targets() {
5208 let target = RunTarget::Explicit(vec!["1".to_string(), "2".to_string()]);
5209
5210 let error = validate_native_run_target(&target).expect_err("must not broaden scope");
5211
5212 assert!(error.contains("multiple explicit targets"));
5213 assert!(error.contains("one target at a time"));
5214 }
5215
5216 #[test]
5217 fn target_ids_from_run_target_extracts_explicit_units() {
5218 assert_eq!(
5219 target_ids_from_run_target(&RunTarget::Unit("273.3".to_string())),
5220 vec!["273.3".to_string()]
5221 );
5222 assert_eq!(
5223 target_ids_from_run_target(&RunTarget::Explicit(vec![
5224 "1".to_string(),
5225 "2".to_string()
5226 ])),
5227 vec!["1".to_string(), "2".to_string()]
5228 );
5229 assert!(target_ids_from_run_target(&RunTarget::AllReady).is_empty());
5230 }
5231
5232 #[test]
5233 fn retry_guardrail_blocks_failed_unit_without_new_update() {
5234 let dir = tempfile::tempdir().unwrap();
5235 let mana_dir = dir.path().join(".mana");
5236 std::fs::create_dir(&mana_dir).unwrap();
5237 mana_core::config::Config {
5238 project: "retry-test".to_string(),
5239 next_id: 1,
5240 auto_close_parent: true,
5241 run: None,
5242 plan: None,
5243 max_loops: 10,
5244 max_concurrent: 4,
5245 poll_interval: 30,
5246 extends: vec![],
5247 rules_file: None,
5248 file_locking: false,
5249 worktree: false,
5250 on_close: None,
5251 on_fail: None,
5252 verify_timeout: None,
5253 review: None,
5254 user: None,
5255 user_email: None,
5256 auto_commit: false,
5257 commit_template: None,
5258 research: None,
5259 run_model: None,
5260 plan_model: None,
5261 review_model: None,
5262 research_model: None,
5263 batch_verify: false,
5264 memory_reserve_mb: 0,
5265 notify: None,
5266 }
5267 .save(&mana_dir)
5268 .unwrap();
5269
5270 let created = mana_core::api::create_unit(
5271 &mana_dir,
5272 mana_core::ops::create::CreateParams {
5273 title: "Retry target".to_string(),
5274 verify: Some("false".to_string()),
5275 ..Default::default()
5276 },
5277 )
5278 .unwrap();
5279 let id = created.unit.id;
5280 let now = chrono::Utc::now();
5281 let mut failed_unit = mana_core::ops::show::get(&mana_dir, &id).unwrap().unit;
5282 failed_unit
5283 .attempt_log
5284 .push(mana_core::unit::AttemptRecord {
5285 num: 1,
5286 outcome: mana_core::unit::AttemptOutcome::Failed,
5287 notes: Some("verify failed".to_string()),
5288 agent: Some("imp-test".to_string()),
5289 started_at: Some(now),
5290 finished_at: Some(now),
5291 autonomy_observation: None,
5292 });
5293 failed_unit.updated_at = now - chrono::Duration::milliseconds(1);
5294 let unit_path = mana_core::discovery::find_unit_file(&mana_dir, &id).unwrap();
5295 failed_unit.to_file(&unit_path).unwrap();
5296
5297 let guardrail = retry_guardrail_for_targets(&mana_dir, std::slice::from_ref(&id))
5298 .unwrap()
5299 .expect("failed unchanged unit should require update");
5300
5301 assert_eq!(guardrail["retry_requires_unit_update"], json!(true));
5302 assert_eq!(guardrail["blocked_units"][0]["id"], json!(id));
5303 assert!(guardrail["next_actions"]
5304 .as_array()
5305 .unwrap()
5306 .iter()
5307 .any(|action| action.as_str().unwrap_or_default().contains("Append notes")));
5308 }
5309
5310 #[test]
5311 fn retry_guardrail_allows_failed_unit_after_update() {
5312 let dir = tempfile::tempdir().unwrap();
5313 let mana_dir = dir.path().join(".mana");
5314 std::fs::create_dir(&mana_dir).unwrap();
5315 mana_core::config::Config {
5316 project: "retry-test".to_string(),
5317 next_id: 1,
5318 auto_close_parent: true,
5319 run: None,
5320 plan: None,
5321 max_loops: 10,
5322 max_concurrent: 4,
5323 poll_interval: 30,
5324 extends: vec![],
5325 rules_file: None,
5326 file_locking: false,
5327 worktree: false,
5328 on_close: None,
5329 on_fail: None,
5330 verify_timeout: None,
5331 review: None,
5332 user: None,
5333 user_email: None,
5334 auto_commit: false,
5335 commit_template: None,
5336 research: None,
5337 run_model: None,
5338 plan_model: None,
5339 review_model: None,
5340 research_model: None,
5341 batch_verify: false,
5342 memory_reserve_mb: 0,
5343 notify: None,
5344 }
5345 .save(&mana_dir)
5346 .unwrap();
5347
5348 let created = mana_core::api::create_unit(
5349 &mana_dir,
5350 mana_core::ops::create::CreateParams {
5351 title: "Retry target".to_string(),
5352 verify: Some("false".to_string()),
5353 ..Default::default()
5354 },
5355 )
5356 .unwrap();
5357 let id = created.unit.id;
5358 let now = chrono::Utc::now();
5359 let mut failed_unit = mana_core::ops::show::get(&mana_dir, &id).unwrap().unit;
5360 failed_unit
5361 .attempt_log
5362 .push(mana_core::unit::AttemptRecord {
5363 num: 1,
5364 outcome: mana_core::unit::AttemptOutcome::Failed,
5365 notes: Some("verify failed".to_string()),
5366 agent: Some("imp-test".to_string()),
5367 started_at: Some(now),
5368 finished_at: Some(now),
5369 autonomy_observation: None,
5370 });
5371 failed_unit.updated_at = now;
5372 let unit_path = mana_core::discovery::find_unit_file(&mana_dir, &id).unwrap();
5373 failed_unit.to_file(&unit_path).unwrap();
5374 std::thread::sleep(std::time::Duration::from_millis(2));
5375 mana_core::api::update_unit(
5376 &mana_dir,
5377 &id,
5378 mana_core::ops::update::UpdateParams {
5379 notes: Some("Changed retry plan after failure".to_string()),
5380 ..Default::default()
5381 },
5382 )
5383 .unwrap();
5384
5385 let guardrail = retry_guardrail_for_targets(&mana_dir, &[id]).unwrap();
5386
5387 assert!(guardrail.is_none());
5388 }
5389 fn native_run_params_for_test() -> mana::commands::run::NativeRunParams {
5390 mana::commands::run::NativeRunParams {
5391 target: mana::commands::run::RunTarget::AllReady,
5392 jobs: 1,
5393 dry_run: false,
5394 loop_mode: false,
5395 keep_going: false,
5396 timeout: 30,
5397 idle_timeout: 5,
5398 json_stream: true,
5399 review: false,
5400 run_model: None,
5401 run_thinking: None,
5402 }
5403 }
5404
5405 fn ready_unit_for_model_test(model: Option<&str>) -> mana_run_core::ReadyUnit {
5406 mana_run_core::ReadyUnit {
5407 id: "1".to_string(),
5408 title: "Model propagation".to_string(),
5409 priority: 2,
5410 critical_path_weight: 0,
5411 paths: Vec::new(),
5412 produces: Vec::new(),
5413 requires: Vec::new(),
5414 dependencies: Vec::new(),
5415 parent: None,
5416 model: model.map(str::to_string),
5417 verify_command: None,
5418 verify_fast: None,
5419 retry: mana_core::ops::run::RunRetryContext {
5420 attempt_number: 0,
5421 previous_failure: None,
5422 previous_notes: Vec::new(),
5423 },
5424 }
5425 }
5426
5427 #[test]
5428 fn native_worker_options_prefer_unit_model_and_config_thinking() {
5429 let dir = tempfile::tempdir().unwrap();
5430 let (mut ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
5431 let config = crate::config::Config {
5432 model: Some("config-model".to_string()),
5433 thinking: Some(imp_llm::ThinkingLevel::High),
5434 ..Default::default()
5435 };
5436 ctx.config = Arc::new(config);
5437 let run_args = mana::commands::run::NativeRunParams {
5438 timeout: 42,
5439 ..native_run_params_for_test()
5440 };
5441
5442 let options = worker_options_for_native_unit(
5443 &ready_unit_for_model_test(Some("unit-model")),
5444 &run_args,
5445 dir.path().to_path_buf(),
5446 dir.path().join(".mana"),
5447 &ctx,
5448 );
5449
5450 assert_eq!(options.model.as_deref(), Some("unit-model"));
5451 assert_eq!(options.thinking, Some(imp_llm::ThinkingLevel::High));
5452 assert_eq!(options.max_turns, Some(42));
5453 assert_eq!(
5454 options.mana_dir_override.as_deref(),
5455 Some(dir.path().join(".mana").as_path())
5456 );
5457 }
5458
5459 #[test]
5460 fn native_worker_options_fall_back_to_config_model() {
5461 let dir = tempfile::tempdir().unwrap();
5462 let (mut ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
5463 let config = crate::config::Config {
5464 model: Some("config-model".to_string()),
5465 ..Default::default()
5466 };
5467 ctx.config = Arc::new(config);
5468
5469 let options = worker_options_for_native_unit(
5470 &ready_unit_for_model_test(None),
5471 &native_run_params_for_test(),
5472 dir.path().to_path_buf(),
5473 dir.path().join(".mana"),
5474 &ctx,
5475 );
5476
5477 assert_eq!(options.model.as_deref(), Some("config-model"));
5478 }
5479
5480 #[test]
5481 fn native_run_runtime_info_reports_config_model() {
5482 let dir = tempfile::tempdir().unwrap();
5483 let (mut ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
5484 let config = crate::config::Config {
5485 model: Some("config-model".to_string()),
5486 ..Default::default()
5487 };
5488 ctx.config = Arc::new(config);
5489
5490 let runtime = runtime_info_for_run(&native_run_params_for_test(), &ctx);
5491
5492 assert_eq!(runtime.direct_agent.as_deref(), Some("imp"));
5493 assert_eq!(runtime.model.as_deref(), Some("config-model"));
5494 }
5495
5496 #[test]
5497 fn mana_run_state_persists_material_events() {
5498 let mut store = ManaRunStore::default();
5499 let run_id = store.start_run("all ready units".to_string(), &native_run_params_for_test());
5500 let before = store.snapshot(Some(&run_id)).unwrap().last_event_at_ms;
5501
5502 store.update_with_event(
5503 &run_id,
5504 &mana::stream::StreamEvent::UnitTool {
5505 id: "1".to_string(),
5506 tool_name: "read".to_string(),
5507 tool_count: 1,
5508 file_path: Some("src/lib.rs".to_string()),
5509 },
5510 );
5511
5512 let state = store.snapshot(Some(&run_id)).unwrap();
5513 assert_eq!(state.event_count, 1);
5514 assert!(state.last_event_at_ms >= before);
5515 assert!(state.log_lines.iter().any(|line| line.contains("#1 read")));
5516 }
5517
5518 #[test]
5519 fn mana_run_state_marks_stale_running_runs_interrupted() {
5520 let mut store = ManaRunStore::default();
5521 let run_id = store.start_run("all ready units".to_string(), &native_run_params_for_test());
5522 {
5523 let run = store
5524 .runs
5525 .iter_mut()
5526 .find(|run| run.run_id == run_id)
5527 .unwrap();
5528 run.status = "running".to_string();
5529 run.last_event_at_ms = unix_time_ms().saturating_sub(INTERRUPTED_RUN_STALE_MS + 1_000);
5530 }
5531
5532 store.classify_stale_unfinished_runs();
5533
5534 let state = store.snapshot(Some(&run_id)).unwrap();
5535 assert_eq!(state.status, "interrupted");
5536 assert!(state.error.as_deref().unwrap_or_default().contains("stale"));
5537 let output = run_state_output(&state);
5538 let text = output.text_content().unwrap_or_default();
5539 assert!(text.contains("Interrupted:"));
5540 assert_eq!(
5541 output.details["recovery"]["stale_workers"][0]["run_id"],
5542 run_id
5543 );
5544 assert!(output.details["recovery"]["next_actions"]
5545 .as_array()
5546 .unwrap()
5547 .iter()
5548 .any(|action| action
5549 .as_str()
5550 .unwrap_or_default()
5551 .contains("interrupted/stale")));
5552 }
5553}