Skip to main content

mana_core/ops/
close.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5
6use crate::config::{Config, DEFAULT_COMMIT_TEMPLATE};
7use crate::discovery::{archive_path_for_unit, find_archived_unit, find_unit_file};
8use crate::graph;
9use crate::hooks::{
10    current_git_branch, execute_config_hook, execute_hook, is_trusted, HookEvent, HookVars,
11};
12use crate::index::{ArchiveIndex, Index, IndexEntry};
13use crate::ops::verify::run_verify_command;
14use crate::unit::{
15    AttemptOutcome, OnCloseAction, OnFailAction, RunRecord, RunResult, Status, Unit,
16};
17use crate::util::title_to_slug;
18
19// ---------------------------------------------------------------------------
20// Public types
21// ---------------------------------------------------------------------------
22
23/// What action was taken by `process_on_fail`.
24#[derive(Debug)]
25pub enum OnFailActionTaken {
26    /// Claim released for retry (attempt N / max M).
27    Retry {
28        attempt: u32,
29        max: u32,
30        delay_secs: Option<u64>,
31    },
32    /// Max retries exhausted — claim kept.
33    RetryExhausted { max: u32 },
34    /// Priority escalated and/or message appended.
35    Escalated,
36    /// No on_fail configured.
37    None,
38}
39
40/// Result of a circuit breaker check.
41#[derive(Debug)]
42pub struct CircuitBreakerStatus {
43    pub tripped: bool,
44    pub subtree_total: u32,
45    pub max_loops: u32,
46}
47
48/// Metadata about a verify failure, used by `record_failure`.
49#[derive(Debug)]
50pub struct VerifyFailure {
51    pub exit_code: Option<i32>,
52    pub output: String,
53    pub timed_out: bool,
54    pub duration_secs: f64,
55    pub started_at: chrono::DateTime<Utc>,
56    pub finished_at: chrono::DateTime<Utc>,
57    pub agent: Option<String>,
58}
59
60/// Options for the full `close` lifecycle.
61pub struct CloseOpts {
62    pub reason: Option<String>,
63    pub force: bool,
64    /// Skip verify and mark as AwaitingVerify instead of Closed.
65    ///
66    /// Set when `--defer-verify` is passed or `MANA_BATCH_VERIFY=1` is in the environment.
67    /// The runner is responsible for running verify later and finalizing the unit.
68    pub defer_verify: bool,
69}
70
71/// Structured warnings emitted during close lifecycle steps.
72#[derive(Debug)]
73pub enum CloseWarning {
74    /// The pre-close hook errored, but close was allowed to continue.
75    PreCloseHookError { message: String },
76    /// The post-close hook returned a non-zero exit status.
77    PostCloseHookRejected,
78    /// The post-close hook errored.
79    PostCloseHookError { message: String },
80    /// Worktree cleanup failed after a successful close.
81    WorktreeCleanupFailed { message: String },
82}
83
84/// Result of an auto-commit attempt after close.
85#[derive(Debug)]
86pub struct AutoCommitResult {
87    pub message: String,
88    pub committed: bool,
89    pub warning: Option<String>,
90}
91
92/// Outcome of attempting to close a single unit.
93#[derive(Debug)]
94pub enum CloseOutcome {
95    /// The unit was closed and archived.
96    Closed(CloseResult),
97    /// The verify command failed.
98    VerifyFailed(VerifyFailureResult),
99    /// The pre-close hook rejected the close.
100    RejectedByHook { unit_id: String },
101    /// Feature unit requires interactive TTY confirmation.
102    FeatureRequiresHuman {
103        unit_id: String,
104        title: String,
105        warnings: Vec<CloseWarning>,
106    },
107    /// Circuit breaker tripped — too many attempts across the subtree.
108    CircuitBreakerTripped {
109        unit_id: String,
110        total_attempts: u32,
111        max: u32,
112        warnings: Vec<CloseWarning>,
113    },
114    /// Worktree merge had conflicts — unit stays open.
115    MergeConflict {
116        files: Vec<String>,
117        warnings: Vec<CloseWarning>,
118    },
119    /// Verify was deferred — unit is now AwaitingVerify.
120    ///
121    /// Emitted when `CloseOpts::defer_verify` is true. The runner is expected to
122    /// run verify later and transition the unit to Closed or back to Open.
123    DeferredVerify { unit_id: String },
124}
125
126/// Details of a successful close.
127#[derive(Debug)]
128pub struct CloseResult {
129    pub unit: Unit,
130    pub archive_path: PathBuf,
131    pub auto_closed_parents: Vec<String>,
132    pub on_close_results: Vec<OnCloseActionResult>,
133    pub warnings: Vec<CloseWarning>,
134    pub auto_commit_result: Option<AutoCommitResult>,
135}
136
137/// Result of one on_close action execution.
138#[derive(Debug)]
139pub enum OnCloseActionResult {
140    /// A `run` command was executed.
141    RanCommand {
142        command: String,
143        success: bool,
144        exit_code: Option<i32>,
145        error: Option<String>,
146    },
147    /// A `notify` message was emitted.
148    Notified { message: String },
149    /// A `run` command was skipped (not trusted).
150    Skipped { command: String },
151}
152
153/// Details of a verify failure during close.
154#[derive(Debug)]
155pub struct VerifyFailureResult {
156    pub unit: Unit,
157    pub attempt_number: u32,
158    pub exit_code: Option<i32>,
159    pub output: String,
160    pub timed_out: bool,
161    pub on_fail_action_taken: Option<OnFailActionTaken>,
162    pub verify_command: String,
163    pub timeout_secs: Option<u64>,
164    pub warnings: Vec<CloseWarning>,
165}
166
167struct HookDecision {
168    accepted: bool,
169    warning: Option<CloseWarning>,
170}
171
172struct PostCloseActionsReport {
173    warnings: Vec<CloseWarning>,
174    on_close_results: Vec<OnCloseActionResult>,
175}
176
177enum WorktreeMergeStatus {
178    Merged,
179    Conflict { files: Vec<String> },
180}
181
182/// Maximum stdout size to capture as outputs (64 KB).
183const MAX_OUTPUT_BYTES: usize = 64 * 1024;
184
185// ---------------------------------------------------------------------------
186// Core close lifecycle
187// ---------------------------------------------------------------------------
188
189/// Close a single unit — the full lifecycle.
190///
191/// Steps: pre-close hook → verify → worktree merge → feature gate → mark closed
192/// → archive → post-close cascade → auto-close parents → rebuild index.
193///
194/// Does NOT handle TTY confirmation for feature units — if the unit is a feature,
195/// returns `CloseOutcome::FeatureRequiresHuman` and the caller decides.
196pub fn close(mana_dir: &Path, id: &str, opts: CloseOpts) -> Result<CloseOutcome> {
197    let project_root = mana_dir
198        .parent()
199        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root from units dir"))?;
200
201    let config = Config::load(mana_dir).ok();
202
203    let unit_path =
204        find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
205    let mut unit =
206        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
207
208    // 1. Pre-close hook
209    let pre_close = run_pre_close_hook(&unit, project_root, opts.reason.as_deref());
210    if !pre_close.accepted {
211        return Ok(CloseOutcome::RejectedByHook {
212            unit_id: id.to_string(),
213        });
214    }
215
216    let mut warnings = Vec::new();
217    if let Some(warning) = pre_close.warning {
218        warnings.push(warning);
219    }
220
221    // 1b. Defer verify — mark as AwaitingVerify and return immediately.
222    //
223    // The runner will collect all AwaitingVerify units after agents complete,
224    // run each unique verify command once, and finalize units accordingly.
225    if opts.defer_verify {
226        unit.status = Status::AwaitingVerify;
227        unit.updated_at = Utc::now();
228        unit.to_file(&unit_path)
229            .with_context(|| format!("Failed to save unit: {}", id))?;
230        rebuild_index(mana_dir)?;
231        return Ok(CloseOutcome::DeferredVerify {
232            unit_id: id.to_string(),
233        });
234    }
235
236    // 2. Verify (if applicable and not force)
237    if let Some(verify_cmd) = unit.verify.clone() {
238        if !verify_cmd.trim().is_empty() && !opts.force {
239            let timeout_secs =
240                unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
241
242            let started_at = Utc::now();
243            let verify_result = run_verify_command(&verify_cmd, project_root, timeout_secs)?;
244            let finished_at = Utc::now();
245            let duration_secs = (finished_at - started_at).num_milliseconds() as f64 / 1000.0;
246            let agent = std::env::var("MANA_AGENT").ok();
247
248            if !verify_result.passed {
249                // Build combined output — on timeout, synthesize a message
250                let combined_output = if verify_result.timed_out {
251                    format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
252                } else {
253                    let stdout = verify_result.stdout.trim();
254                    let stderr = verify_result.stderr.trim();
255                    let sep = if !stdout.is_empty() && !stderr.is_empty() {
256                        "\n"
257                    } else {
258                        ""
259                    };
260                    format!("{}{}{}", stdout, sep, stderr)
261                };
262
263                // Record the failure
264                let failure = VerifyFailure {
265                    exit_code: verify_result.exit_code,
266                    output: combined_output,
267                    timed_out: verify_result.timed_out,
268                    duration_secs,
269                    started_at,
270                    finished_at,
271                    agent,
272                };
273                record_failure_on_unit(&mut unit, &failure);
274
275                // Circuit breaker
276                let root_id = find_root_parent(mana_dir, &unit)?;
277                let config_max = config.as_ref().map(|c| c.max_loops).unwrap_or(10);
278                let max_loops_limit = resolve_max_loops(mana_dir, &unit, &root_id, config_max);
279
280                if max_loops_limit > 0 {
281                    // Save unit first so subtree count is accurate
282                    unit.to_file(&unit_path)
283                        .with_context(|| format!("Failed to save unit: {}", id))?;
284
285                    let cb = check_circuit_breaker(mana_dir, &mut unit, &root_id, max_loops_limit)?;
286                    if cb.tripped {
287                        unit.to_file(&unit_path)
288                            .with_context(|| format!("Failed to save unit: {}", id))?;
289
290                        // Rebuild index
291                        rebuild_index(mana_dir)?;
292
293                        return Ok(CloseOutcome::CircuitBreakerTripped {
294                            unit_id: id.to_string(),
295                            total_attempts: cb.subtree_total,
296                            max: cb.max_loops,
297                            warnings,
298                        });
299                    }
300                }
301
302                // Process on_fail action
303                let action_taken = process_on_fail(&mut unit);
304
305                unit.to_file(&unit_path)
306                    .with_context(|| format!("Failed to save unit: {}", id))?;
307
308                // Fire on_fail config hook
309                run_on_fail_hook(&unit, project_root, config.as_ref(), &failure.output);
310
311                // Rebuild index
312                rebuild_index(mana_dir)?;
313
314                return Ok(CloseOutcome::VerifyFailed(VerifyFailureResult {
315                    attempt_number: unit.attempts,
316                    exit_code: failure.exit_code,
317                    output: failure.output,
318                    timed_out: failure.timed_out,
319                    on_fail_action_taken: Some(action_taken),
320                    verify_command: verify_cmd,
321                    timeout_secs,
322                    warnings,
323                    unit,
324                }));
325            }
326
327            // Record success in history
328            unit.history.push(RunRecord {
329                attempt: unit.attempts + 1,
330                started_at,
331                finished_at: Some(finished_at),
332                duration_secs: Some(duration_secs),
333                agent,
334                result: RunResult::Pass,
335                exit_code: verify_result.exit_code,
336                tokens: None,
337                cost: None,
338                output_snippet: None,
339            });
340
341            // Capture stdout as unit outputs
342            capture_verify_outputs(&mut unit, &verify_result.stdout);
343        }
344    }
345
346    // 3. Worktree merge (after verify passes, before archiving)
347    let worktree_info = detect_valid_worktree(project_root);
348    if let Some(ref wt_info) = worktree_info {
349        match handle_worktree_merge(wt_info, &unit)? {
350            WorktreeMergeStatus::Merged => {}
351            WorktreeMergeStatus::Conflict { files } => {
352                return Ok(CloseOutcome::MergeConflict { files, warnings });
353            }
354        }
355    }
356
357    // 4. Feature gate — delegate to caller
358    if unit.feature {
359        use std::io::IsTerminal;
360        if !opts.force || !std::io::stdin().is_terminal() {
361            return Ok(CloseOutcome::FeatureRequiresHuman {
362                unit_id: unit.id.clone(),
363                title: unit.title.clone(),
364                warnings,
365            });
366        }
367    }
368
369    // 5. Mark the unit closed
370    let now = Utc::now();
371    unit.status = Status::Closed;
372    unit.closed_at = Some(now);
373    unit.close_reason = opts.reason.clone();
374    unit.updated_at = now;
375
376    // Finalize the current attempt as success
377    if let Some(attempt) = unit.attempt_log.last_mut() {
378        if attempt.finished_at.is_none() {
379            attempt.outcome = AttemptOutcome::Success;
380            attempt.finished_at = Some(now);
381            attempt.notes = opts.reason.clone();
382        }
383    }
384
385    // Update last_verified for facts
386    if unit.unit_type == "fact" {
387        unit.last_verified = Some(now);
388    }
389
390    unit.to_file(&unit_path)
391        .with_context(|| format!("Failed to save unit: {}", id))?;
392
393    // 6. Archive
394    let archive_path = archive_unit(mana_dir, &mut unit, &unit_path)?;
395
396    // 7. Post-close cascade
397    let post_close =
398        run_post_close_actions(&unit, project_root, opts.reason.as_deref(), config.as_ref());
399    warnings.extend(post_close.warnings);
400
401    // Clean up worktree after successful close
402    if let Some(ref wt_info) = worktree_info {
403        if let Some(warning) = cleanup_worktree(wt_info) {
404            warnings.push(warning);
405        }
406    }
407
408    // 8. Auto-close parents
409    let auto_closed_parents = if mana_dir.exists() {
410        if let Some(parent_id) = &unit.parent {
411            let auto_close_enabled = config.as_ref().map(|c| c.auto_close_parent).unwrap_or(true);
412            if auto_close_enabled {
413                auto_close_parents(mana_dir, parent_id)?
414            } else {
415                vec![]
416            }
417        } else {
418            vec![]
419        }
420    } else {
421        vec![]
422    };
423
424    // Rebuild index before auto-commit so archived units, parent cascades, and
425    // index updates are included in the close commit.
426    rebuild_index(mana_dir)?;
427
428    // Auto-commit if configured (skip in worktree mode — it already commits)
429    let auto_commit_result = if worktree_info.is_none() {
430        let auto_commit_enabled = config.as_ref().map(|c| c.auto_commit).unwrap_or(false);
431        if auto_commit_enabled {
432            let template = config.as_ref().and_then(|c| c.commit_template.clone());
433            Some(auto_commit_on_close(
434                project_root,
435                id,
436                &unit.title,
437                unit.parent.as_deref(),
438                &unit.labels,
439                template.as_deref(),
440            ))
441        } else {
442            None
443        }
444    } else {
445        None
446    };
447
448    Ok(CloseOutcome::Closed(CloseResult {
449        unit,
450        archive_path,
451        auto_closed_parents,
452        on_close_results: post_close.on_close_results,
453        warnings,
454        auto_commit_result,
455    }))
456}
457
458/// Mark a unit as explicitly failed. Stays open with claim released.
459///
460/// Records the failure in attempt_log for episodic memory and appends
461/// a structured failure summary to notes.
462pub fn close_failed(mana_dir: &Path, id: &str, reason: Option<String>) -> Result<Unit> {
463    let now = Utc::now();
464
465    let unit_path =
466        find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
467    let mut unit =
468        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
469
470    // Finalize the current attempt as failed
471    if let Some(attempt) = unit.attempt_log.last_mut() {
472        if attempt.finished_at.is_none() {
473            attempt.outcome = AttemptOutcome::Failed;
474            attempt.finished_at = Some(now);
475            attempt.notes = reason.clone();
476        }
477    }
478
479    // Release the claim (unit stays open for retry)
480    unit.claimed_by = None;
481    unit.claimed_at = None;
482    unit.status = Status::Open;
483    unit.updated_at = now;
484
485    // Generate structured failure summary and append to notes
486    {
487        let attempt_num = unit.attempt_log.len() as u32;
488        let duration_secs = unit
489            .attempt_log
490            .last()
491            .and_then(|a| a.started_at)
492            .map(|started| (now - started).num_seconds().max(0) as u64)
493            .unwrap_or(0);
494
495        let ctx = crate::failure::FailureContext {
496            unit_id: id.to_string(),
497            unit_title: unit.title.clone(),
498            attempt: attempt_num.max(1),
499            duration_secs,
500            tool_count: 0,
501            turns: 0,
502            input_tokens: 0,
503            output_tokens: 0,
504            cost: 0.0,
505            error: reason,
506            tool_log: vec![],
507            verify_command: unit.verify.clone(),
508        };
509        let summary = crate::failure::build_failure_summary(&ctx);
510
511        match &mut unit.notes {
512            Some(notes) => {
513                notes.push('\n');
514                notes.push_str(&summary);
515            }
516            None => unit.notes = Some(summary),
517        }
518    }
519
520    unit.to_file(&unit_path)
521        .with_context(|| format!("Failed to save unit: {}", id))?;
522
523    // Rebuild index
524    rebuild_index(mana_dir)?;
525
526    Ok(unit)
527}
528
529// ---------------------------------------------------------------------------
530// Public composable functions
531// ---------------------------------------------------------------------------
532
533/// Check if all children of a parent unit are closed.
534///
535/// Checks both active and archived units. Returns true if the parent has no
536/// children, or if all children have status=closed.
537pub fn all_children_closed(mana_dir: &Path, parent_id: &str) -> Result<bool> {
538    let index = Index::build(mana_dir)?;
539    let archived = Index::collect_archived(mana_dir).unwrap_or_default();
540
541    let mut all_units = index.units;
542    all_units.extend(archived);
543
544    let children: Vec<_> = all_units
545        .iter()
546        .filter(|b| b.parent.as_deref() == Some(parent_id))
547        .collect();
548
549    if children.is_empty() {
550        return Ok(true);
551    }
552
553    for child in children {
554        if child.status != Status::Closed {
555            return Ok(false);
556        }
557    }
558
559    Ok(true)
560}
561
562/// Auto-close parent chain when all children are done.
563///
564/// Recursively walks up the parent chain, closing and archiving each parent
565/// whose children are all closed. Feature parents are skipped. Returns the
566/// list of parent IDs that were auto-closed.
567pub fn auto_close_parents(mana_dir: &Path, parent_id: &str) -> Result<Vec<String>> {
568    let mut closed = Vec::new();
569    auto_close_parent_recursive(mana_dir, parent_id, &mut closed)?;
570    Ok(closed)
571}
572
573fn auto_close_parent_recursive(
574    mana_dir: &Path,
575    parent_id: &str,
576    closed: &mut Vec<String>,
577) -> Result<()> {
578    if !all_children_closed(mana_dir, parent_id)? {
579        return Ok(());
580    }
581
582    let unit_path = match find_unit_file(mana_dir, parent_id) {
583        Ok(path) => path,
584        Err(_) => return Ok(()), // Already archived
585    };
586
587    let mut unit = Unit::from_file(&unit_path)
588        .with_context(|| format!("Failed to load parent unit: {}", parent_id))?;
589
590    if unit.status == Status::Closed {
591        return Ok(());
592    }
593
594    // Feature units are never auto-closed
595    if unit.feature {
596        return Ok(());
597    }
598
599    let now = Utc::now();
600    unit.status = Status::Closed;
601    unit.closed_at = Some(now);
602    unit.close_reason = Some("Auto-closed: all children completed".to_string());
603    unit.updated_at = now;
604
605    unit.to_file(&unit_path)
606        .with_context(|| format!("Failed to save parent unit: {}", parent_id))?;
607
608    archive_unit(mana_dir, &mut unit, &unit_path)?;
609    closed.push(parent_id.to_string());
610
611    // Recurse to grandparent
612    if let Some(grandparent_id) = &unit.parent {
613        auto_close_parent_recursive(mana_dir, grandparent_id, closed)?;
614    }
615
616    Ok(())
617}
618
619/// Archive a closed unit to the dated archive directory.
620///
621/// Moves the unit file, marks `is_archived = true`, and updates the archive index.
622/// Returns the archive path.
623pub fn archive_unit(mana_dir: &Path, unit: &mut Unit, unit_path: &Path) -> Result<PathBuf> {
624    let id = &unit.id;
625    let slug = unit
626        .slug
627        .clone()
628        .unwrap_or_else(|| title_to_slug(&unit.title));
629    let ext = unit_path
630        .extension()
631        .and_then(|e| e.to_str())
632        .unwrap_or("md");
633    let today = chrono::Local::now().naive_local().date();
634    let archive_path = archive_path_for_unit(mana_dir, id, &slug, ext, today);
635
636    if let Some(parent) = archive_path.parent() {
637        std::fs::create_dir_all(parent)
638            .with_context(|| format!("Failed to create archive directories for unit {}", id))?;
639    }
640
641    std::fs::rename(unit_path, &archive_path)
642        .with_context(|| format!("Failed to move unit {} to archive", id))?;
643
644    unit.is_archived = true;
645    unit.to_file(&archive_path)
646        .with_context(|| format!("Failed to save archived unit: {}", id))?;
647
648    // Append to archive index
649    {
650        let mut archive_index =
651            ArchiveIndex::load(mana_dir).unwrap_or(ArchiveIndex { units: Vec::new() });
652        archive_index.append(IndexEntry::from(&*unit));
653        let _ = archive_index.save(mana_dir);
654    }
655
656    Ok(archive_path)
657}
658
659/// Record a failed verify attempt on a unit.
660///
661/// Increments attempts, appends failure details to notes, and pushes
662/// a structured history entry. Does not save to disk — caller decides when to write.
663pub fn record_failure(unit: &mut Unit, failure: &VerifyFailure) {
664    record_failure_on_unit(unit, failure);
665}
666
667/// Process on_fail actions (retry release, escalate).
668///
669/// Mutates unit in-place (releases claim for retry, escalates priority).
670/// Returns what action was taken.
671pub fn process_on_fail(unit: &mut Unit) -> OnFailActionTaken {
672    let on_fail = match &unit.on_fail {
673        Some(action) => action.clone(),
674        None => return OnFailActionTaken::None,
675    };
676
677    match on_fail {
678        OnFailAction::Retry { max, delay_secs } => {
679            let max_retries = max.unwrap_or(unit.max_attempts);
680            if unit.attempts < max_retries {
681                unit.claimed_by = None;
682                unit.claimed_at = None;
683                OnFailActionTaken::Retry {
684                    attempt: unit.attempts,
685                    max: max_retries,
686                    delay_secs,
687                }
688            } else {
689                OnFailActionTaken::RetryExhausted { max: max_retries }
690            }
691        }
692        OnFailAction::Escalate { priority, message } => {
693            if let Some(p) = priority {
694                unit.priority = p;
695            }
696            if let Some(msg) = &message {
697                let note = format!(
698                    "\n## Escalated — {}\n{}",
699                    Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
700                    msg
701                );
702                match &mut unit.notes {
703                    Some(notes) => notes.push_str(&note),
704                    None => unit.notes = Some(note),
705                }
706            }
707            if !unit.labels.contains(&"escalated".to_string()) {
708                unit.labels.push("escalated".to_string());
709            }
710            OnFailActionTaken::Escalated
711        }
712    }
713}
714
715/// Check circuit breaker for a unit.
716///
717/// If subtree attempts exceed `max_loops`, trips the breaker: adds
718/// "circuit-breaker" label and sets priority to P0. Unit is mutated
719/// but NOT saved — caller decides when to write.
720pub fn check_circuit_breaker(
721    mana_dir: &Path,
722    unit: &mut Unit,
723    root_id: &str,
724    max_loops: u32,
725) -> Result<CircuitBreakerStatus> {
726    if max_loops == 0 {
727        return Ok(CircuitBreakerStatus {
728            tripped: false,
729            subtree_total: 0,
730            max_loops: 0,
731        });
732    }
733
734    let subtree_total = graph::count_subtree_attempts(mana_dir, root_id)?;
735    if subtree_total >= max_loops {
736        if !unit.labels.contains(&"circuit-breaker".to_string()) {
737            unit.labels.push("circuit-breaker".to_string());
738        }
739        unit.priority = 0;
740        Ok(CircuitBreakerStatus {
741            tripped: true,
742            subtree_total,
743            max_loops,
744        })
745    } else {
746        Ok(CircuitBreakerStatus {
747            tripped: false,
748            subtree_total,
749            max_loops,
750        })
751    }
752}
753
754/// Walk up the parent chain to find the root ancestor of a unit.
755///
756/// Returns the ID of the topmost parent (the unit with no parent).
757/// If the unit itself has no parent, returns its own ID.
758pub fn find_root_parent(mana_dir: &Path, unit: &Unit) -> Result<String> {
759    let mut current_id = match &unit.parent {
760        None => return Ok(unit.id.clone()),
761        Some(pid) => pid.clone(),
762    };
763
764    loop {
765        let path = find_unit_file(mana_dir, &current_id)
766            .or_else(|_| find_archived_unit(mana_dir, &current_id));
767
768        match path {
769            Ok(p) => {
770                let b = Unit::from_file(&p)
771                    .with_context(|| format!("Failed to load parent unit: {}", current_id))?;
772                match b.parent {
773                    Some(parent_id) => current_id = parent_id,
774                    None => return Ok(current_id),
775                }
776            }
777            Err(_) => return Ok(current_id),
778        }
779    }
780}
781
782/// Resolve the effective max_loops for a unit, considering root parent overrides.
783pub fn resolve_max_loops(mana_dir: &Path, unit: &Unit, root_id: &str, config_max: u32) -> u32 {
784    if root_id == unit.id {
785        unit.effective_max_loops(config_max)
786    } else {
787        let root_path =
788            find_unit_file(mana_dir, root_id).or_else(|_| find_archived_unit(mana_dir, root_id));
789        match root_path {
790            Ok(p) => Unit::from_file(&p)
791                .map(|b| b.effective_max_loops(config_max))
792                .unwrap_or(config_max),
793            Err(_) => config_max,
794        }
795    }
796}
797
798// ---------------------------------------------------------------------------
799// Internal helpers
800// ---------------------------------------------------------------------------
801
802/// Record a verify failure on a unit (internal).
803fn record_failure_on_unit(unit: &mut Unit, failure: &VerifyFailure) {
804    unit.attempts += 1;
805    unit.updated_at = Utc::now();
806
807    // Append failure to notes
808    let failure_note = format_failure_note(unit.attempts, failure.exit_code, &failure.output);
809    match &mut unit.notes {
810        Some(notes) => notes.push_str(&failure_note),
811        None => unit.notes = Some(failure_note),
812    }
813
814    // Record structured history entry
815    let output_snippet = if failure.output.is_empty() {
816        None
817    } else {
818        Some(truncate_output(&failure.output, 20))
819    };
820    unit.history.push(RunRecord {
821        attempt: unit.attempts,
822        started_at: failure.started_at,
823        finished_at: Some(failure.finished_at),
824        duration_secs: Some(failure.duration_secs),
825        agent: failure.agent.clone(),
826        result: if failure.timed_out {
827            RunResult::Timeout
828        } else {
829            RunResult::Fail
830        },
831        exit_code: failure.exit_code,
832        tokens: None,
833        cost: None,
834        output_snippet,
835    });
836}
837
838/// Capture verify stdout as unit outputs.
839fn capture_verify_outputs(unit: &mut Unit, stdout: &str) {
840    let stdout = stdout.trim();
841    if stdout.is_empty() {
842        return;
843    }
844
845    if stdout.len() > MAX_OUTPUT_BYTES {
846        let end = truncate_to_char_boundary(stdout, MAX_OUTPUT_BYTES);
847        let truncated = &stdout[..end];
848        unit.outputs = Some(serde_json::json!({
849            "text": truncated,
850            "truncated": true,
851            "original_bytes": stdout.len()
852        }));
853    } else {
854        match serde_json::from_str::<serde_json::Value>(stdout) {
855            Ok(json) => {
856                unit.outputs = Some(json);
857            }
858            Err(_) => {
859                unit.outputs = Some(serde_json::json!({
860                    "text": stdout
861                }));
862            }
863        }
864    }
865}
866
867/// Find the largest byte index <= `max_bytes` that falls on a UTF-8 char boundary.
868pub fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
869    if max_bytes >= s.len() {
870        return s.len();
871    }
872    let mut end = max_bytes;
873    while !s.is_char_boundary(end) {
874        end -= 1;
875    }
876    end
877}
878
879/// Truncate output to first N + last N lines.
880pub fn truncate_output(output: &str, max_lines: usize) -> String {
881    let lines: Vec<&str> = output.lines().collect();
882
883    if lines.len() <= max_lines * 2 {
884        return output.to_string();
885    }
886
887    let first = &lines[..max_lines];
888    let last = &lines[lines.len() - max_lines..];
889
890    format!(
891        "{}\n\n... ({} lines omitted) ...\n\n{}",
892        first.join("\n"),
893        lines.len() - max_lines * 2,
894        last.join("\n")
895    )
896}
897
898/// Format a verify failure as a Markdown block to append to notes.
899pub fn format_failure_note(attempt: u32, exit_code: Option<i32>, output: &str) -> String {
900    let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
901    let truncated = truncate_output(output, 50);
902    let exit_str = exit_code
903        .map(|c| format!("Exit code: {}\n", c))
904        .unwrap_or_default();
905
906    format!(
907        "\n## Attempt {} — {}\n{}\n```\n{}\n```\n",
908        attempt, timestamp, exit_str, truncated
909    )
910}
911
912// ---------------------------------------------------------------------------
913// Hook helpers
914// ---------------------------------------------------------------------------
915
916/// Run pre-close hook. Hook errors are returned as warnings but do not block close.
917fn run_pre_close_hook(unit: &Unit, project_root: &Path, reason: Option<&str>) -> HookDecision {
918    let result = execute_hook(
919        HookEvent::PreClose,
920        unit,
921        project_root,
922        reason.map(|s| s.to_string()),
923    );
924
925    match result {
926        Ok(hook_passed) => HookDecision {
927            accepted: hook_passed,
928            warning: None,
929        },
930        Err(e) => HookDecision {
931            accepted: true,
932            warning: Some(CloseWarning::PreCloseHookError {
933                message: e.to_string(),
934            }),
935        },
936    }
937}
938
939/// Run post-close hook + on_close actions + config hooks.
940fn run_post_close_actions(
941    unit: &Unit,
942    project_root: &Path,
943    reason: Option<&str>,
944    config: Option<&Config>,
945) -> PostCloseActionsReport {
946    let mut warnings = Vec::new();
947
948    // Fire post-close hook
949    match execute_hook(
950        HookEvent::PostClose,
951        unit,
952        project_root,
953        reason.map(|s| s.to_string()),
954    ) {
955        Ok(false) => warnings.push(CloseWarning::PostCloseHookRejected),
956        Err(e) => warnings.push(CloseWarning::PostCloseHookError {
957            message: e.to_string(),
958        }),
959        Ok(true) => {}
960    }
961
962    // Process on_close actions
963    let mut on_close_results = Vec::new();
964    for action in &unit.on_close {
965        match action {
966            OnCloseAction::Run { command } => {
967                if !is_trusted(project_root) {
968                    on_close_results.push(OnCloseActionResult::Skipped {
969                        command: command.clone(),
970                    });
971                    continue;
972                }
973
974                let status = std::process::Command::new("sh")
975                    .args(["-c", command.as_str()])
976                    .current_dir(project_root)
977                    .status();
978                let result = match status {
979                    Ok(status) => OnCloseActionResult::RanCommand {
980                        command: command.clone(),
981                        success: status.success(),
982                        exit_code: status.code(),
983                        error: None,
984                    },
985                    Err(e) => OnCloseActionResult::RanCommand {
986                        command: command.clone(),
987                        success: false,
988                        exit_code: None,
989                        error: Some(e.to_string()),
990                    },
991                };
992                on_close_results.push(result);
993            }
994            OnCloseAction::Notify { message } => {
995                on_close_results.push(OnCloseActionResult::Notified {
996                    message: message.clone(),
997                });
998            }
999        }
1000    }
1001
1002    // Fire on_close config hook
1003    if let Some(config) = config {
1004        if let Some(ref on_close_template) = config.on_close {
1005            let vars = HookVars {
1006                id: Some(unit.id.clone()),
1007                title: Some(unit.title.clone()),
1008                status: Some("closed".into()),
1009                branch: current_git_branch(),
1010                ..Default::default()
1011            };
1012            execute_config_hook("on_close", on_close_template, &vars, project_root);
1013        }
1014    }
1015
1016    PostCloseActionsReport {
1017        warnings,
1018        on_close_results,
1019    }
1020}
1021
1022/// Fire the on_fail config hook.
1023fn run_on_fail_hook(unit: &Unit, project_root: &Path, config: Option<&Config>, output: &str) {
1024    if let Some(config) = config {
1025        if let Some(ref on_fail_template) = config.on_fail {
1026            let vars = HookVars {
1027                id: Some(unit.id.clone()),
1028                title: Some(unit.title.clone()),
1029                status: Some(format!("{}", unit.status)),
1030                attempt: Some(unit.attempts),
1031                output: Some(output.to_string()),
1032                branch: current_git_branch(),
1033                ..Default::default()
1034            };
1035            execute_config_hook("on_fail", on_fail_template, &vars, project_root);
1036        }
1037    }
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Worktree helpers
1042// ---------------------------------------------------------------------------
1043
1044/// Detect and validate worktree context.
1045fn detect_valid_worktree(project_root: &Path) -> Option<crate::worktree::WorktreeInfo> {
1046    let info = crate::worktree::detect_worktree(project_root).unwrap_or(None)?;
1047
1048    let canonical_root =
1049        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1050    if canonical_root.starts_with(&info.worktree_path) {
1051        Some(info)
1052    } else {
1053        None
1054    }
1055}
1056
1057/// Commit worktree changes and merge to main.
1058fn handle_worktree_merge(
1059    wt_info: &crate::worktree::WorktreeInfo,
1060    unit: &Unit,
1061) -> Result<WorktreeMergeStatus> {
1062    let message = expand_commit_template(
1063        DEFAULT_COMMIT_TEMPLATE,
1064        &unit.id,
1065        &unit.title,
1066        unit.parent.as_deref(),
1067        &unit.labels,
1068    );
1069    crate::worktree::commit_worktree_changes(&wt_info.worktree_path, &message)?;
1070
1071    match crate::worktree::merge_to_main(wt_info, &unit.id)? {
1072        crate::worktree::MergeResult::Success | crate::worktree::MergeResult::NothingToCommit => {
1073            Ok(WorktreeMergeStatus::Merged)
1074        }
1075        crate::worktree::MergeResult::Conflict { files } => {
1076            Ok(WorktreeMergeStatus::Conflict { files })
1077        }
1078    }
1079}
1080
1081/// Clean up worktree after successful close.
1082fn cleanup_worktree(wt_info: &crate::worktree::WorktreeInfo) -> Option<CloseWarning> {
1083    crate::worktree::cleanup_worktree(wt_info)
1084        .err()
1085        .map(|e| CloseWarning::WorktreeCleanupFailed {
1086            message: e.to_string(),
1087        })
1088}
1089
1090/// Expand a commit template with placeholder values.
1091///
1092/// Supported placeholders: `{id}`, `{title}`, `{parent_id}`, `{labels}`.
1093fn expand_commit_template(
1094    template: &str,
1095    id: &str,
1096    title: &str,
1097    parent_id: Option<&str>,
1098    labels: &[String],
1099) -> String {
1100    template
1101        .replace("{id}", id)
1102        .replace("{title}", title)
1103        .replace("{parent_id}", parent_id.unwrap_or(""))
1104        .replace("{labels}", &labels.join(","))
1105}
1106
1107/// Auto-commit changes on close (non-worktree mode).
1108fn auto_commit_on_close(
1109    project_root: &Path,
1110    id: &str,
1111    title: &str,
1112    parent_id: Option<&str>,
1113    labels: &[String],
1114    template: Option<&str>,
1115) -> AutoCommitResult {
1116    let message = expand_commit_template(
1117        template.unwrap_or(DEFAULT_COMMIT_TEMPLATE),
1118        id,
1119        title,
1120        parent_id,
1121        labels,
1122    );
1123
1124    let add_status = std::process::Command::new("git")
1125        .args(["add", "-A"])
1126        .current_dir(project_root)
1127        .stdout(std::process::Stdio::null())
1128        .stderr(std::process::Stdio::piped())
1129        .status();
1130
1131    match add_status {
1132        Ok(status) if !status.success() => {
1133            return AutoCommitResult {
1134                message,
1135                committed: false,
1136                warning: Some(format!(
1137                    "git add -A failed (exit {})",
1138                    status.code().unwrap_or(-1)
1139                )),
1140            };
1141        }
1142        Err(e) => {
1143            return AutoCommitResult {
1144                message,
1145                committed: false,
1146                warning: Some(format!("git add -A failed: {}", e)),
1147            };
1148        }
1149        _ => {}
1150    }
1151
1152    let commit_result = std::process::Command::new("git")
1153        .args(["commit", "-m", &message])
1154        .current_dir(project_root)
1155        .stdout(std::process::Stdio::null())
1156        .stderr(std::process::Stdio::piped())
1157        .output();
1158
1159    match commit_result {
1160        Ok(output) if output.status.success() => AutoCommitResult {
1161            message,
1162            committed: true,
1163            warning: None,
1164        },
1165        Ok(output) if output.status.code() == Some(1) => AutoCommitResult {
1166            message,
1167            committed: false,
1168            warning: None,
1169        },
1170        Ok(output) => {
1171            let stderr = String::from_utf8_lossy(&output.stderr);
1172            AutoCommitResult {
1173                message,
1174                committed: false,
1175                warning: Some(format!(
1176                    "git commit failed (exit {}): {}",
1177                    output.status.code().unwrap_or(-1),
1178                    stderr.trim()
1179                )),
1180            }
1181        }
1182        Err(e) => AutoCommitResult {
1183            message,
1184            committed: false,
1185            warning: Some(format!("git commit failed: {}", e)),
1186        },
1187    }
1188}
1189
1190/// Rebuild the index.
1191fn rebuild_index(mana_dir: &Path) -> Result<()> {
1192    if mana_dir.exists() {
1193        let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
1194        index
1195            .save(mana_dir)
1196            .with_context(|| "Failed to save index")?;
1197    }
1198    Ok(())
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use crate::config::{Config, DEFAULT_COMMIT_TEMPLATE};
1205    use std::fs;
1206    use tempfile::TempDir;
1207
1208    fn setup_mana_dir() -> (TempDir, PathBuf) {
1209        let dir = TempDir::new().unwrap();
1210        let mana_dir = dir.path().join(".mana");
1211        fs::create_dir(&mana_dir).unwrap();
1212        (dir, mana_dir)
1213    }
1214
1215    fn setup_mana_dir_with_config() -> (TempDir, PathBuf) {
1216        let dir = TempDir::new().unwrap();
1217        let mana_dir = dir.path().join(".mana");
1218        fs::create_dir(&mana_dir).unwrap();
1219
1220        Config {
1221            project: "test".to_string(),
1222            next_id: 100,
1223            auto_close_parent: true,
1224            run: None,
1225            plan: None,
1226            max_loops: 10,
1227            max_concurrent: 4,
1228            poll_interval: 30,
1229            extends: vec![],
1230            rules_file: None,
1231            file_locking: false,
1232            worktree: false,
1233            on_close: None,
1234            on_fail: None,
1235            post_plan: None,
1236            verify_timeout: None,
1237            review: None,
1238            user: None,
1239            user_email: None,
1240            auto_commit: false,
1241            commit_template: None,
1242            research: None,
1243            run_model: None,
1244            plan_model: None,
1245            review_model: None,
1246            research_model: None,
1247            batch_verify: false,
1248            memory_reserve_mb: 0,
1249            notify: None,
1250        }
1251        .save(&mana_dir)
1252        .unwrap();
1253
1254        (dir, mana_dir)
1255    }
1256
1257    fn setup_git_mana_dir_with_config(config: Config) -> (TempDir, PathBuf) {
1258        let dir = TempDir::new().unwrap();
1259        let project_root = dir.path();
1260        let mana_dir = project_root.join(".mana");
1261        fs::create_dir(&mana_dir).unwrap();
1262        config.save(&mana_dir).unwrap();
1263
1264        run_git(project_root, &["init"]);
1265        run_git(project_root, &["config", "user.email", "test@test.com"]);
1266        run_git(project_root, &["config", "user.name", "Test"]);
1267
1268        fs::write(project_root.join("initial.txt"), "initial").unwrap();
1269        run_git(project_root, &["add", "-A"]);
1270        run_git(project_root, &["commit", "-m", "Initial commit"]);
1271
1272        (dir, mana_dir)
1273    }
1274
1275    fn run_git(dir: &Path, args: &[&str]) {
1276        let output = std::process::Command::new("git")
1277            .args(args)
1278            .current_dir(dir)
1279            .output()
1280            .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
1281        assert!(
1282            output.status.success(),
1283            "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1284            args,
1285            dir.display(),
1286            output.status.code(),
1287            String::from_utf8_lossy(&output.stdout),
1288            String::from_utf8_lossy(&output.stderr),
1289        );
1290    }
1291
1292    fn git_stdout(dir: &Path, args: &[&str]) -> String {
1293        let output = std::process::Command::new("git")
1294            .args(args)
1295            .current_dir(dir)
1296            .output()
1297            .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
1298        assert!(
1299            output.status.success(),
1300            "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1301            args,
1302            dir.display(),
1303            output.status.code(),
1304            String::from_utf8_lossy(&output.stdout),
1305            String::from_utf8_lossy(&output.stderr),
1306        );
1307        String::from_utf8(output.stdout).unwrap()
1308    }
1309
1310    fn write_unit(mana_dir: &Path, unit: &Unit) {
1311        let slug = title_to_slug(&unit.title);
1312        unit.to_file(mana_dir.join(format!("{}-{}.md", unit.id, slug)))
1313            .unwrap();
1314    }
1315
1316    // =====================================================================
1317    // close() tests
1318    // =====================================================================
1319
1320    #[test]
1321    fn close_single_unit() {
1322        let (_dir, mana_dir) = setup_mana_dir();
1323        let unit = Unit::new("1", "Task");
1324        write_unit(&mana_dir, &unit);
1325
1326        let result = close(
1327            &mana_dir,
1328            "1",
1329            CloseOpts {
1330                reason: None,
1331                force: false,
1332                defer_verify: false,
1333            },
1334        )
1335        .unwrap();
1336
1337        match result {
1338            CloseOutcome::Closed(r) => {
1339                assert_eq!(r.unit.status, Status::Closed);
1340                assert!(r.unit.closed_at.is_some());
1341                assert!(r.unit.is_archived);
1342                assert!(r.archive_path.exists());
1343            }
1344            _ => panic!("Expected Closed outcome"),
1345        }
1346    }
1347
1348    #[test]
1349    fn close_with_reason() {
1350        let (_dir, mana_dir) = setup_mana_dir();
1351        let unit = Unit::new("1", "Task");
1352        write_unit(&mana_dir, &unit);
1353
1354        let result = close(
1355            &mana_dir,
1356            "1",
1357            CloseOpts {
1358                reason: Some("Fixed".to_string()),
1359                force: false,
1360                defer_verify: false,
1361            },
1362        )
1363        .unwrap();
1364
1365        match result {
1366            CloseOutcome::Closed(r) => {
1367                assert_eq!(r.unit.close_reason, Some("Fixed".to_string()));
1368            }
1369            _ => panic!("Expected Closed outcome"),
1370        }
1371    }
1372
1373    #[test]
1374    fn close_with_passing_verify() {
1375        let (_dir, mana_dir) = setup_mana_dir();
1376        let mut unit = Unit::new("1", "Task");
1377        unit.verify = Some("true".to_string());
1378        write_unit(&mana_dir, &unit);
1379
1380        let result = close(
1381            &mana_dir,
1382            "1",
1383            CloseOpts {
1384                reason: None,
1385                force: false,
1386                defer_verify: false,
1387            },
1388        )
1389        .unwrap();
1390
1391        match result {
1392            CloseOutcome::Closed(r) => {
1393                assert_eq!(r.unit.status, Status::Closed);
1394                assert!(r.unit.is_archived);
1395                assert_eq!(r.unit.history.len(), 1);
1396                assert_eq!(r.unit.history[0].result, RunResult::Pass);
1397            }
1398            _ => panic!("Expected Closed outcome"),
1399        }
1400    }
1401
1402    #[test]
1403    fn close_with_failing_verify() {
1404        let (_dir, mana_dir) = setup_mana_dir();
1405        let mut unit = Unit::new("1", "Task");
1406        unit.verify = Some("false".to_string());
1407        write_unit(&mana_dir, &unit);
1408
1409        let result = close(
1410            &mana_dir,
1411            "1",
1412            CloseOpts {
1413                reason: None,
1414                force: false,
1415                defer_verify: false,
1416            },
1417        )
1418        .unwrap();
1419
1420        match result {
1421            CloseOutcome::VerifyFailed(r) => {
1422                assert_eq!(r.unit.status, Status::Open);
1423                assert_eq!(r.unit.attempts, 1);
1424            }
1425            _ => panic!("Expected VerifyFailed outcome"),
1426        }
1427    }
1428
1429    #[test]
1430    fn close_force_skips_verify() {
1431        let (_dir, mana_dir) = setup_mana_dir();
1432        let mut unit = Unit::new("1", "Task");
1433        unit.verify = Some("false".to_string());
1434        write_unit(&mana_dir, &unit);
1435
1436        let result = close(
1437            &mana_dir,
1438            "1",
1439            CloseOpts {
1440                reason: None,
1441                force: true,
1442                defer_verify: false,
1443            },
1444        )
1445        .unwrap();
1446
1447        match result {
1448            CloseOutcome::Closed(r) => {
1449                assert_eq!(r.unit.status, Status::Closed);
1450                assert!(r.unit.is_archived);
1451                assert_eq!(r.unit.attempts, 0);
1452            }
1453            _ => panic!("Expected Closed outcome"),
1454        }
1455    }
1456
1457    #[test]
1458    fn close_feature_returns_requires_human() {
1459        let (_dir, mana_dir) = setup_mana_dir();
1460        let mut unit = Unit::new("1", "Feature");
1461        unit.feature = true;
1462        write_unit(&mana_dir, &unit);
1463
1464        let result = close(
1465            &mana_dir,
1466            "1",
1467            CloseOpts {
1468                reason: None,
1469                force: false,
1470                defer_verify: false,
1471            },
1472        )
1473        .unwrap();
1474
1475        assert!(matches!(result, CloseOutcome::FeatureRequiresHuman { .. }));
1476    }
1477
1478    #[test]
1479    fn close_nonexistent_unit() {
1480        let (_dir, mana_dir) = setup_mana_dir();
1481        let result = close(
1482            &mana_dir,
1483            "99",
1484            CloseOpts {
1485                reason: None,
1486                force: false,
1487                defer_verify: false,
1488            },
1489        );
1490        assert!(result.is_err());
1491    }
1492
1493    // =====================================================================
1494    // close_failed() tests
1495    // =====================================================================
1496
1497    #[test]
1498    fn close_failed_marks_unit_as_failed() {
1499        let (_dir, mana_dir) = setup_mana_dir();
1500        let mut unit = Unit::new("1", "Task");
1501        unit.status = Status::InProgress;
1502        unit.claimed_by = Some("agent-1".to_string());
1503        unit.attempt_log.push(crate::unit::AttemptRecord {
1504            num: 1,
1505            outcome: AttemptOutcome::Abandoned,
1506            notes: None,
1507            agent: Some("agent-1".to_string()),
1508            started_at: Some(Utc::now()),
1509            finished_at: None,
1510        });
1511        write_unit(&mana_dir, &unit);
1512
1513        let result = close_failed(&mana_dir, "1", Some("blocked".to_string())).unwrap();
1514        assert_eq!(result.status, Status::Open);
1515        assert!(result.claimed_by.is_none());
1516        assert_eq!(result.attempt_log[0].outcome, AttemptOutcome::Failed);
1517        assert!(result.attempt_log[0].finished_at.is_some());
1518    }
1519
1520    // =====================================================================
1521    // all_children_closed() tests
1522    // =====================================================================
1523
1524    #[test]
1525    fn all_children_closed_when_no_children() {
1526        let (_dir, mana_dir) = setup_mana_dir();
1527        let unit = Unit::new("1", "Parent");
1528        write_unit(&mana_dir, &unit);
1529
1530        assert!(all_children_closed(&mana_dir, "1").unwrap());
1531    }
1532
1533    #[test]
1534    fn all_children_closed_when_some_open() {
1535        let (_dir, mana_dir) = setup_mana_dir();
1536        let parent = Unit::new("1", "Parent");
1537        write_unit(&mana_dir, &parent);
1538
1539        let mut child1 = Unit::new("1.1", "Child 1");
1540        child1.parent = Some("1".to_string());
1541        child1.status = Status::Closed;
1542        write_unit(&mana_dir, &child1);
1543
1544        let mut child2 = Unit::new("1.2", "Child 2");
1545        child2.parent = Some("1".to_string());
1546        write_unit(&mana_dir, &child2);
1547
1548        assert!(!all_children_closed(&mana_dir, "1").unwrap());
1549    }
1550
1551    // =====================================================================
1552    // auto_close_parents() tests
1553    // =====================================================================
1554
1555    #[test]
1556    fn auto_close_parents_when_all_children_closed() {
1557        let (_dir, mana_dir) = setup_mana_dir_with_config();
1558        let parent = Unit::new("1", "Parent");
1559        write_unit(&mana_dir, &parent);
1560
1561        let mut child = Unit::new("1.1", "Child");
1562        child.parent = Some("1".to_string());
1563        write_unit(&mana_dir, &child);
1564
1565        // Close the child first
1566        let _ = close(
1567            &mana_dir,
1568            "1.1",
1569            CloseOpts {
1570                reason: None,
1571                force: false,
1572                defer_verify: false,
1573            },
1574        )
1575        .unwrap();
1576
1577        // Parent should be auto-closed
1578        let parent_archived = find_archived_unit(&mana_dir, "1");
1579        assert!(parent_archived.is_ok());
1580        let p = Unit::from_file(parent_archived.unwrap()).unwrap();
1581        assert_eq!(p.status, Status::Closed);
1582        assert!(p.close_reason.as_ref().unwrap().contains("Auto-closed"));
1583    }
1584
1585    #[test]
1586    fn auto_close_skips_feature_parents() {
1587        let (_dir, mana_dir) = setup_mana_dir_with_config();
1588        let mut parent = Unit::new("1", "Feature Parent");
1589        parent.feature = true;
1590        write_unit(&mana_dir, &parent);
1591
1592        let mut child = Unit::new("1.1", "Child");
1593        child.parent = Some("1".to_string());
1594        write_unit(&mana_dir, &child);
1595
1596        let _ = close(
1597            &mana_dir,
1598            "1.1",
1599            CloseOpts {
1600                reason: None,
1601                force: false,
1602                defer_verify: false,
1603            },
1604        )
1605        .unwrap();
1606
1607        // Feature parent should still be open
1608        let parent_still_open = find_unit_file(&mana_dir, "1");
1609        assert!(parent_still_open.is_ok());
1610        let p = Unit::from_file(parent_still_open.unwrap()).unwrap();
1611        assert_eq!(p.status, Status::Open);
1612    }
1613
1614    // =====================================================================
1615    // archive_unit() tests
1616    // =====================================================================
1617
1618    #[test]
1619    fn archive_unit_moves_and_marks() {
1620        let (_dir, mana_dir) = setup_mana_dir();
1621        let mut unit = Unit::new("1", "Task");
1622        unit.status = Status::Closed;
1623        let slug = title_to_slug(&unit.title);
1624        let unit_path = mana_dir.join(format!("1-{}.md", slug));
1625        unit.to_file(&unit_path).unwrap();
1626
1627        let archive_path = archive_unit(&mana_dir, &mut unit, &unit_path).unwrap();
1628        assert!(archive_path.exists());
1629        assert!(!unit_path.exists());
1630        assert!(unit.is_archived);
1631    }
1632
1633    // =====================================================================
1634    // record_failure() tests
1635    // =====================================================================
1636
1637    #[test]
1638    fn record_failure_increments_attempts() {
1639        let mut unit = Unit::new("1", "Task");
1640        let failure = VerifyFailure {
1641            exit_code: Some(1),
1642            output: "error".to_string(),
1643            timed_out: false,
1644            duration_secs: 1.0,
1645            started_at: Utc::now(),
1646            finished_at: Utc::now(),
1647            agent: None,
1648        };
1649        record_failure(&mut unit, &failure);
1650        assert_eq!(unit.attempts, 1);
1651        assert_eq!(unit.history.len(), 1);
1652        assert_eq!(unit.history[0].result, RunResult::Fail);
1653    }
1654
1655    #[test]
1656    fn record_failure_timeout() {
1657        let mut unit = Unit::new("1", "Task");
1658        let failure = VerifyFailure {
1659            exit_code: None,
1660            output: "timed out".to_string(),
1661            timed_out: true,
1662            duration_secs: 30.0,
1663            started_at: Utc::now(),
1664            finished_at: Utc::now(),
1665            agent: None,
1666        };
1667        record_failure(&mut unit, &failure);
1668        assert_eq!(unit.history[0].result, RunResult::Timeout);
1669    }
1670
1671    // =====================================================================
1672    // process_on_fail() tests
1673    // =====================================================================
1674
1675    #[test]
1676    fn process_on_fail_retry_releases_claim() {
1677        let mut unit = Unit::new("1", "Task");
1678        unit.on_fail = Some(OnFailAction::Retry {
1679            max: Some(5),
1680            delay_secs: None,
1681        });
1682        unit.attempts = 1;
1683        unit.claimed_by = Some("agent-1".to_string());
1684        unit.claimed_at = Some(Utc::now());
1685
1686        let result = process_on_fail(&mut unit);
1687        assert!(matches!(result, OnFailActionTaken::Retry { .. }));
1688        assert!(unit.claimed_by.is_none());
1689    }
1690
1691    #[test]
1692    fn process_on_fail_escalate_sets_priority() {
1693        let mut unit = Unit::new("1", "Task");
1694        unit.on_fail = Some(OnFailAction::Escalate {
1695            priority: Some(0),
1696            message: None,
1697        });
1698        unit.priority = 2;
1699
1700        let result = process_on_fail(&mut unit);
1701        assert!(matches!(result, OnFailActionTaken::Escalated));
1702        assert_eq!(unit.priority, 0);
1703        assert!(unit.labels.contains(&"escalated".to_string()));
1704    }
1705
1706    #[test]
1707    fn process_on_fail_none() {
1708        let mut unit = Unit::new("1", "Task");
1709        let result = process_on_fail(&mut unit);
1710        assert!(matches!(result, OnFailActionTaken::None));
1711    }
1712
1713    // =====================================================================
1714    // check_circuit_breaker() tests
1715    // =====================================================================
1716
1717    #[test]
1718    fn circuit_breaker_zero_disabled() {
1719        let (_dir, mana_dir) = setup_mana_dir();
1720        let mut unit = Unit::new("1", "Task");
1721        let result = check_circuit_breaker(&mana_dir, &mut unit, "1", 0).unwrap();
1722        assert!(!result.tripped);
1723    }
1724
1725    // =====================================================================
1726    // Helper tests
1727    // =====================================================================
1728
1729    #[test]
1730    fn truncate_to_char_boundary_ascii() {
1731        let s = "hello world";
1732        assert_eq!(truncate_to_char_boundary(s, 5), 5);
1733    }
1734
1735    #[test]
1736    fn truncate_to_char_boundary_multibyte() {
1737        let s = "😀😁😂";
1738        assert_eq!(truncate_to_char_boundary(s, 5), 4);
1739    }
1740
1741    #[test]
1742    fn truncate_output_short() {
1743        let output = "line1\nline2\nline3";
1744        let result = truncate_output(output, 50);
1745        assert_eq!(result, output);
1746    }
1747
1748    #[test]
1749    fn format_failure_note_includes_exit_code() {
1750        let note = format_failure_note(1, Some(1), "error message");
1751        assert!(note.contains("## Attempt 1"));
1752        assert!(note.contains("Exit code: 1"));
1753        assert!(note.contains("error message"));
1754    }
1755
1756    #[test]
1757    fn expand_commit_template_substitutes_all_placeholders() {
1758        let message = expand_commit_template(
1759            "feat(unit-{id}): {title} [{parent_id}] {labels}",
1760            "2.3",
1761            "Ship it",
1762            Some("2"),
1763            &["feature".to_string(), "git".to_string()],
1764        );
1765
1766        assert_eq!(message, "feat(unit-2.3): Ship it [2] feature,git");
1767    }
1768
1769    #[test]
1770    fn close_auto_commit_uses_default_template_and_includes_index_updates() {
1771        let config = Config {
1772            project: "test".to_string(),
1773            next_id: 100,
1774            auto_commit: true,
1775            ..Config::default()
1776        };
1777        let (_dir, mana_dir) = setup_git_mana_dir_with_config(config);
1778        let project_root = mana_dir.parent().unwrap();
1779
1780        let parent = Unit::new("1", "Parent");
1781        write_unit(&mana_dir, &parent);
1782
1783        let mut child = Unit::new("1.1", "Child");
1784        child.parent = Some("1".to_string());
1785        write_unit(&mana_dir, &child);
1786
1787        let result = close(
1788            &mana_dir,
1789            "1.1",
1790            CloseOpts {
1791                reason: None,
1792                force: false,
1793                defer_verify: false,
1794            },
1795        )
1796        .unwrap();
1797
1798        let close_result = match result {
1799            CloseOutcome::Closed(result) => result,
1800            other => panic!("Expected Closed outcome, got {:?}", other),
1801        };
1802        let auto_commit = close_result
1803            .auto_commit_result
1804            .expect("auto-commit result should be present when enabled");
1805        assert!(auto_commit.committed);
1806        assert_eq!(
1807            auto_commit.message,
1808            DEFAULT_COMMIT_TEMPLATE
1809                .replace("{id}", "1.1")
1810                .replace("{title}", "Child")
1811        );
1812        assert_eq!(close_result.auto_closed_parents, vec!["1".to_string()]);
1813
1814        let head_subject = git_stdout(project_root, &["log", "-1", "--pretty=%s"]);
1815        assert_eq!(head_subject.trim(), "feat(unit-1.1): Child");
1816
1817        let changed_files = git_stdout(project_root, &["show", "--name-only", "--format=", "HEAD"]);
1818        assert!(
1819            changed_files.contains(".mana/index.yaml"),
1820            "{changed_files}"
1821        );
1822        assert!(changed_files.contains("1-parent.md"), "{changed_files}");
1823        assert!(changed_files.contains("1.1-child.md"), "{changed_files}");
1824    }
1825
1826    // =====================================================================
1827    // close_defer tests — deferred verify via defer_verify: true
1828    // =====================================================================
1829
1830    /// With defer_verify: true, close() skips the verify command and sets
1831    /// status to AwaitingVerify instead of Closed.
1832    #[test]
1833    fn close_defer_skips_verify() {
1834        let (_dir, mana_dir) = setup_mana_dir();
1835        let mut unit = Unit::new("1", "Task");
1836        // A failing verify — should NOT be run when defer_verify is true.
1837        unit.verify = Some("false".to_string());
1838        write_unit(&mana_dir, &unit);
1839
1840        let outcome = close(
1841            &mana_dir,
1842            "1",
1843            CloseOpts {
1844                reason: None,
1845                force: false,
1846                defer_verify: true,
1847            },
1848        )
1849        .unwrap();
1850
1851        // Unit should be AwaitingVerify, not Closed, and no verify failure recorded.
1852        match outcome {
1853            CloseOutcome::DeferredVerify { .. } => {}
1854            other => panic!("Expected DeferredVerify outcome, got {:?}", other),
1855        }
1856
1857        // Confirm the on-disk state reflects AwaitingVerify.
1858        let saved = Unit::from_file(
1859            find_unit_file(&mana_dir, "1").expect("unit file should still be in active dir"),
1860        )
1861        .unwrap();
1862        assert_eq!(saved.status, Status::AwaitingVerify);
1863        // No verify was run — attempts counter stays at 0.
1864        assert_eq!(saved.attempts, 0);
1865    }
1866
1867    /// With defer_verify: true, the returned outcome is DeferredVerify containing
1868    /// the correct unit ID.
1869    #[test]
1870    fn close_defer_returns_outcome() {
1871        let (_dir, mana_dir) = setup_mana_dir();
1872        let unit = Unit::new("42", "Deferred Task");
1873        write_unit(&mana_dir, &unit);
1874
1875        let outcome = close(
1876            &mana_dir,
1877            "42",
1878            CloseOpts {
1879                reason: None,
1880                force: false,
1881                defer_verify: true,
1882            },
1883        )
1884        .unwrap();
1885
1886        match outcome {
1887            CloseOutcome::DeferredVerify { unit_id } => {
1888                assert_eq!(unit_id, "42");
1889            }
1890            other => panic!("Expected DeferredVerify outcome, got {:?}", other),
1891        }
1892    }
1893
1894    /// Without defer_verify, the normal close lifecycle runs: a failing verify
1895    /// returns VerifyFailed, not DeferredVerify.
1896    #[test]
1897    fn close_defer_normal_unchanged() {
1898        let (_dir, mana_dir) = setup_mana_dir();
1899        let mut unit = Unit::new("1", "Task");
1900        unit.verify = Some("false".to_string());
1901        write_unit(&mana_dir, &unit);
1902
1903        let outcome = close(
1904            &mana_dir,
1905            "1",
1906            CloseOpts {
1907                reason: None,
1908                force: false,
1909                defer_verify: false,
1910            },
1911        )
1912        .unwrap();
1913
1914        match outcome {
1915            CloseOutcome::VerifyFailed(r) => {
1916                assert_eq!(r.unit.status, Status::Open);
1917                assert_eq!(r.unit.attempts, 1);
1918            }
1919            other => panic!("Expected VerifyFailed outcome, got {:?}", other),
1920        }
1921    }
1922}