Skip to main content

mana/commands/close/
mod.rs

1#[allow(dead_code)]
2mod worktree_merge;
3
4use std::path::Path;
5
6use anyhow::{anyhow, Context, Result};
7
8use crate::unit::Unit;
9
10// Re-export core close ops for use in tests
11use mana_core::ops::close::{
12    self as ops_close, AutoCommitResult, CloseOpts, CloseOutcome, CloseWarning,
13    OnCloseActionResult, OnFailActionTaken,
14};
15
16// These imports are used by test modules via `use super::*`
17#[allow(unused_imports)]
18use crate::index::Index;
19#[allow(unused_imports)]
20use crate::unit::{RunResult, Status};
21#[allow(unused_imports)]
22use chrono::Utc;
23#[allow(unused_imports)]
24use mana_core::ops::close::truncate_to_char_boundary;
25
26#[cfg(test)]
27use std::fs;
28
29fn print_close_warnings(unit_id: &str, warnings: &[CloseWarning]) {
30    for warning in warnings {
31        match warning {
32            CloseWarning::PreCloseHookError { message } => {
33                eprintln!(
34                    "Warning: pre-close hook error for unit {}: {}",
35                    unit_id, message
36                );
37            }
38            CloseWarning::PostCloseHookRejected => {
39                eprintln!(
40                    "Warning: post-close hook returned non-zero for unit {}",
41                    unit_id
42                );
43            }
44            CloseWarning::PostCloseHookError { message } => {
45                eprintln!(
46                    "Warning: post-close hook error for unit {}: {}",
47                    unit_id, message
48                );
49            }
50            CloseWarning::WorktreeCleanupFailed { message } => {
51                eprintln!(
52                    "Warning: failed to clean up worktree for unit {}: {}",
53                    unit_id, message
54                );
55            }
56        }
57    }
58}
59
60fn print_on_close_results(unit_id: &str, results: &[OnCloseActionResult]) {
61    for result in results {
62        match result {
63            OnCloseActionResult::RanCommand {
64                command,
65                success,
66                exit_code,
67                error,
68            } => {
69                if *success {
70                    eprintln!("on_close: ran `{}`", command);
71                } else if let Some(error) = error {
72                    eprintln!(
73                        "on_close run command error for unit {} (`{}`): {}",
74                        unit_id, command, error
75                    );
76                } else {
77                    eprintln!(
78                        "on_close run command failed for unit {} (`{}`) with exit {}",
79                        unit_id,
80                        command,
81                        exit_code.unwrap_or(-1)
82                    );
83                }
84            }
85            OnCloseActionResult::Notified { message } => {
86                println!("[unit {}] {}", unit_id, message);
87            }
88            OnCloseActionResult::Skipped { command } => {
89                eprintln!(
90                    "on_close: skipping `{}` for unit {} (not trusted — run `mana trust` to enable)",
91                    command,
92                    unit_id
93                );
94            }
95        }
96    }
97}
98
99fn print_auto_commit_result(result: &AutoCommitResult) {
100    if result.committed {
101        eprintln!("auto_commit: {}", result.message);
102    }
103    if let Some(warning) = &result.warning {
104        eprintln!("Warning: {}", warning);
105    }
106}
107
108/// Close one or more units.
109///
110/// Sets status=closed, closed_at=now, and optionally close_reason.
111/// If the unit has a verify command, it must pass before closing (unless force=true).
112/// Calls pre-close hook before verify (can block close if hook fails).
113/// Auto-closes parent units when all children are closed (if enabled in config).
114/// Rebuilds the index.
115pub fn cmd_close(
116    mana_dir: &Path,
117    ids: Vec<String>,
118    reason: Option<String>,
119    force: bool,
120    defer_verify: bool,
121) -> Result<()> {
122    if ids.is_empty() {
123        return Err(anyhow!("At least one unit ID is required"));
124    }
125
126    let mut any_closed = false;
127    let mut rejected_units = Vec::new();
128
129    for id in &ids {
130        let outcome = ops_close::close(
131            mana_dir,
132            id,
133            CloseOpts {
134                reason: reason.clone(),
135                force,
136                defer_verify,
137            },
138        )?;
139
140        match outcome {
141            CloseOutcome::Closed(result) => {
142                println!("Closed unit {}: {}", id, result.unit.title);
143                any_closed = true;
144
145                print_on_close_results(&result.unit.id, &result.on_close_results);
146                print_close_warnings(&result.unit.id, &result.warnings);
147                if let Some(auto_commit_result) = &result.auto_commit_result {
148                    print_auto_commit_result(auto_commit_result);
149                }
150
151                for parent_id in &result.auto_closed_parents {
152                    // Load from archive to get the title
153                    if let Ok(archived_path) =
154                        crate::discovery::find_archived_unit(mana_dir, parent_id)
155                    {
156                        if let Ok(parent) = Unit::from_file(&archived_path) {
157                            println!("Auto-closed parent unit {}: {}", parent_id, parent.title);
158                        }
159                    }
160                }
161            }
162            CloseOutcome::VerifyFailed(result) => {
163                print_close_warnings(&result.unit.id, &result.warnings);
164
165                // Display detailed failure feedback
166                if result.timed_out {
167                    println!("✗ Verify timed out for unit {}", id);
168                } else {
169                    println!("✗ Verify failed for unit {}", id);
170                }
171                println!();
172                println!("Command: {}", result.verify_command);
173                if result.timed_out {
174                    println!("Timed out after {}s", result.timeout_secs.unwrap_or(0));
175                } else if let Some(code) = result.exit_code {
176                    println!("Exit code: {}", code);
177                }
178                if !result.output.is_empty() {
179                    println!("Output:");
180                    for line in result.output.lines() {
181                        println!("  {}", line);
182                    }
183                }
184                println!();
185                println!("Attempt {}. Unit remains open.", result.attempt_number);
186                println!("Tip: Run `mana verify {}` to test without closing.", id);
187                println!("Tip: Use `mana close {} --force` to skip verify.", id);
188
189                // Display on_fail action info
190                if let Some(action) = result.on_fail_action_taken {
191                    match action {
192                        OnFailActionTaken::Retry {
193                            attempt,
194                            max,
195                            delay_secs,
196                        } => {
197                            println!("on_fail: will retry (attempt {}/{})", attempt, max);
198                            if let Some(delay) = delay_secs {
199                                println!(
200                                    "on_fail: retry delay {}s (enforced by orchestrator)",
201                                    delay
202                                );
203                            }
204                        }
205                        OnFailActionTaken::RetryExhausted { max } => {
206                            println!("on_fail: max retries ({}) exhausted", max);
207                        }
208                        OnFailActionTaken::Escalated => {
209                            // Load unit to get on_fail details
210                            if let Some(crate::unit::OnFailAction::Escalate { priority, message }) =
211                                &result.unit.on_fail
212                            {
213                                if let Some(p) = priority {
214                                    println!("on_fail: escalated priority → P{}", p);
215                                }
216                                if let Some(msg) = message {
217                                    println!("on_fail: {}", msg);
218                                }
219                            }
220                        }
221                        OnFailActionTaken::None => {}
222                    }
223                }
224            }
225            CloseOutcome::RejectedByHook { unit_id } => {
226                eprintln!("Unit {} rejected by pre-close hook", unit_id);
227                rejected_units.push(unit_id);
228            }
229            CloseOutcome::FeatureRequiresHuman {
230                unit_id,
231                title,
232                warnings,
233            } => {
234                print_close_warnings(&unit_id, &warnings);
235
236                use std::io::IsTerminal;
237                if !std::io::stdin().is_terminal() {
238                    println!("Feature \"{}\" requires human review to close.", title);
239                    continue;
240                }
241                eprintln!("Feature: \"{}\" — mark as complete? [y/N] ", title);
242                let mut input = String::new();
243                std::io::stdin().read_line(&mut input).unwrap_or(0);
244                if !input.trim().eq_ignore_ascii_case("y") {
245                    println!("Skipped feature \"{}\"", title);
246                    continue;
247                }
248                // User confirmed — close with force to bypass verify and feature gate.
249                let outcome = ops_close::close(
250                    mana_dir,
251                    &unit_id,
252                    CloseOpts {
253                        reason: reason.clone(),
254                        force: true,
255                        defer_verify: false,
256                    },
257                );
258                match outcome {
259                    Ok(CloseOutcome::Closed(result)) => {
260                        println!("Closed unit {}: {}", unit_id, result.unit.title);
261                        print_on_close_results(&result.unit.id, &result.on_close_results);
262                        print_close_warnings(&result.unit.id, &result.warnings);
263                        if let Some(auto_commit_result) = &result.auto_commit_result {
264                            print_auto_commit_result(auto_commit_result);
265                        }
266                        any_closed = true;
267                    }
268                    Ok(other) => {
269                        eprintln!("Failed to close feature unit {}: {:?}", unit_id, other);
270                    }
271                    Err(err) => {
272                        eprintln!("Failed to close feature unit {}: {}", unit_id, err);
273                    }
274                }
275            }
276            CloseOutcome::CircuitBreakerTripped {
277                unit_id,
278                total_attempts,
279                max,
280                warnings,
281            } => {
282                print_close_warnings(&unit_id, &warnings);
283                eprintln!(
284                    "⚡ Circuit breaker tripped for unit {} \
285                     (subtree total {} >= max_loops {})",
286                    unit_id, total_attempts, max
287                );
288                eprintln!(
289                    "Unit {} escalated to P0 with 'circuit-breaker' label. \
290                     Manual intervention required.",
291                    unit_id
292                );
293            }
294            CloseOutcome::MergeConflict { files, warnings } => {
295                print_close_warnings(id, &warnings);
296                eprintln!("Merge conflict in files: {:?}", files);
297                eprintln!("Resolve conflicts and run `mana close {}` again", id);
298            }
299            CloseOutcome::DeferredVerify { unit_id } => {
300                println!(
301                    "Deferred verify for unit {} — status set to awaiting_verify",
302                    unit_id
303                );
304            }
305        }
306    }
307
308    // Report rejected units
309    if !rejected_units.is_empty() {
310        eprintln!(
311            "Failed to close {} unit(s) due to pre-close hook rejection: {}",
312            rejected_units.len(),
313            rejected_units.join(", ")
314        );
315    }
316
317    // Rebuild index once after all updates
318    if (any_closed || !ids.is_empty()) && mana_dir.exists() {
319        let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
320        index
321            .save(mana_dir)
322            .with_context(|| "Failed to save index")?;
323    }
324
325    Ok(())
326}
327
328/// Mark an attempt as explicitly failed.
329///
330/// The unit stays open and the claim is released so another agent can retry.
331/// Records the failure in attempt_log for episodic memory.
332pub fn cmd_close_failed(mana_dir: &Path, ids: Vec<String>, reason: Option<String>) -> Result<()> {
333    if ids.is_empty() {
334        return Err(anyhow!("At least one unit ID is required"));
335    }
336
337    for id in &ids {
338        let result = ops_close::close_failed(mana_dir, id, reason.clone())?;
339
340        let attempt_count = result.attempt_log.len();
341        println!(
342            "Marked unit {} as failed (attempt #{}): {}",
343            id, attempt_count, result.title
344        );
345        if let Some(ref reason_text) = reason {
346            println!("  Reason: {}", reason_text);
347        }
348        println!("  Unit remains open for retry.");
349    }
350
351    Ok(())
352}
353
354#[cfg(test)]
355#[path = "tests_close.rs"]
356mod tests;
357
358#[cfg(test)]
359#[path = "tests_verify_timeout.rs"]
360mod verify_timeout_tests;