1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use chrono::Utc;
6
7use sha2::{Digest, Sha256};
8
9use crate::config::{Config, DEFAULT_COMMIT_TEMPLATE};
10use crate::discovery::{archive_path_for_unit, find_archived_unit, find_unit_file};
11use crate::graph;
12use crate::hooks::{
13 current_git_branch, execute_config_hook, execute_hook, is_trusted, HookEvent, HookVars,
14};
15use crate::index::{ArchiveIndex, Index, IndexEntry, LockedIndex};
16use crate::ops::verify::run_verify_command;
17use crate::unit::{
18 AttemptOutcome, OnCloseAction, OnFailAction, RunRecord, RunResult, Status, Unit, VerifyPosture,
19};
20use crate::util::title_to_slug;
21
22#[derive(Debug, serde::Serialize)]
28pub enum OnFailActionTaken {
29 Retry {
31 attempt: u32,
32 max: u32,
33 delay_secs: Option<u64>,
34 },
35 RetryExhausted { max: u32 },
37 Escalated,
39 None,
41}
42
43#[derive(Debug)]
45pub struct CircuitBreakerStatus {
46 pub tripped: bool,
47 pub subtree_total: u32,
48 pub max_loops: u32,
49}
50
51#[derive(Debug)]
53pub struct VerifyFailure {
54 pub exit_code: Option<i32>,
55 pub output: String,
56 pub timed_out: bool,
57 pub duration_secs: f64,
58 pub started_at: chrono::DateTime<Utc>,
59 pub finished_at: chrono::DateTime<Utc>,
60 pub agent: Option<String>,
61}
62
63pub struct CloseOpts {
65 pub reason: Option<String>,
66 pub force: bool,
67 pub defer_verify: bool,
72}
73
74#[derive(Debug, serde::Serialize)]
76pub enum CloseWarning {
77 PreCloseHookError { message: String },
79 PostCloseHookRejected,
81 PostCloseHookError { message: String },
83 WorktreeCleanupFailed { message: String },
85 VerifyChanged,
87}
88
89#[derive(Debug, Default, serde::Serialize)]
91pub struct CloseEvidence {
92 pub changed_files: Vec<String>,
94 pub additions: u32,
96 pub deletions: u32,
98 pub only_mana_changes: bool,
100 pub no_path_overlap: bool,
102}
103
104#[derive(Debug, serde::Serialize)]
106pub struct AutoCommitResult {
107 pub message: String,
108 pub committed: bool,
109 pub warning: Option<String>,
110}
111
112#[derive(Debug, serde::Serialize)]
114pub enum CloseOutcome {
115 Closed(CloseResult),
117 VerifyFailed(VerifyFailureResult),
119 RejectedByHook { unit_id: String },
121 FeatureRequiresHuman {
123 unit_id: String,
124 title: String,
125 warnings: Vec<CloseWarning>,
126 },
127 CircuitBreakerTripped {
129 unit_id: String,
130 total_attempts: u32,
131 max: u32,
132 warnings: Vec<CloseWarning>,
133 },
134 MergeConflict {
136 files: Vec<String>,
137 warnings: Vec<CloseWarning>,
138 },
139 DeferredVerify { unit_id: String },
144 VerifyFrozenViolation {
146 unit_id: String,
147 warnings: Vec<CloseWarning>,
148 },
149}
150
151#[derive(Debug, serde::Serialize)]
153pub struct CloseResult {
154 pub unit: Unit,
155 pub archive_path: PathBuf,
156 pub auto_closed_parents: Vec<String>,
157 pub on_close_results: Vec<OnCloseActionResult>,
158 pub warnings: Vec<CloseWarning>,
159 pub auto_commit_result: Option<AutoCommitResult>,
160 pub evidence: Option<CloseEvidence>,
162}
163
164#[derive(Debug, serde::Serialize)]
166pub enum OnCloseActionResult {
167 RanCommand {
169 command: String,
170 success: bool,
171 exit_code: Option<i32>,
172 error: Option<String>,
173 },
174 Notified { message: String },
176 Skipped { command: String },
178}
179
180#[derive(Debug, serde::Serialize)]
182pub struct VerifyFailureResult {
183 pub unit: Unit,
184 pub attempt_number: u32,
185 pub exit_code: Option<i32>,
186 pub output: String,
187 pub timed_out: bool,
188 pub on_fail_action_taken: Option<OnFailActionTaken>,
189 pub verify_command: String,
190 pub timeout_secs: Option<u64>,
191 pub warnings: Vec<CloseWarning>,
192}
193
194struct HookDecision {
195 accepted: bool,
196 warning: Option<CloseWarning>,
197}
198
199struct PostCloseActionsReport {
200 warnings: Vec<CloseWarning>,
201 on_close_results: Vec<OnCloseActionResult>,
202}
203
204enum WorktreeMergeStatus {
205 Merged,
206 Conflict { files: Vec<String> },
207}
208
209const MAX_OUTPUT_BYTES: usize = 64 * 1024;
211
212fn compute_close_evidence(
214 project_root: &Path,
215 checkpoint: Option<&str>,
216 unit_paths: &[String],
217) -> Option<CloseEvidence> {
218 let checkpoint = checkpoint?;
219
220 let name_output = std::process::Command::new("git")
221 .args(["diff", "--name-only", checkpoint, "HEAD"])
222 .current_dir(project_root)
223 .output()
224 .ok()?;
225
226 if !name_output.status.success() {
227 return None;
228 }
229
230 let changed_files: Vec<String> = String::from_utf8_lossy(&name_output.stdout)
231 .lines()
232 .map(str::trim)
233 .filter(|s| !s.is_empty())
234 .map(String::from)
235 .collect();
236
237 let numstat_output = std::process::Command::new("git")
238 .args(["diff", "--numstat", checkpoint, "HEAD"])
239 .current_dir(project_root)
240 .output()
241 .ok();
242
243 let (mut additions, mut deletions) = (0u32, 0u32);
244 if let Some(ref out) = numstat_output {
245 for line in String::from_utf8_lossy(&out.stdout).lines() {
246 let parts: Vec<&str> = line.split_whitespace().collect();
247 if parts.len() >= 2 {
248 additions += parts[0].parse::<u32>().unwrap_or(0);
249 deletions += parts[1].parse::<u32>().unwrap_or(0);
250 }
251 }
252 }
253
254 let only_mana_changes = !changed_files.is_empty()
255 && changed_files
256 .iter()
257 .all(|f| f.starts_with(".mana/") || f.starts_with(".mana\\"));
258
259 let no_path_overlap = if unit_paths.is_empty() {
260 false
261 } else {
262 !changed_files.iter().any(|changed| {
263 unit_paths
264 .iter()
265 .any(|expected| changed == expected || changed.starts_with(expected))
266 })
267 };
268
269 Some(CloseEvidence {
270 changed_files,
271 additions,
272 deletions,
273 only_mana_changes,
274 no_path_overlap,
275 })
276}
277
278fn has_non_mana_changes_since_checkpoint(project_root: &Path, checkpoint: &str) -> Result<bool> {
279 let diff_output = std::process::Command::new("git")
280 .args(["diff", "--name-only", checkpoint, "--"])
281 .current_dir(project_root)
282 .output()
283 .context("Failed to compare working tree against checkpoint")?;
284
285 if !diff_output.status.success() {
286 return Ok(true);
287 }
288
289 let tracked_changed = String::from_utf8_lossy(&diff_output.stdout)
290 .lines()
291 .map(str::trim)
292 .any(|path| !path.is_empty() && !path.starts_with(".mana/"));
293 if tracked_changed {
294 return Ok(true);
295 }
296
297 let untracked_output = std::process::Command::new("git")
298 .args(["ls-files", "--others", "--exclude-standard"])
299 .current_dir(project_root)
300 .output()
301 .context("Failed to list untracked files")?;
302
303 if !untracked_output.status.success() {
304 return Ok(true);
305 }
306
307 Ok(String::from_utf8_lossy(&untracked_output.stdout)
308 .lines()
309 .map(str::trim)
310 .any(|path| !path.is_empty() && !path.starts_with(".mana/")))
311}
312
313pub fn close(mana_dir: &Path, id: &str, opts: CloseOpts) -> Result<CloseOutcome> {
325 let project_root = mana_dir
326 .parent()
327 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root from units dir"))?;
328
329 let config = Config::load_with_extends(mana_dir).ok();
330
331 let unit_path =
332 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
333 let mut unit =
334 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
335
336 let pre_close = run_pre_close_hook(&unit, project_root, opts.reason.as_deref());
338 if !pre_close.accepted {
339 return Ok(CloseOutcome::RejectedByHook {
340 unit_id: id.to_string(),
341 });
342 }
343
344 let mut warnings = Vec::new();
345 if let Some(warning) = pre_close.warning {
346 warnings.push(warning);
347 }
348
349 if opts.defer_verify {
354 unit.status = Status::AwaitingVerify;
355 unit.updated_at = Utc::now();
356 if let Some(disposition) = unit.autonomy_disposition.as_mut() {
357 disposition.verify = VerifyPosture::Deferred;
358 }
359 refresh_autonomy_disposition(&mut unit);
360 unit.to_file(&unit_path)
361 .with_context(|| format!("Failed to save unit: {}", id))?;
362 rebuild_index(mana_dir)?;
363 return Ok(CloseOutcome::DeferredVerify {
364 unit_id: id.to_string(),
365 });
366 }
367
368 if let Some(ref stored_hash) = unit.verify_hash {
370 if let Some(ref verify_cmd) = unit.verify {
371 let mut hasher = Sha256::new();
372 hasher.update(verify_cmd.as_bytes());
373 let current_hash = format!("{:x}", hasher.finalize());
374 if current_hash != *stored_hash {
375 if !opts.force {
376 if let Some(disposition) = unit.autonomy_disposition.as_mut() {
377 disposition.verify = VerifyPosture::FrozenViolation;
378 }
379 refresh_autonomy_disposition(&mut unit);
380 unit.updated_at = Utc::now();
381 unit.to_file(&unit_path)
382 .with_context(|| format!("Failed to save unit: {}", id))?;
383 rebuild_index(mana_dir)?;
384 return Ok(CloseOutcome::VerifyFrozenViolation {
385 unit_id: id.to_string(),
386 warnings,
387 });
388 }
389 warnings.push(CloseWarning::VerifyChanged);
390 }
391 }
392 }
393
394 if let Some(verify_cmd) = unit.verify.clone() {
396 if !verify_cmd.trim().is_empty() && !opts.force {
397 let timeout_secs =
398 unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
399
400 let started_at = Utc::now();
401 let verify_result = run_verify_command(&verify_cmd, project_root, timeout_secs)?;
402 let finished_at = Utc::now();
403 let duration_secs = (finished_at - started_at).num_milliseconds() as f64 / 1000.0;
404 let agent = std::env::var("MANA_AGENT").ok();
405
406 if !verify_result.passed {
407 let combined_output = if verify_result.timed_out {
409 format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
410 } else {
411 let stdout = verify_result.stdout.trim();
412 let stderr = verify_result.stderr.trim();
413 let sep = if !stdout.is_empty() && !stderr.is_empty() {
414 "\n"
415 } else {
416 ""
417 };
418 format!("{}{}{}", stdout, sep, stderr)
419 };
420
421 let failure = VerifyFailure {
423 exit_code: verify_result.exit_code,
424 output: combined_output,
425 timed_out: verify_result.timed_out,
426 duration_secs,
427 started_at,
428 finished_at,
429 agent,
430 };
431 record_failure_on_unit(&mut unit, &failure);
432
433 let root_id = find_root_parent(mana_dir, &unit)?;
435 let config_max = config.as_ref().map(|c| c.max_loops).unwrap_or(10);
436 let max_loops_limit = resolve_max_loops(mana_dir, &unit, &root_id, config_max);
437
438 if max_loops_limit > 0 {
439 unit.to_file(&unit_path)
441 .with_context(|| format!("Failed to save unit: {}", id))?;
442
443 let cb = check_circuit_breaker(mana_dir, &mut unit, &root_id, max_loops_limit)?;
444 if cb.tripped {
445 unit.to_file(&unit_path)
446 .with_context(|| format!("Failed to save unit: {}", id))?;
447
448 rebuild_index(mana_dir)?;
450
451 return Ok(CloseOutcome::CircuitBreakerTripped {
452 unit_id: id.to_string(),
453 total_attempts: cb.subtree_total,
454 max: cb.max_loops,
455 warnings,
456 });
457 }
458 }
459
460 let action_taken = process_on_fail(&mut unit);
462
463 unit.to_file(&unit_path)
464 .with_context(|| format!("Failed to save unit: {}", id))?;
465
466 run_on_fail_hook(&unit, project_root, config.as_ref(), &failure.output);
468
469 rebuild_index(mana_dir)?;
471
472 return Ok(CloseOutcome::VerifyFailed(VerifyFailureResult {
473 attempt_number: unit.attempts,
474 exit_code: failure.exit_code,
475 output: failure.output,
476 timed_out: failure.timed_out,
477 on_fail_action_taken: Some(action_taken),
478 verify_command: verify_cmd,
479 timeout_secs,
480 warnings,
481 unit,
482 }));
483 }
484
485 if !unit.fail_first {
486 if let Some(checkpoint) = unit.checkpoint.as_deref() {
487 if !has_non_mana_changes_since_checkpoint(project_root, checkpoint)? {
488 anyhow::bail!(
489 "Cannot close unit {}: verify already passed when work began and no non-.mana changes were detected since claim.\n\nUse --force to override, or add acceptance criteria / a failing verify gate for this kind of work.",
490 id
491 );
492 }
493 }
494 }
495
496 unit.history.push(RunRecord {
498 attempt: unit.attempts + 1,
499 started_at,
500 finished_at: Some(finished_at),
501 duration_secs: Some(duration_secs),
502 agent,
503 result: RunResult::Pass,
504 exit_code: verify_result.exit_code,
505 tokens: None,
506 cost: None,
507 output_snippet: None,
508 autonomy_observation: None,
509 });
510
511 capture_verify_outputs(&mut unit, &verify_result.stdout);
513 refresh_autonomy_disposition(&mut unit);
514 }
515 }
516
517 let worktree_info = detect_valid_worktree(project_root);
521 if let Some(ref wt_info) = worktree_info {
522 match handle_worktree_merge(wt_info, &unit)? {
523 WorktreeMergeStatus::Merged => {}
524 WorktreeMergeStatus::Conflict { files } => {
525 return Ok(CloseOutcome::MergeConflict { files, warnings });
526 }
527 }
528 }
529
530 if unit.feature {
532 use std::io::IsTerminal;
533 if !opts.force || !std::io::stdin().is_terminal() {
534 return Ok(CloseOutcome::FeatureRequiresHuman {
535 unit_id: unit.id.clone(),
536 title: unit.title.clone(),
537 warnings,
538 });
539 }
540 }
541
542 let evidence = compute_close_evidence(project_root, unit.checkpoint.as_deref(), &unit.paths);
544
545 if let Some(record) = unit.history.last_mut() {
546 if record.result == RunResult::Pass {
547 record.output_snippet =
548 build_pass_output_snippet(unit.verify.as_deref(), evidence.as_ref());
549 }
550 }
551
552 let now = Utc::now();
554 unit.status = Status::Closed;
555 unit.closed_at = Some(now);
556 unit.close_reason = opts.reason.clone();
557 unit.updated_at = now;
558
559 if let Some(attempt) = unit.attempt_log.last_mut() {
561 if attempt.finished_at.is_none() {
562 attempt.outcome = AttemptOutcome::Success;
563 attempt.finished_at = Some(now);
564 attempt.notes = opts.reason.clone();
565 }
566 }
567
568 if unit.unit_type == "fact" {
570 unit.last_verified = Some(now);
571 }
572
573 refresh_autonomy_disposition(&mut unit);
574
575 unit.to_file(&unit_path)
576 .with_context(|| format!("Failed to save unit: {}", id))?;
577
578 let archive_path = archive_unit(mana_dir, &mut unit, &unit_path)?;
580
581 rebuild_index(mana_dir)?;
585
586 let post_close =
588 run_post_close_actions(&unit, project_root, opts.reason.as_deref(), config.as_ref());
589 warnings.extend(post_close.warnings);
590
591 if let Some(ref wt_info) = worktree_info {
593 if let Some(warning) = cleanup_worktree(wt_info) {
594 warnings.push(warning);
595 }
596 }
597
598 let auto_closed_parents = if mana_dir.exists() {
600 if let Some(parent_id) = &unit.parent {
601 let auto_close_enabled = config.as_ref().map(|c| c.auto_close_parent).unwrap_or(true);
602 if auto_close_enabled {
603 auto_close_parents(mana_dir, parent_id)?
604 } else {
605 vec![]
606 }
607 } else {
608 vec![]
609 }
610 } else {
611 vec![]
612 };
613
614 rebuild_index(mana_dir)?;
617
618 let close_commit_target_paths = close_commit_target_paths(
619 project_root,
620 mana_dir,
621 &unit,
622 &unit_path,
623 &archive_path,
624 &auto_closed_parents,
625 );
626
627 let auto_commit_result = if worktree_info.is_none() {
629 let auto_commit_enabled = config.as_ref().map(|c| c.auto_commit).unwrap_or(false);
630 if auto_commit_enabled {
631 let template = config.as_ref().and_then(|c| c.commit_template.clone());
632 Some(auto_commit_on_close(
633 project_root,
634 id,
635 &unit.title,
636 unit.parent.as_deref(),
637 &unit.labels,
638 template.as_deref(),
639 &close_commit_target_paths,
640 ))
641 } else {
642 None
643 }
644 } else {
645 None
646 };
647
648 Ok(CloseOutcome::Closed(CloseResult {
649 unit,
650 archive_path,
651 auto_closed_parents,
652 on_close_results: post_close.on_close_results,
653 warnings,
654 auto_commit_result,
655 evidence,
656 }))
657}
658
659pub fn close_failed(mana_dir: &Path, id: &str, reason: Option<String>) -> Result<Unit> {
664 let now = Utc::now();
665
666 let unit_path =
667 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
668 let mut unit =
669 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
670
671 if let Some(attempt) = unit.attempt_log.last_mut() {
673 if attempt.finished_at.is_none() {
674 attempt.outcome = AttemptOutcome::Failed;
675 attempt.finished_at = Some(now);
676 attempt.notes = reason.clone();
677 }
678 }
679
680 unit.claimed_by = None;
682 unit.claimed_at = None;
683 unit.status = Status::Open;
684 unit.updated_at = now;
685
686 {
688 let attempt_num = unit.attempt_log.len() as u32;
689 let duration_secs = unit
690 .attempt_log
691 .last()
692 .and_then(|a| a.started_at)
693 .map(|started| (now - started).num_seconds().max(0) as u64)
694 .unwrap_or(0);
695
696 let ctx = crate::failure::FailureContext {
697 unit_id: id.to_string(),
698 unit_title: unit.title.clone(),
699 attempt: attempt_num.max(1),
700 duration_secs,
701 tool_count: 0,
702 turns: 0,
703 input_tokens: 0,
704 output_tokens: 0,
705 cost: 0.0,
706 error: reason,
707 tool_log: vec![],
708 verify_command: unit.verify.clone(),
709 };
710 let summary = crate::failure::build_failure_summary(&ctx);
711
712 match &mut unit.notes {
713 Some(notes) => {
714 notes.push('\n');
715 notes.push_str(&summary);
716 }
717 None => unit.notes = Some(summary),
718 }
719 }
720
721 unit.to_file(&unit_path)
722 .with_context(|| format!("Failed to save unit: {}", id))?;
723
724 rebuild_index(mana_dir)?;
726
727 Ok(unit)
728}
729
730pub fn all_children_closed(mana_dir: &Path, parent_id: &str) -> Result<bool> {
739 let index = Index::build(mana_dir)?;
740 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
741
742 let mut all_units = index.units;
743 all_units.extend(archived);
744
745 let children: Vec<_> = all_units
746 .iter()
747 .filter(|b| b.parent.as_deref() == Some(parent_id))
748 .collect();
749
750 if children.is_empty() {
751 return Ok(true);
752 }
753
754 for child in children {
755 if child.status != Status::Closed {
756 return Ok(false);
757 }
758 }
759
760 Ok(true)
761}
762
763pub fn auto_close_parents(mana_dir: &Path, parent_id: &str) -> Result<Vec<String>> {
769 let mut closed = Vec::new();
770 auto_close_parent_recursive(mana_dir, parent_id, &mut closed)?;
771 Ok(closed)
772}
773
774fn auto_close_parent_recursive(
775 mana_dir: &Path,
776 parent_id: &str,
777 closed: &mut Vec<String>,
778) -> Result<()> {
779 if !all_children_closed(mana_dir, parent_id)? {
780 return Ok(());
781 }
782
783 let unit_path = match find_unit_file(mana_dir, parent_id) {
784 Ok(path) => path,
785 Err(_) => return Ok(()), };
787
788 let mut unit = Unit::from_file(&unit_path)
789 .with_context(|| format!("Failed to load parent unit: {}", parent_id))?;
790
791 if unit.status == Status::Closed {
792 return Ok(());
793 }
794
795 if unit.feature {
797 return Ok(());
798 }
799
800 let now = Utc::now();
801 unit.status = Status::Closed;
802 unit.closed_at = Some(now);
803 unit.close_reason = Some("Auto-closed: all children completed".to_string());
804 unit.updated_at = now;
805
806 unit.to_file(&unit_path)
807 .with_context(|| format!("Failed to save parent unit: {}", parent_id))?;
808
809 archive_unit(mana_dir, &mut unit, &unit_path)?;
810 closed.push(parent_id.to_string());
811
812 if let Some(grandparent_id) = &unit.parent {
814 auto_close_parent_recursive(mana_dir, grandparent_id, closed)?;
815 }
816
817 Ok(())
818}
819
820pub fn archive_unit(mana_dir: &Path, unit: &mut Unit, unit_path: &Path) -> Result<PathBuf> {
825 let id = &unit.id;
826 let slug = unit
827 .slug
828 .clone()
829 .unwrap_or_else(|| title_to_slug(&unit.title));
830 let ext = unit_path
831 .extension()
832 .and_then(|e| e.to_str())
833 .unwrap_or("md");
834 let today = chrono::Local::now().naive_local().date();
835 let archive_path = archive_path_for_unit(mana_dir, id, &slug, ext, today);
836
837 if let Some(parent) = archive_path.parent() {
838 std::fs::create_dir_all(parent)
839 .with_context(|| format!("Failed to create archive directories for unit {}", id))?;
840 }
841
842 std::fs::rename(unit_path, &archive_path)
843 .with_context(|| format!("Failed to move unit {} to archive", id))?;
844
845 unit.is_archived = true;
846 unit.to_file(&archive_path)
847 .with_context(|| format!("Failed to save archived unit: {}", id))?;
848
849 {
851 let mut archive_index =
852 ArchiveIndex::load(mana_dir).unwrap_or(ArchiveIndex { units: Vec::new() });
853 archive_index.append(IndexEntry::from(&*unit));
854 let _ = archive_index.save(mana_dir);
855 }
856
857 Ok(archive_path)
858}
859
860pub fn record_failure(unit: &mut Unit, failure: &VerifyFailure) {
865 record_failure_on_unit(unit, failure);
866}
867
868fn build_pass_output_snippet(
869 verify_command: Option<&str>,
870 evidence: Option<&CloseEvidence>,
871) -> Option<String> {
872 let mut parts = Vec::new();
873
874 if let Some(verify) = verify_command.map(str::trim).filter(|v| !v.is_empty()) {
875 parts.push(format!("verify passed: {}", verify));
876 } else {
877 parts.push("verify passed".to_string());
878 }
879
880 let file_count = evidence.map(|e| e.changed_files.len()).unwrap_or(0);
881 if file_count > 0 {
882 let evidence = evidence.expect("file_count > 0 implies evidence exists");
883 let mut scope = format!(
884 "changed {} file{} (+{}/-{})",
885 file_count,
886 if file_count == 1 { "" } else { "s" },
887 evidence.additions,
888 evidence.deletions
889 );
890 if evidence.only_mana_changes {
891 scope.push_str(", only .mana changes");
892 }
893 if evidence.no_path_overlap {
894 scope.push_str(", no declared path overlap");
895 }
896 parts.push(scope);
897 } else if evidence
898 .map(|e| e.only_mana_changes || e.no_path_overlap)
899 .unwrap_or(false)
900 {
901 let evidence = evidence.expect("scope flags imply evidence exists");
902 let mut scope_flags = Vec::new();
903 if evidence.only_mana_changes {
904 scope_flags.push("only .mana changes");
905 }
906 if evidence.no_path_overlap {
907 scope_flags.push("no declared path overlap");
908 }
909 if !scope_flags.is_empty() {
910 parts.push(scope_flags.join(", "));
911 }
912 }
913
914 if parts.is_empty() {
915 None
916 } else {
917 Some(parts.join("; "))
918 }
919}
920
921pub fn process_on_fail(unit: &mut Unit) -> OnFailActionTaken {
926 let on_fail = match &unit.on_fail {
927 Some(action) => action.clone(),
928 None => {
929 refresh_attempt_pressure(unit);
930 return OnFailActionTaken::None;
931 }
932 };
933
934 let action_taken = match on_fail {
935 OnFailAction::Retry { max, delay_secs } => {
936 let max_retries = max.unwrap_or(unit.max_attempts);
937 if unit.attempts < max_retries {
938 unit.claimed_by = None;
939 unit.claimed_at = None;
940 OnFailActionTaken::Retry {
941 attempt: unit.attempts,
942 max: max_retries,
943 delay_secs,
944 }
945 } else {
946 OnFailActionTaken::RetryExhausted { max: max_retries }
947 }
948 }
949 OnFailAction::Escalate { priority, message } => {
950 if let Some(p) = priority {
951 unit.priority = p;
952 }
953 if let Some(msg) = &message {
954 let note = format!(
955 "\n## Escalated — {}\n{}",
956 Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
957 msg
958 );
959 match &mut unit.notes {
960 Some(notes) => notes.push_str(¬e),
961 None => unit.notes = Some(note),
962 }
963 }
964 if !unit.labels.contains(&"escalated".to_string()) {
965 unit.labels.push("escalated".to_string());
966 }
967 OnFailActionTaken::Escalated
968 }
969 };
970
971 refresh_attempt_pressure(unit);
972 action_taken
973}
974
975pub fn check_circuit_breaker(
981 mana_dir: &Path,
982 unit: &mut Unit,
983 root_id: &str,
984 max_loops: u32,
985) -> Result<CircuitBreakerStatus> {
986 if max_loops == 0 {
987 refresh_attempt_pressure(unit);
988 return Ok(CircuitBreakerStatus {
989 tripped: false,
990 subtree_total: 0,
991 max_loops: 0,
992 });
993 }
994
995 let subtree_total = graph::count_subtree_attempts(mana_dir, root_id)?;
996 if subtree_total >= max_loops {
997 if !unit.labels.contains(&"circuit-breaker".to_string()) {
998 unit.labels.push("circuit-breaker".to_string());
999 }
1000 unit.priority = 0;
1001 refresh_attempt_pressure(unit);
1002 Ok(CircuitBreakerStatus {
1003 tripped: true,
1004 subtree_total,
1005 max_loops,
1006 })
1007 } else {
1008 refresh_attempt_pressure(unit);
1009 Ok(CircuitBreakerStatus {
1010 tripped: false,
1011 subtree_total,
1012 max_loops,
1013 })
1014 }
1015}
1016
1017pub fn find_root_parent(mana_dir: &Path, unit: &Unit) -> Result<String> {
1022 let mut current_id = match &unit.parent {
1023 None => return Ok(unit.id.clone()),
1024 Some(pid) => pid.clone(),
1025 };
1026
1027 loop {
1028 let path = find_unit_file(mana_dir, ¤t_id)
1029 .or_else(|_| find_archived_unit(mana_dir, ¤t_id));
1030
1031 match path {
1032 Ok(p) => {
1033 let b = Unit::from_file(&p)
1034 .with_context(|| format!("Failed to load parent unit: {}", current_id))?;
1035 match b.parent {
1036 Some(parent_id) => current_id = parent_id,
1037 None => return Ok(current_id),
1038 }
1039 }
1040 Err(_) => return Ok(current_id),
1041 }
1042 }
1043}
1044
1045pub fn resolve_max_loops(mana_dir: &Path, unit: &Unit, root_id: &str, config_max: u32) -> u32 {
1047 if root_id == unit.id {
1048 unit.effective_max_loops(config_max)
1049 } else {
1050 let root_path =
1051 find_unit_file(mana_dir, root_id).or_else(|_| find_archived_unit(mana_dir, root_id));
1052 match root_path {
1053 Ok(p) => Unit::from_file(&p)
1054 .map(|b| b.effective_max_loops(config_max))
1055 .unwrap_or(config_max),
1056 Err(_) => config_max,
1057 }
1058 }
1059}
1060
1061fn record_failure_on_unit(unit: &mut Unit, failure: &VerifyFailure) {
1067 unit.attempts += 1;
1068 unit.updated_at = Utc::now();
1069
1070 let failure_note = format_failure_note(unit.attempts, failure.exit_code, &failure.output);
1072 match &mut unit.notes {
1073 Some(notes) => notes.push_str(&failure_note),
1074 None => unit.notes = Some(failure_note),
1075 }
1076
1077 let output_snippet = if failure.output.is_empty() {
1079 None
1080 } else {
1081 Some(truncate_output(&failure.output, 20))
1082 };
1083 unit.history.push(RunRecord {
1084 attempt: unit.attempts,
1085 started_at: failure.started_at,
1086 finished_at: Some(failure.finished_at),
1087 duration_secs: Some(failure.duration_secs),
1088 agent: failure.agent.clone(),
1089 result: if failure.timed_out {
1090 RunResult::Timeout
1091 } else {
1092 RunResult::Fail
1093 },
1094 exit_code: failure.exit_code,
1095 tokens: None,
1096 cost: None,
1097 output_snippet,
1098 autonomy_observation: None,
1099 });
1100 refresh_autonomy_disposition(unit);
1101}
1102
1103fn refresh_autonomy_disposition(unit: &mut Unit) {
1104 unit.refresh_autonomy_disposition();
1105}
1106
1107fn refresh_attempt_pressure(unit: &mut Unit) {
1108 refresh_autonomy_disposition(unit);
1109}
1110
1111fn capture_verify_outputs(unit: &mut Unit, stdout: &str) {
1113 let stdout = stdout.trim();
1114 if stdout.is_empty() {
1115 return;
1116 }
1117
1118 if stdout.len() > MAX_OUTPUT_BYTES {
1119 let end = truncate_to_char_boundary(stdout, MAX_OUTPUT_BYTES);
1120 let truncated = &stdout[..end];
1121 unit.outputs = Some(serde_json::json!({
1122 "text": truncated,
1123 "truncated": true,
1124 "original_bytes": stdout.len()
1125 }));
1126 } else {
1127 match serde_json::from_str::<serde_json::Value>(stdout) {
1128 Ok(json) => {
1129 unit.outputs = Some(json);
1130 }
1131 Err(_) => {
1132 unit.outputs = Some(serde_json::json!({
1133 "text": stdout
1134 }));
1135 }
1136 }
1137 }
1138}
1139
1140pub fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
1142 if max_bytes >= s.len() {
1143 return s.len();
1144 }
1145 let mut end = max_bytes;
1146 while !s.is_char_boundary(end) {
1147 end -= 1;
1148 }
1149 end
1150}
1151
1152pub fn truncate_output(output: &str, max_lines: usize) -> String {
1154 let lines: Vec<&str> = output.lines().collect();
1155
1156 if lines.len() <= max_lines * 2 {
1157 return output.to_string();
1158 }
1159
1160 let first = &lines[..max_lines];
1161 let last = &lines[lines.len() - max_lines..];
1162
1163 format!(
1164 "{}\n\n... ({} lines omitted) ...\n\n{}",
1165 first.join("\n"),
1166 lines.len() - max_lines * 2,
1167 last.join("\n")
1168 )
1169}
1170
1171pub fn format_failure_note(attempt: u32, exit_code: Option<i32>, output: &str) -> String {
1173 let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
1174 let truncated = truncate_output(output, 50);
1175 let exit_str = exit_code
1176 .map(|c| format!("Exit code: {}\n", c))
1177 .unwrap_or_default();
1178
1179 format!(
1180 "\n## Attempt {} — {}\n{}\n```\n{}\n```\n",
1181 attempt, timestamp, exit_str, truncated
1182 )
1183}
1184
1185fn run_pre_close_hook(unit: &Unit, project_root: &Path, reason: Option<&str>) -> HookDecision {
1191 let result = execute_hook(
1192 HookEvent::PreClose,
1193 unit,
1194 project_root,
1195 reason.map(|s| s.to_string()),
1196 );
1197
1198 match result {
1199 Ok(hook_passed) => HookDecision {
1200 accepted: hook_passed,
1201 warning: None,
1202 },
1203 Err(e) => HookDecision {
1204 accepted: true,
1205 warning: Some(CloseWarning::PreCloseHookError {
1206 message: e.to_string(),
1207 }),
1208 },
1209 }
1210}
1211
1212fn run_post_close_actions(
1214 unit: &Unit,
1215 project_root: &Path,
1216 reason: Option<&str>,
1217 config: Option<&Config>,
1218) -> PostCloseActionsReport {
1219 let mut warnings = Vec::new();
1220
1221 match execute_hook(
1223 HookEvent::PostClose,
1224 unit,
1225 project_root,
1226 reason.map(|s| s.to_string()),
1227 ) {
1228 Ok(false) => warnings.push(CloseWarning::PostCloseHookRejected),
1229 Err(e) => warnings.push(CloseWarning::PostCloseHookError {
1230 message: e.to_string(),
1231 }),
1232 Ok(true) => {}
1233 }
1234
1235 let mut on_close_results = Vec::new();
1237 for action in &unit.on_close {
1238 match action {
1239 OnCloseAction::Run { command } => {
1240 if !is_trusted(project_root) {
1241 on_close_results.push(OnCloseActionResult::Skipped {
1242 command: command.clone(),
1243 });
1244 continue;
1245 }
1246
1247 let status = std::process::Command::new("sh")
1248 .args(["-c", command.as_str()])
1249 .current_dir(project_root)
1250 .status();
1251 let result = match status {
1252 Ok(status) => OnCloseActionResult::RanCommand {
1253 command: command.clone(),
1254 success: status.success(),
1255 exit_code: status.code(),
1256 error: None,
1257 },
1258 Err(e) => OnCloseActionResult::RanCommand {
1259 command: command.clone(),
1260 success: false,
1261 exit_code: None,
1262 error: Some(e.to_string()),
1263 },
1264 };
1265 on_close_results.push(result);
1266 }
1267 OnCloseAction::Notify { message } => {
1268 on_close_results.push(OnCloseActionResult::Notified {
1269 message: message.clone(),
1270 });
1271 }
1272 }
1273 }
1274
1275 if let Some(config) = config {
1277 if let Some(ref on_close_template) = config.on_close {
1278 let vars = HookVars {
1279 id: Some(unit.id.clone()),
1280 title: Some(unit.title.clone()),
1281 status: Some("closed".into()),
1282 branch: current_git_branch(),
1283 ..Default::default()
1284 };
1285 execute_config_hook("on_close", on_close_template, &vars, project_root);
1286 }
1287 }
1288
1289 PostCloseActionsReport {
1290 warnings,
1291 on_close_results,
1292 }
1293}
1294
1295fn run_on_fail_hook(unit: &Unit, project_root: &Path, config: Option<&Config>, output: &str) {
1297 if let Some(config) = config {
1298 if let Some(ref on_fail_template) = config.on_fail {
1299 let vars = HookVars {
1300 id: Some(unit.id.clone()),
1301 title: Some(unit.title.clone()),
1302 status: Some(format!("{}", unit.status)),
1303 attempt: Some(unit.attempts),
1304 output: Some(output.to_string()),
1305 branch: current_git_branch(),
1306 ..Default::default()
1307 };
1308 execute_config_hook("on_fail", on_fail_template, &vars, project_root);
1309 }
1310 }
1311}
1312
1313fn detect_valid_worktree(project_root: &Path) -> Option<crate::worktree::WorktreeInfo> {
1319 let info = crate::worktree::detect_worktree(project_root).unwrap_or(None)?;
1320
1321 let canonical_root =
1322 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1323 if canonical_root.starts_with(&info.worktree_path) {
1324 Some(info)
1325 } else {
1326 None
1327 }
1328}
1329
1330fn handle_worktree_merge(
1332 wt_info: &crate::worktree::WorktreeInfo,
1333 unit: &Unit,
1334) -> Result<WorktreeMergeStatus> {
1335 let message = expand_commit_template(
1336 DEFAULT_COMMIT_TEMPLATE,
1337 &unit.id,
1338 &unit.title,
1339 unit.parent.as_deref(),
1340 &unit.labels,
1341 );
1342 crate::worktree::commit_worktree_changes(&wt_info.worktree_path, &message)?;
1343
1344 match crate::worktree::merge_to_main(wt_info, &unit.id)? {
1345 crate::worktree::MergeResult::Success | crate::worktree::MergeResult::NothingToCommit => {
1346 Ok(WorktreeMergeStatus::Merged)
1347 }
1348 crate::worktree::MergeResult::Conflict { files } => {
1349 Ok(WorktreeMergeStatus::Conflict { files })
1350 }
1351 }
1352}
1353
1354fn cleanup_worktree(wt_info: &crate::worktree::WorktreeInfo) -> Option<CloseWarning> {
1356 crate::worktree::cleanup_worktree(wt_info)
1357 .err()
1358 .map(|e| CloseWarning::WorktreeCleanupFailed {
1359 message: e.to_string(),
1360 })
1361}
1362
1363fn expand_commit_template(
1367 template: &str,
1368 id: &str,
1369 title: &str,
1370 parent_id: Option<&str>,
1371 labels: &[String],
1372) -> String {
1373 template
1374 .replace("{id}", id)
1375 .replace("{title}", title)
1376 .replace("{parent_id}", parent_id.unwrap_or(""))
1377 .replace("{labels}", &labels.join(","))
1378}
1379
1380fn relative_git_path(project_root: &Path, path: &Path) -> Option<String> {
1381 let relative = path.strip_prefix(project_root).ok()?;
1382 Some(relative.to_string_lossy().replace('\\', "/"))
1383}
1384
1385fn add_target_path(targets: &mut BTreeSet<String>, project_root: &Path, path: &Path) {
1386 if let Some(relative) = relative_git_path(project_root, path) {
1387 if !relative.is_empty() {
1388 targets.insert(relative);
1389 }
1390 }
1391}
1392
1393fn git_path_tracked(project_root: &Path, relative_path: &str) -> bool {
1394 std::process::Command::new("git")
1395 .arg("ls-files")
1396 .arg("--error-unmatch")
1397 .arg("--")
1398 .arg(relative_path)
1399 .current_dir(project_root)
1400 .stdout(std::process::Stdio::null())
1401 .stderr(std::process::Stdio::null())
1402 .status()
1403 .is_ok_and(|status| status.success())
1404}
1405
1406fn add_tracked_or_existing_target_path(
1407 targets: &mut BTreeSet<String>,
1408 project_root: &Path,
1409 path: &Path,
1410) {
1411 if let Some(relative) = relative_git_path(project_root, path) {
1412 if !relative.is_empty() && (path.exists() || git_path_tracked(project_root, &relative)) {
1413 targets.insert(relative);
1414 }
1415 }
1416}
1417
1418fn close_commit_target_paths(
1419 project_root: &Path,
1420 mana_dir: &Path,
1421 unit: &Unit,
1422 original_unit_path: &Path,
1423 archive_path: &Path,
1424 auto_closed_parents: &[String],
1425) -> Vec<String> {
1426 let mut targets = BTreeSet::new();
1427
1428 for path in &unit.paths {
1429 let path = path.trim();
1430 if !path.is_empty() {
1431 targets.insert(path.replace('\\', "/"));
1432 }
1433 }
1434
1435 add_tracked_or_existing_target_path(&mut targets, project_root, original_unit_path);
1436 add_tracked_or_existing_target_path(&mut targets, project_root, archive_path);
1437
1438 for relative in ["index.yaml", "archive.yaml"] {
1439 let path = mana_dir.join(relative);
1440 if path.exists() {
1441 add_target_path(&mut targets, project_root, &path);
1442 }
1443 }
1444
1445 for parent_id in auto_closed_parents {
1446 if let Ok(parent_archive_path) = find_archived_unit(mana_dir, parent_id) {
1447 add_target_path(&mut targets, project_root, &parent_archive_path);
1448 }
1449 }
1450
1451 targets.into_iter().collect()
1452}
1453
1454fn auto_commit_on_close(
1456 project_root: &Path,
1457 id: &str,
1458 title: &str,
1459 parent_id: Option<&str>,
1460 labels: &[String],
1461 template: Option<&str>,
1462 target_paths: &[String],
1463) -> AutoCommitResult {
1464 let message = expand_commit_template(
1465 template.unwrap_or(DEFAULT_COMMIT_TEMPLATE),
1466 id,
1467 title,
1468 parent_id,
1469 labels,
1470 );
1471
1472 if target_paths.is_empty() {
1473 return AutoCommitResult {
1474 message,
1475 committed: false,
1476 warning: None,
1477 };
1478 }
1479
1480 match crate::worktree::commit_worktree_paths(project_root, &message, target_paths) {
1481 Ok(committed) => AutoCommitResult {
1482 message,
1483 committed,
1484 warning: None,
1485 },
1486 Err(err) => AutoCommitResult {
1487 message,
1488 committed: false,
1489 warning: Some(format!("git targeted auto-commit failed: {err}")),
1490 },
1491 }
1492}
1493
1494fn rebuild_index(mana_dir: &Path) -> Result<()> {
1496 if mana_dir.exists() {
1497 let mut locked =
1498 LockedIndex::acquire(mana_dir).with_context(|| "Failed to acquire locked index")?;
1499 locked.index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
1500 locked
1501 .save_and_release()
1502 .with_context(|| "Failed to save index")?;
1503 }
1504 Ok(())
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510 use crate::config::{Config, DEFAULT_COMMIT_TEMPLATE};
1511 use crate::unit::{AutonomyBlockerCode, VerifyPosture};
1512 use std::fs;
1513 use tempfile::TempDir;
1514
1515 fn with_temp_home<T>(f: impl FnOnce() -> T) -> T {
1516 use std::sync::{Mutex, OnceLock};
1517
1518 static HOME_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1519 let guard = HOME_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
1520
1521 let home = tempfile::tempdir().unwrap();
1522 let old_home = std::env::var_os("HOME");
1523 std::env::set_var("HOME", home.path());
1524 let result = f();
1525 if let Some(old_home) = old_home {
1526 std::env::set_var("HOME", old_home);
1527 } else {
1528 std::env::remove_var("HOME");
1529 }
1530 drop(guard);
1531 result
1532 }
1533
1534 fn setup_mana_dir() -> (TempDir, PathBuf) {
1535 let dir = TempDir::new().unwrap();
1536 let mana_dir = dir.path().join(".mana");
1537 fs::create_dir(&mana_dir).unwrap();
1538 (dir, mana_dir)
1539 }
1540
1541 fn setup_mana_dir_with_config() -> (TempDir, PathBuf) {
1542 let dir = TempDir::new().unwrap();
1543 let mana_dir = dir.path().join(".mana");
1544 fs::create_dir(&mana_dir).unwrap();
1545
1546 Config {
1547 project: "test".to_string(),
1548 next_id: 100,
1549 auto_close_parent: true,
1550 run: None,
1551 plan: None,
1552 max_loops: 10,
1553 max_concurrent: 4,
1554 poll_interval: 30,
1555 extends: vec![],
1556 rules_file: None,
1557 file_locking: false,
1558 worktree: false,
1559 on_close: None,
1560 on_fail: None,
1561 verify_timeout: None,
1562 review: None,
1563 user: None,
1564 user_email: None,
1565 auto_commit: false,
1566 commit_template: None,
1567 research: None,
1568 run_model: None,
1569 plan_model: None,
1570 review_model: None,
1571 research_model: None,
1572 batch_verify: false,
1573 memory_reserve_mb: 0,
1574 notify: None,
1575 }
1576 .save(&mana_dir)
1577 .unwrap();
1578
1579 (dir, mana_dir)
1580 }
1581
1582 fn setup_git_mana_dir_with_config(config: Config) -> (TempDir, PathBuf) {
1583 let dir = TempDir::new().unwrap();
1584 let project_root = dir.path();
1585 let mana_dir = project_root.join(".mana");
1586 fs::create_dir(&mana_dir).unwrap();
1587 config.save(&mana_dir).unwrap();
1588
1589 run_git(project_root, &["init"]);
1590 run_git(project_root, &["config", "user.email", "test@test.com"]);
1591 run_git(project_root, &["config", "user.name", "Test"]);
1592
1593 fs::write(project_root.join("initial.txt"), "initial").unwrap();
1594 run_git(project_root, &["add", "-A"]);
1595 run_git(project_root, &["commit", "-m", "Initial commit"]);
1596
1597 (dir, mana_dir)
1598 }
1599
1600 fn run_git(dir: &Path, args: &[&str]) {
1601 let output = std::process::Command::new("git")
1602 .args(args)
1603 .current_dir(dir)
1604 .output()
1605 .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
1606 assert!(
1607 output.status.success(),
1608 "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1609 args,
1610 dir.display(),
1611 output.status.code(),
1612 String::from_utf8_lossy(&output.stdout),
1613 String::from_utf8_lossy(&output.stderr),
1614 );
1615 }
1616
1617 fn git_stdout(dir: &Path, args: &[&str]) -> String {
1618 let output = std::process::Command::new("git")
1619 .args(args)
1620 .current_dir(dir)
1621 .output()
1622 .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
1623 assert!(
1624 output.status.success(),
1625 "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1626 args,
1627 dir.display(),
1628 output.status.code(),
1629 String::from_utf8_lossy(&output.stdout),
1630 String::from_utf8_lossy(&output.stderr),
1631 );
1632 String::from_utf8(output.stdout).unwrap()
1633 }
1634
1635 fn write_unit(mana_dir: &Path, unit: &Unit) {
1636 let slug = title_to_slug(&unit.title);
1637 unit.to_file(mana_dir.join(format!("{}-{}.md", unit.id, slug)))
1638 .unwrap();
1639 }
1640
1641 #[test]
1646 fn close_single_unit() {
1647 let (_dir, mana_dir) = setup_mana_dir();
1648 let unit = Unit::new("1", "Task");
1649 write_unit(&mana_dir, &unit);
1650
1651 let result = close(
1652 &mana_dir,
1653 "1",
1654 CloseOpts {
1655 reason: None,
1656 force: false,
1657 defer_verify: false,
1658 },
1659 )
1660 .unwrap();
1661
1662 match result {
1663 CloseOutcome::Closed(r) => {
1664 assert_eq!(r.unit.status, Status::Closed);
1665 assert!(r.unit.closed_at.is_some());
1666 assert!(r.unit.is_archived);
1667 assert!(r.archive_path.exists());
1668 }
1669 _ => panic!("Expected Closed outcome"),
1670 }
1671 }
1672
1673 #[test]
1674 fn close_with_reason() {
1675 let (_dir, mana_dir) = setup_mana_dir();
1676 let unit = Unit::new("1", "Task");
1677 write_unit(&mana_dir, &unit);
1678
1679 let result = close(
1680 &mana_dir,
1681 "1",
1682 CloseOpts {
1683 reason: Some("Fixed".to_string()),
1684 force: false,
1685 defer_verify: false,
1686 },
1687 )
1688 .unwrap();
1689
1690 match result {
1691 CloseOutcome::Closed(r) => {
1692 assert_eq!(r.unit.close_reason, Some("Fixed".to_string()));
1693 }
1694 _ => panic!("Expected Closed outcome"),
1695 }
1696 }
1697
1698 #[test]
1699 fn close_with_passing_verify() {
1700 let (_dir, mana_dir) = setup_mana_dir();
1701 let mut unit = Unit::new("1", "Task");
1702 unit.verify = Some("true".to_string());
1703 write_unit(&mana_dir, &unit);
1704
1705 let result = close(
1706 &mana_dir,
1707 "1",
1708 CloseOpts {
1709 reason: None,
1710 force: false,
1711 defer_verify: false,
1712 },
1713 )
1714 .unwrap();
1715
1716 match result {
1717 CloseOutcome::Closed(r) => {
1718 assert_eq!(r.unit.status, Status::Closed);
1719 assert!(r.unit.is_archived);
1720 assert_eq!(r.unit.history.len(), 1);
1721 let record = &r.unit.history[0];
1722 assert_eq!(record.result, RunResult::Pass);
1723 let snippet = record.output_snippet.as_deref().unwrap_or("");
1724 assert!(snippet.contains("verify passed"));
1725 }
1726 _ => panic!("Expected Closed outcome"),
1727 }
1728 }
1729
1730 #[test]
1731 fn close_rejects_pass_ok_unit_with_no_non_mana_changes() {
1732 let config = Config {
1733 project: "test".to_string(),
1734 next_id: 100,
1735 auto_close_parent: true,
1736 run: None,
1737 plan: None,
1738 max_loops: 10,
1739 max_concurrent: 4,
1740 poll_interval: 30,
1741 extends: vec![],
1742 rules_file: None,
1743 file_locking: false,
1744 worktree: false,
1745 on_close: None,
1746 on_fail: None,
1747 verify_timeout: None,
1748 review: None,
1749 user: None,
1750 user_email: None,
1751 auto_commit: false,
1752 commit_template: None,
1753 research: None,
1754 run_model: None,
1755 plan_model: None,
1756 review_model: None,
1757 research_model: None,
1758 batch_verify: false,
1759 memory_reserve_mb: 0,
1760 notify: None,
1761 };
1762 let (dir, mana_dir) = setup_git_mana_dir_with_config(config);
1763 let checkpoint = git_stdout(dir.path(), &["rev-parse", "HEAD"])
1764 .trim()
1765 .to_string();
1766
1767 let mut unit = Unit::new("1", "Pass-ok no-op");
1768 unit.status = Status::InProgress;
1769 unit.verify = Some("true".to_string());
1770 unit.checkpoint = Some(checkpoint);
1771 write_unit(&mana_dir, &unit);
1772
1773 let result = close(
1774 &mana_dir,
1775 "1",
1776 CloseOpts {
1777 reason: None,
1778 force: false,
1779 defer_verify: false,
1780 },
1781 );
1782
1783 let err = result.unwrap_err().to_string();
1784 assert!(err.contains("no non-.mana changes were detected since claim"));
1785 }
1786
1787 #[test]
1788 fn close_allows_pass_ok_unit_when_non_mana_changes_exist() {
1789 let config = Config {
1790 project: "test".to_string(),
1791 next_id: 100,
1792 auto_close_parent: true,
1793 run: None,
1794 plan: None,
1795 max_loops: 10,
1796 max_concurrent: 4,
1797 poll_interval: 30,
1798 extends: vec![],
1799 rules_file: None,
1800 file_locking: false,
1801 worktree: false,
1802 on_close: None,
1803 on_fail: None,
1804 verify_timeout: None,
1805 review: None,
1806 user: None,
1807 user_email: None,
1808 auto_commit: false,
1809 commit_template: None,
1810 research: None,
1811 run_model: None,
1812 plan_model: None,
1813 review_model: None,
1814 research_model: None,
1815 batch_verify: false,
1816 memory_reserve_mb: 0,
1817 notify: None,
1818 };
1819 let (dir, mana_dir) = setup_git_mana_dir_with_config(config);
1820 let checkpoint = git_stdout(dir.path(), &["rev-parse", "HEAD"])
1821 .trim()
1822 .to_string();
1823 fs::write(dir.path().join("feature.txt"), "changed").unwrap();
1824
1825 let mut unit = Unit::new("1", "Pass-ok with changes");
1826 unit.status = Status::InProgress;
1827 unit.verify = Some("true".to_string());
1828 unit.checkpoint = Some(checkpoint);
1829 write_unit(&mana_dir, &unit);
1830
1831 let result = close(
1832 &mana_dir,
1833 "1",
1834 CloseOpts {
1835 reason: None,
1836 force: false,
1837 defer_verify: false,
1838 },
1839 )
1840 .unwrap();
1841
1842 match result {
1843 CloseOutcome::Closed(r) => assert_eq!(r.unit.status, Status::Closed),
1844 _ => panic!("Expected Closed outcome"),
1845 }
1846 }
1847
1848 #[test]
1849 fn close_with_failing_verify() {
1850 let (_dir, mana_dir) = setup_mana_dir();
1851 let mut unit = Unit::new("1", "Task");
1852 unit.verify = Some("false".to_string());
1853 write_unit(&mana_dir, &unit);
1854
1855 let result = close(
1856 &mana_dir,
1857 "1",
1858 CloseOpts {
1859 reason: None,
1860 force: false,
1861 defer_verify: false,
1862 },
1863 )
1864 .unwrap();
1865
1866 match result {
1867 CloseOutcome::VerifyFailed(r) => {
1868 assert_eq!(r.unit.status, Status::Open);
1869 assert_eq!(r.unit.attempts, 1);
1870 }
1871 _ => panic!("Expected VerifyFailed outcome"),
1872 }
1873 }
1874
1875 #[test]
1876 fn close_force_skips_verify() {
1877 let (_dir, mana_dir) = setup_mana_dir();
1878 let mut unit = Unit::new("1", "Task");
1879 unit.verify = Some("false".to_string());
1880 write_unit(&mana_dir, &unit);
1881
1882 let result = close(
1883 &mana_dir,
1884 "1",
1885 CloseOpts {
1886 reason: None,
1887 force: true,
1888 defer_verify: false,
1889 },
1890 )
1891 .unwrap();
1892
1893 match result {
1894 CloseOutcome::Closed(r) => {
1895 assert_eq!(r.unit.status, Status::Closed);
1896 assert!(r.unit.is_archived);
1897 assert_eq!(r.unit.attempts, 0);
1898 }
1899 _ => panic!("Expected Closed outcome"),
1900 }
1901 }
1902
1903 #[test]
1904 fn close_feature_returns_requires_human() {
1905 let (_dir, mana_dir) = setup_mana_dir();
1906 let mut unit = Unit::new("1", "Feature");
1907 unit.feature = true;
1908 write_unit(&mana_dir, &unit);
1909
1910 let result = close(
1911 &mana_dir,
1912 "1",
1913 CloseOpts {
1914 reason: None,
1915 force: false,
1916 defer_verify: false,
1917 },
1918 )
1919 .unwrap();
1920
1921 assert!(matches!(result, CloseOutcome::FeatureRequiresHuman { .. }));
1922 }
1923
1924 #[test]
1925 fn close_nonexistent_unit() {
1926 let (_dir, mana_dir) = setup_mana_dir();
1927 let result = close(
1928 &mana_dir,
1929 "99",
1930 CloseOpts {
1931 reason: None,
1932 force: false,
1933 defer_verify: false,
1934 },
1935 );
1936 assert!(result.is_err());
1937 }
1938
1939 #[test]
1944 fn close_failed_marks_unit_as_failed() {
1945 let (_dir, mana_dir) = setup_mana_dir();
1946 let mut unit = Unit::new("1", "Task");
1947 unit.status = Status::InProgress;
1948 unit.claimed_by = Some("agent-1".to_string());
1949 unit.attempt_log.push(crate::unit::AttemptRecord {
1950 num: 1,
1951 outcome: AttemptOutcome::Abandoned,
1952 notes: None,
1953 agent: Some("agent-1".to_string()),
1954 started_at: Some(Utc::now()),
1955 finished_at: None,
1956 autonomy_observation: None,
1957 });
1958 write_unit(&mana_dir, &unit);
1959
1960 let result = close_failed(&mana_dir, "1", Some("blocked".to_string())).unwrap();
1961 assert_eq!(result.status, Status::Open);
1962 assert!(result.claimed_by.is_none());
1963 assert_eq!(result.attempt_log[0].outcome, AttemptOutcome::Failed);
1964 assert!(result.attempt_log[0].finished_at.is_some());
1965 }
1966
1967 #[test]
1972 fn all_children_closed_when_no_children() {
1973 let (_dir, mana_dir) = setup_mana_dir();
1974 let unit = Unit::new("1", "Parent");
1975 write_unit(&mana_dir, &unit);
1976
1977 assert!(all_children_closed(&mana_dir, "1").unwrap());
1978 }
1979
1980 #[test]
1981 fn all_children_closed_when_some_open() {
1982 let (_dir, mana_dir) = setup_mana_dir();
1983 let parent = Unit::new("1", "Parent");
1984 write_unit(&mana_dir, &parent);
1985
1986 let mut child1 = Unit::new("1.1", "Child 1");
1987 child1.parent = Some("1".to_string());
1988 child1.status = Status::Closed;
1989 write_unit(&mana_dir, &child1);
1990
1991 let mut child2 = Unit::new("1.2", "Child 2");
1992 child2.parent = Some("1".to_string());
1993 write_unit(&mana_dir, &child2);
1994
1995 assert!(!all_children_closed(&mana_dir, "1").unwrap());
1996 }
1997
1998 #[test]
2003 fn auto_close_parents_when_all_children_closed() {
2004 let (_dir, mana_dir) = setup_mana_dir_with_config();
2005 let parent = Unit::new("1", "Parent");
2006 write_unit(&mana_dir, &parent);
2007
2008 let mut child = Unit::new("1.1", "Child");
2009 child.parent = Some("1".to_string());
2010 write_unit(&mana_dir, &child);
2011
2012 let _ = close(
2014 &mana_dir,
2015 "1.1",
2016 CloseOpts {
2017 reason: None,
2018 force: false,
2019 defer_verify: false,
2020 },
2021 )
2022 .unwrap();
2023
2024 let parent_archived = find_archived_unit(&mana_dir, "1");
2026 assert!(parent_archived.is_ok());
2027 let p = Unit::from_file(parent_archived.unwrap()).unwrap();
2028 assert_eq!(p.status, Status::Closed);
2029 assert!(p.close_reason.as_ref().unwrap().contains("Auto-closed"));
2030 }
2031
2032 #[test]
2033 fn auto_close_skips_feature_parents() {
2034 let (_dir, mana_dir) = setup_mana_dir_with_config();
2035 let mut parent = Unit::new("1", "Feature Parent");
2036 parent.feature = true;
2037 write_unit(&mana_dir, &parent);
2038
2039 let mut child = Unit::new("1.1", "Child");
2040 child.parent = Some("1".to_string());
2041 write_unit(&mana_dir, &child);
2042
2043 let _ = close(
2044 &mana_dir,
2045 "1.1",
2046 CloseOpts {
2047 reason: None,
2048 force: false,
2049 defer_verify: false,
2050 },
2051 )
2052 .unwrap();
2053
2054 let parent_still_open = find_unit_file(&mana_dir, "1");
2056 assert!(parent_still_open.is_ok());
2057 let p = Unit::from_file(parent_still_open.unwrap()).unwrap();
2058 assert_eq!(p.status, Status::Open);
2059 }
2060
2061 #[test]
2066 fn archive_unit_moves_and_marks() {
2067 let (_dir, mana_dir) = setup_mana_dir();
2068 let mut unit = Unit::new("1", "Task");
2069 unit.status = Status::Closed;
2070 let slug = title_to_slug(&unit.title);
2071 let unit_path = mana_dir.join(format!("1-{}.md", slug));
2072 unit.to_file(&unit_path).unwrap();
2073
2074 let archive_path = archive_unit(&mana_dir, &mut unit, &unit_path).unwrap();
2075 assert!(archive_path.exists());
2076 assert!(!unit_path.exists());
2077 assert!(unit.is_archived);
2078 }
2079
2080 #[test]
2085 fn record_failure_increments_attempts() {
2086 let mut unit = Unit::new("1", "Task");
2087 let failure = VerifyFailure {
2088 exit_code: Some(1),
2089 output: "error".to_string(),
2090 timed_out: false,
2091 duration_secs: 1.0,
2092 started_at: Utc::now(),
2093 finished_at: Utc::now(),
2094 agent: None,
2095 };
2096 record_failure(&mut unit, &failure);
2097 assert_eq!(unit.attempts, 1);
2098 assert_eq!(unit.history.len(), 1);
2099 assert_eq!(unit.history[0].result, RunResult::Fail);
2100 let disposition = unit
2101 .autonomy_disposition
2102 .expect("attempt pressure should be derived");
2103 assert_eq!(
2104 disposition.attempt_pressure,
2105 crate::unit::AttemptPressure::WithinBudget
2106 );
2107 assert_eq!(disposition.continuation_budget, Some(2));
2108 }
2109
2110 #[test]
2111 fn record_failure_timeout() {
2112 let mut unit = Unit::new("1", "Task");
2113 let failure = VerifyFailure {
2114 exit_code: None,
2115 output: "timed out".to_string(),
2116 timed_out: true,
2117 duration_secs: 30.0,
2118 started_at: Utc::now(),
2119 finished_at: Utc::now(),
2120 agent: None,
2121 };
2122 record_failure(&mut unit, &failure);
2123 assert_eq!(unit.history[0].result, RunResult::Timeout);
2124 }
2125
2126 #[test]
2131 fn process_on_fail_retry_releases_claim() {
2132 let mut unit = Unit::new("1", "Task");
2133 unit.on_fail = Some(OnFailAction::Retry {
2134 max: Some(5),
2135 delay_secs: None,
2136 });
2137 unit.attempts = 1;
2138 unit.claimed_by = Some("agent-1".to_string());
2139 unit.claimed_at = Some(Utc::now());
2140
2141 let result = process_on_fail(&mut unit);
2142 assert!(matches!(result, OnFailActionTaken::Retry { .. }));
2143 assert!(unit.claimed_by.is_none());
2144 let disposition = unit
2145 .autonomy_disposition
2146 .expect("attempt pressure should be present");
2147 assert_eq!(
2148 disposition.attempt_pressure,
2149 crate::unit::AttemptPressure::WithinBudget
2150 );
2151 assert_eq!(disposition.continuation_budget, Some(4));
2152 }
2153
2154 #[test]
2155 fn process_on_fail_escalate_sets_priority() {
2156 let mut unit = Unit::new("1", "Task");
2157 unit.on_fail = Some(OnFailAction::Escalate {
2158 priority: Some(0),
2159 message: None,
2160 });
2161 unit.priority = 2;
2162 unit.history.push(RunRecord {
2163 attempt: 1,
2164 started_at: Utc::now(),
2165 finished_at: Some(Utc::now()),
2166 duration_secs: Some(1.0),
2167 agent: None,
2168 result: RunResult::Fail,
2169 exit_code: Some(1),
2170 tokens: None,
2171 cost: None,
2172 output_snippet: None,
2173 autonomy_observation: None,
2174 });
2175
2176 let result = process_on_fail(&mut unit);
2177 assert!(matches!(result, OnFailActionTaken::Escalated));
2178 assert_eq!(unit.priority, 0);
2179 assert!(unit.labels.contains(&"escalated".to_string()));
2180 let disposition = unit
2181 .autonomy_disposition
2182 .expect("attempt pressure should be present");
2183 assert_eq!(
2184 disposition.attempt_pressure,
2185 crate::unit::AttemptPressure::Exhausted
2186 );
2187 assert!(disposition
2188 .blockers
2189 .contains(&crate::unit::AutonomyBlockerCode::AttemptBudgetExhausted));
2190 }
2191
2192 #[test]
2193 fn process_on_fail_none() {
2194 let mut unit = Unit::new("1", "Task");
2195 let result = process_on_fail(&mut unit);
2196 assert!(matches!(result, OnFailActionTaken::None));
2197 }
2198
2199 #[test]
2204 fn circuit_breaker_zero_disabled() {
2205 let (_dir, mana_dir) = setup_mana_dir();
2206 let mut unit = Unit::new("1", "Task");
2207 let result = check_circuit_breaker(&mana_dir, &mut unit, "1", 0).unwrap();
2208 assert!(!result.tripped);
2209 let disposition = unit
2210 .autonomy_disposition
2211 .expect("attempt pressure should be present");
2212 assert_eq!(
2213 disposition.attempt_pressure,
2214 crate::unit::AttemptPressure::WithinBudget
2215 );
2216 assert_eq!(disposition.continuation_budget, Some(3));
2217 }
2218
2219 #[test]
2220 fn circuit_breaker_sets_tripped_attempt_pressure() {
2221 let (_dir, mana_dir) = setup_mana_dir();
2222 let mut root = Unit::new("1", "Root");
2223 root.attempts = 2;
2224 write_unit(&mana_dir, &root);
2225
2226 let mut child = Unit::new("1.1", "Child");
2227 child.parent = Some("1".to_string());
2228 child.attempts = 1;
2229 write_unit(&mana_dir, &child);
2230
2231 let mut loaded_child = Unit::from_file(find_unit_file(&mana_dir, "1.1").unwrap()).unwrap();
2232 let result = check_circuit_breaker(&mana_dir, &mut loaded_child, "1", 3).unwrap();
2233 assert!(result.tripped);
2234 let disposition = loaded_child
2235 .autonomy_disposition
2236 .expect("attempt pressure should be present");
2237 assert_eq!(
2238 disposition.attempt_pressure,
2239 crate::unit::AttemptPressure::CircuitBreakerTripped
2240 );
2241 assert!(disposition
2242 .blockers
2243 .contains(&crate::unit::AutonomyBlockerCode::CircuitBreakerTripped));
2244 assert_eq!(disposition.continuation_budget, Some(0));
2245 }
2246
2247 #[test]
2252 fn truncate_to_char_boundary_ascii() {
2253 let s = "hello world";
2254 assert_eq!(truncate_to_char_boundary(s, 5), 5);
2255 }
2256
2257 #[test]
2258 fn truncate_to_char_boundary_multibyte() {
2259 let s = "😀😁😂";
2260 assert_eq!(truncate_to_char_boundary(s, 5), 4);
2261 }
2262
2263 #[test]
2264 fn truncate_output_short() {
2265 let output = "line1\nline2\nline3";
2266 let result = truncate_output(output, 50);
2267 assert_eq!(result, output);
2268 }
2269
2270 #[test]
2271 fn format_failure_note_includes_exit_code() {
2272 let note = format_failure_note(1, Some(1), "error message");
2273 assert!(note.contains("## Attempt 1"));
2274 assert!(note.contains("Exit code: 1"));
2275 assert!(note.contains("error message"));
2276 }
2277
2278 #[test]
2279 fn expand_commit_template_substitutes_all_placeholders() {
2280 let message = expand_commit_template(
2281 "feat(unit-{id}): {title} [{parent_id}] {labels}",
2282 "2.3",
2283 "Ship it",
2284 Some("2"),
2285 &["feature".to_string(), "git".to_string()],
2286 );
2287
2288 assert_eq!(message, "feat(unit-2.3): Ship it [2] feature,git");
2289 }
2290
2291 #[test]
2292 fn close_auto_commit_preserves_preexisting_staged_changes() {
2293 with_temp_home(|| {
2294 let config = Config {
2295 project: "test".to_string(),
2296 next_id: 100,
2297 auto_commit: true,
2298 ..Config::default()
2299 };
2300 let (_dir, mana_dir) = setup_git_mana_dir_with_config(config);
2301 let project_root = mana_dir.parent().unwrap();
2302
2303 let unit = Unit::new("1", "Close Me");
2304 write_unit(&mana_dir, &unit);
2305 run_git(project_root, &["add", ".mana"]);
2306 run_git(project_root, &["commit", "-m", "Add unit"]);
2307
2308 fs::write(project_root.join("staged.txt"), "preexisting staged").unwrap();
2309 fs::write(project_root.join("dirty.txt"), "preexisting dirty").unwrap();
2310 run_git(project_root, &["add", "staged.txt"]);
2311
2312 let result = close(
2313 &mana_dir,
2314 "1",
2315 CloseOpts {
2316 reason: None,
2317 force: false,
2318 defer_verify: false,
2319 },
2320 )
2321 .unwrap();
2322
2323 let close_result = match result {
2324 CloseOutcome::Closed(result) => result,
2325 other => panic!("Expected Closed outcome, got {:?}", other),
2326 };
2327 let auto_commit = close_result.auto_commit_result.expect("auto-commit result");
2328 assert!(auto_commit.warning.is_none(), "{:?}", auto_commit.warning);
2329 assert!(auto_commit.committed);
2330
2331 let changed_files =
2332 git_stdout(project_root, &["show", "--name-only", "--format=", "HEAD"]);
2333 assert!(changed_files.contains(".mana/archive"), "{changed_files}");
2334 assert!(!changed_files.contains("staged.txt"), "{changed_files}");
2335 assert!(!changed_files.contains("dirty.txt"), "{changed_files}");
2336
2337 let status = git_stdout(project_root, &["status", "--short"]);
2338 assert!(status.contains("A staged.txt"), "{status}");
2339 assert!(status.contains("?? dirty.txt"), "{status}");
2340 });
2341 }
2342
2343 #[test]
2344 fn close_auto_commit_uses_default_template_and_includes_index_updates() {
2345 with_temp_home(|| {
2346 let config = Config {
2347 project: "test".to_string(),
2348 next_id: 100,
2349 auto_commit: true,
2350 ..Config::default()
2351 };
2352 let (_dir, mana_dir) = setup_git_mana_dir_with_config(config);
2353 let project_root = mana_dir.parent().unwrap();
2354
2355 let parent = Unit::new("1", "Parent");
2356 write_unit(&mana_dir, &parent);
2357
2358 let mut child = Unit::new("1.1", "Child");
2359 child.parent = Some("1".to_string());
2360 write_unit(&mana_dir, &child);
2361
2362 fs::write(project_root.join("unrelated.txt"), "do not commit").unwrap();
2363
2364 let result = close(
2365 &mana_dir,
2366 "1.1",
2367 CloseOpts {
2368 reason: None,
2369 force: false,
2370 defer_verify: false,
2371 },
2372 )
2373 .unwrap();
2374
2375 let close_result = match result {
2376 CloseOutcome::Closed(result) => result,
2377 other => panic!("Expected Closed outcome, got {:?}", other),
2378 };
2379 let auto_commit = close_result
2380 .auto_commit_result
2381 .expect("auto-commit result should be present when enabled");
2382 assert!(auto_commit.warning.is_none(), "{:?}", auto_commit.warning);
2383 assert!(auto_commit.committed);
2384 assert_eq!(
2385 auto_commit.message,
2386 DEFAULT_COMMIT_TEMPLATE
2387 .replace("{id}", "1.1")
2388 .replace("{title}", "Child")
2389 );
2390 assert_eq!(close_result.auto_closed_parents, vec!["1".to_string()]);
2391
2392 let head_subject = git_stdout(project_root, &["log", "-1", "--pretty=%s"]);
2393 assert_eq!(head_subject.trim(), "feat(unit-1.1): Child");
2394
2395 let changed_files =
2396 git_stdout(project_root, &["show", "--name-only", "--format=", "HEAD"]);
2397 assert!(
2398 changed_files.contains(".mana/index.yaml"),
2399 "{changed_files}"
2400 );
2401 assert!(changed_files.contains("1-parent.md"), "{changed_files}");
2402 assert!(changed_files.contains("1.1-child.md"), "{changed_files}");
2403 assert!(!changed_files.contains("unrelated.txt"), "{changed_files}");
2404
2405 let status = git_stdout(project_root, &["status", "--short"]);
2406 assert!(status.contains("?? unrelated.txt"), "{status}");
2407 });
2408 }
2409
2410 #[test]
2417 fn close_defer_skips_verify() {
2418 let (_dir, mana_dir) = setup_mana_dir();
2419 let mut unit = Unit::new("1", "Task");
2420 unit.verify = Some("false".to_string());
2422 write_unit(&mana_dir, &unit);
2423
2424 let outcome = close(
2425 &mana_dir,
2426 "1",
2427 CloseOpts {
2428 reason: None,
2429 force: false,
2430 defer_verify: true,
2431 },
2432 )
2433 .unwrap();
2434
2435 match outcome {
2437 CloseOutcome::DeferredVerify { .. } => {}
2438 other => panic!("Expected DeferredVerify outcome, got {:?}", other),
2439 }
2440
2441 let saved = Unit::from_file(
2443 find_unit_file(&mana_dir, "1").expect("unit file should still be in active dir"),
2444 )
2445 .unwrap();
2446 assert_eq!(saved.status, Status::AwaitingVerify);
2447 let disposition = saved
2448 .autonomy_disposition
2449 .expect("deferred verify should persist autonomy disposition");
2450 assert_eq!(disposition.verify, VerifyPosture::Deferred);
2451 assert!(disposition
2452 .blockers
2453 .contains(&AutonomyBlockerCode::VerifyDeferred));
2454 assert_eq!(saved.attempts, 0);
2456 }
2457
2458 #[test]
2461 fn close_defer_returns_outcome() {
2462 let (_dir, mana_dir) = setup_mana_dir();
2463 let unit = Unit::new("42", "Deferred Task");
2464 write_unit(&mana_dir, &unit);
2465
2466 let outcome = close(
2467 &mana_dir,
2468 "42",
2469 CloseOpts {
2470 reason: None,
2471 force: false,
2472 defer_verify: true,
2473 },
2474 )
2475 .unwrap();
2476
2477 match outcome {
2478 CloseOutcome::DeferredVerify { unit_id } => {
2479 assert_eq!(unit_id, "42");
2480 }
2481 other => panic!("Expected DeferredVerify outcome, got {:?}", other),
2482 }
2483 }
2484
2485 #[test]
2486 fn close_verify_frozen_violation_persists_autonomy_gate() {
2487 let (_dir, mana_dir) = setup_mana_dir();
2488 let mut unit = Unit::new("1", "Frozen verify");
2489 unit.verify = Some("true".to_string());
2490 let mut hasher = Sha256::new();
2491 hasher.update("false".as_bytes());
2492 unit.verify_hash = Some(format!("{:x}", hasher.finalize()));
2493 write_unit(&mana_dir, &unit);
2494
2495 let outcome = close(
2496 &mana_dir,
2497 "1",
2498 CloseOpts {
2499 reason: None,
2500 force: false,
2501 defer_verify: false,
2502 },
2503 )
2504 .unwrap();
2505
2506 match outcome {
2507 CloseOutcome::VerifyFrozenViolation { unit_id, .. } => {
2508 assert_eq!(unit_id, "1");
2509 }
2510 other => panic!("Expected VerifyFrozenViolation outcome, got {:?}", other),
2511 }
2512
2513 let saved = Unit::from_file(find_unit_file(&mana_dir, "1").unwrap()).unwrap();
2514 let disposition = saved
2515 .autonomy_disposition
2516 .expect("frozen violation should persist autonomy disposition");
2517 assert_eq!(disposition.verify, VerifyPosture::FrozenViolation);
2518 assert!(disposition
2519 .blockers
2520 .contains(&AutonomyBlockerCode::VerifyFrozenViolation));
2521 }
2522
2523 #[test]
2524 fn close_defer_normal_unchanged() {
2525 let (_dir, mana_dir) = setup_mana_dir();
2526 let mut unit = Unit::new("1", "Task");
2527 unit.verify = Some("false".to_string());
2528 write_unit(&mana_dir, &unit);
2529
2530 let outcome = close(
2531 &mana_dir,
2532 "1",
2533 CloseOpts {
2534 reason: None,
2535 force: false,
2536 defer_verify: false,
2537 },
2538 )
2539 .unwrap();
2540
2541 match outcome {
2542 CloseOutcome::VerifyFailed(r) => {
2543 assert_eq!(r.unit.status, Status::Open);
2544 assert_eq!(r.unit.attempts, 1);
2545 let disposition = r
2546 .unit
2547 .autonomy_disposition
2548 .expect("verify failure should persist autonomy disposition");
2549 assert_eq!(disposition.verify, VerifyPosture::Failed);
2550 assert!(disposition
2551 .blockers
2552 .contains(&AutonomyBlockerCode::VerifyFailed));
2553 }
2554 other => panic!("Expected VerifyFailed outcome, got {:?}", other),
2555 }
2556 }
2557}