Skip to main content

mana_core/ops/
close.rs

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// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26/// What action was taken by `process_on_fail`.
27#[derive(Debug, serde::Serialize)]
28pub enum OnFailActionTaken {
29    /// Claim released for retry (attempt N / max M).
30    Retry {
31        attempt: u32,
32        max: u32,
33        delay_secs: Option<u64>,
34    },
35    /// Max retries exhausted — claim kept.
36    RetryExhausted { max: u32 },
37    /// Priority escalated and/or message appended.
38    Escalated,
39    /// No on_fail configured.
40    None,
41}
42
43/// Result of a circuit breaker check.
44#[derive(Debug)]
45pub struct CircuitBreakerStatus {
46    pub tripped: bool,
47    pub subtree_total: u32,
48    pub max_loops: u32,
49}
50
51/// Metadata about a verify failure, used by `record_failure`.
52#[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
63/// Options for the full `close` lifecycle.
64pub struct CloseOpts {
65    pub reason: Option<String>,
66    pub force: bool,
67    /// Skip verify and mark as AwaitingVerify instead of Closed.
68    ///
69    /// Set when `--defer-verify` is passed or `MANA_BATCH_VERIFY=1` is in the environment.
70    /// The runner is responsible for running verify later and finalizing the unit.
71    pub defer_verify: bool,
72}
73
74/// Structured warnings emitted during close lifecycle steps.
75#[derive(Debug, serde::Serialize)]
76pub enum CloseWarning {
77    /// The pre-close hook errored, but close was allowed to continue.
78    PreCloseHookError { message: String },
79    /// The post-close hook returned a non-zero exit status.
80    PostCloseHookRejected,
81    /// The post-close hook errored.
82    PostCloseHookError { message: String },
83    /// Worktree cleanup failed after a successful close.
84    WorktreeCleanupFailed { message: String },
85    /// The verify command was changed since claim (--force overrode the block).
86    VerifyChanged,
87}
88
89/// Evidence collected at close time from the diff since claim checkpoint.
90#[derive(Debug, Default, serde::Serialize)]
91pub struct CloseEvidence {
92    /// Files changed since checkpoint.
93    pub changed_files: Vec<String>,
94    /// Total lines added across all files.
95    pub additions: u32,
96    /// Total lines deleted across all files.
97    pub deletions: u32,
98    /// Whether only .mana/ files changed (suspicious for code tasks).
99    pub only_mana_changes: bool,
100    /// Whether no changed file overlaps with unit.paths (suspicious).
101    pub no_path_overlap: bool,
102}
103
104/// Result of an auto-commit attempt after close.
105#[derive(Debug, serde::Serialize)]
106pub struct AutoCommitResult {
107    pub message: String,
108    pub committed: bool,
109    pub warning: Option<String>,
110}
111
112/// Outcome of attempting to close a single unit.
113#[derive(Debug, serde::Serialize)]
114pub enum CloseOutcome {
115    /// The unit was closed and archived.
116    Closed(CloseResult),
117    /// The verify command failed.
118    VerifyFailed(VerifyFailureResult),
119    /// The pre-close hook rejected the close.
120    RejectedByHook { unit_id: String },
121    /// Feature unit requires interactive TTY confirmation.
122    FeatureRequiresHuman {
123        unit_id: String,
124        title: String,
125        warnings: Vec<CloseWarning>,
126    },
127    /// Circuit breaker tripped — too many attempts across the subtree.
128    CircuitBreakerTripped {
129        unit_id: String,
130        total_attempts: u32,
131        max: u32,
132        warnings: Vec<CloseWarning>,
133    },
134    /// Worktree merge had conflicts — unit stays open.
135    MergeConflict {
136        files: Vec<String>,
137        warnings: Vec<CloseWarning>,
138    },
139    /// Verify was deferred — unit is now AwaitingVerify.
140    ///
141    /// Emitted when `CloseOpts::defer_verify` is true. The runner is expected to
142    /// run verify later and transition the unit to Closed or back to Open.
143    DeferredVerify { unit_id: String },
144    /// The verify command was changed after claim — judge integrity violated.
145    VerifyFrozenViolation {
146        unit_id: String,
147        warnings: Vec<CloseWarning>,
148    },
149}
150
151/// Details of a successful close.
152#[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    /// Diff evidence from claim checkpoint, if available.
161    pub evidence: Option<CloseEvidence>,
162}
163
164/// Result of one on_close action execution.
165#[derive(Debug, serde::Serialize)]
166pub enum OnCloseActionResult {
167    /// A `run` command was executed.
168    RanCommand {
169        command: String,
170        success: bool,
171        exit_code: Option<i32>,
172        error: Option<String>,
173    },
174    /// A `notify` message was emitted.
175    Notified { message: String },
176    /// A `run` command was skipped (not trusted).
177    Skipped { command: String },
178}
179
180/// Details of a verify failure during close.
181#[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
209/// Maximum stdout size to capture as outputs (64 KB).
210const MAX_OUTPUT_BYTES: usize = 64 * 1024;
211
212/// Compute close-time evidence: diff from checkpoint, changed files, stats.
213fn 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
313// ---------------------------------------------------------------------------
314// Core close lifecycle
315// ---------------------------------------------------------------------------
316
317/// Close a single unit — the full lifecycle.
318///
319/// Steps: pre-close hook → verify → worktree merge → feature gate → mark closed
320/// → archive → post-close cascade → auto-close parents → rebuild index.
321///
322/// Does NOT handle TTY confirmation for feature units — if the unit is a feature,
323/// returns `CloseOutcome::FeatureRequiresHuman` and the caller decides.
324pub 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    // 1. Pre-close hook
337    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    // 1b. Defer verify — mark as AwaitingVerify and return immediately.
350    //
351    // The runner will collect all AwaitingVerify units after agents complete,
352    // run each unique verify command once, and finalize units accordingly.
353    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    // 1c. Verify freeze check — was the judge changed since claim?
369    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    // 2. Verify (if applicable and not force)
395    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                // Build combined output — on timeout, synthesize a message
408                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                // Record the failure
422                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                // Circuit breaker
434                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                    // Save unit first so subtree count is accurate
440                    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
449                        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                // Process on_fail action
461                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                // Fire on_fail config hook
467                run_on_fail_hook(&unit, project_root, config.as_ref(), &failure.output);
468
469                // Rebuild index
470                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            // Record success in history
497            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 stdout as unit outputs
512            capture_verify_outputs(&mut unit, &verify_result.stdout);
513            refresh_autonomy_disposition(&mut unit);
514        }
515    }
516
517    // 3. Worktree merge (after verify passes, before archiving).
518    // Use the original broad commit behavior for secondary worktrees so implementation
519    // files created in the isolated worktree are merged before the worktree is cleaned up.
520    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    // 4. Feature gate — delegate to caller
531    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    // 4b. Compute close evidence from diff
543    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    // 5. Mark the unit closed
553    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    // Finalize the current attempt as success
560    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    // Update last_verified for facts
569    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    // 6. Archive
579    let archive_path = archive_unit(mana_dir, &mut unit, &unit_path)?;
580
581    // 6b. Rebuild index immediately after archive so stale entries don't linger.
582    // Without this, readers between archive (file rename) and the later rebuild
583    // would see a stale index referencing a now-moved file.
584    rebuild_index(mana_dir)?;
585
586    // 7. Post-close cascade
587    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    // Clean up worktree after successful close
592    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    // 8. Auto-close parents
599    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 before auto-commit so archived units, parent cascades, and
615    // index updates are included in the close commit.
616    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    // Auto-commit if configured (skip in worktree mode — it already commits)
628    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
659/// Mark a unit as explicitly failed. Stays open with claim released.
660///
661/// Records the failure in attempt_log for episodic memory and appends
662/// a structured failure summary to notes.
663pub 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    // Finalize the current attempt as failed
672    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    // Release the claim (unit stays open for retry)
681    unit.claimed_by = None;
682    unit.claimed_at = None;
683    unit.status = Status::Open;
684    unit.updated_at = now;
685
686    // Generate structured failure summary and append to notes
687    {
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
725    rebuild_index(mana_dir)?;
726
727    Ok(unit)
728}
729
730// ---------------------------------------------------------------------------
731// Public composable functions
732// ---------------------------------------------------------------------------
733
734/// Check if all children of a parent unit are closed.
735///
736/// Checks both active and archived units. Returns true if the parent has no
737/// children, or if all children have status=closed.
738pub 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
763/// Auto-close parent chain when all children are done.
764///
765/// Recursively walks up the parent chain, closing and archiving each parent
766/// whose children are all closed. Feature parents are skipped. Returns the
767/// list of parent IDs that were auto-closed.
768pub 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(()), // Already archived
786    };
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    // Feature units are never auto-closed
796    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    // Recurse to grandparent
813    if let Some(grandparent_id) = &unit.parent {
814        auto_close_parent_recursive(mana_dir, grandparent_id, closed)?;
815    }
816
817    Ok(())
818}
819
820/// Archive a closed unit to the dated archive directory.
821///
822/// Moves the unit file, marks `is_archived = true`, and updates the archive index.
823/// Returns the archive path.
824pub 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    // Append to archive index
850    {
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
860/// Record a failed verify attempt on a unit.
861///
862/// Increments attempts, appends failure details to notes, and pushes
863/// a structured history entry. Does not save to disk — caller decides when to write.
864pub 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
921/// Process on_fail actions (retry release, escalate).
922///
923/// Mutates unit in-place (releases claim for retry, escalates priority).
924/// Returns what action was taken.
925pub 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(&note),
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
975/// Check circuit breaker for a unit.
976///
977/// If subtree attempts exceed `max_loops`, trips the breaker: adds
978/// "circuit-breaker" label and sets priority to P0. Unit is mutated
979/// but NOT saved — caller decides when to write.
980pub 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
1017/// Walk up the parent chain to find the root ancestor of a unit.
1018///
1019/// Returns the ID of the topmost parent (the unit with no parent).
1020/// If the unit itself has no parent, returns its own ID.
1021pub 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, &current_id)
1029            .or_else(|_| find_archived_unit(mana_dir, &current_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
1045/// Resolve the effective max_loops for a unit, considering root parent overrides.
1046pub 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
1061// ---------------------------------------------------------------------------
1062// Internal helpers
1063// ---------------------------------------------------------------------------
1064
1065/// Record a verify failure on a unit (internal).
1066fn record_failure_on_unit(unit: &mut Unit, failure: &VerifyFailure) {
1067    unit.attempts += 1;
1068    unit.updated_at = Utc::now();
1069
1070    // Append failure to notes
1071    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    // Record structured history entry
1078    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
1111/// Capture verify stdout as unit outputs.
1112fn 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
1140/// Find the largest byte index <= `max_bytes` that falls on a UTF-8 char boundary.
1141pub 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
1152/// Truncate output to first N + last N lines.
1153pub 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
1171/// Format a verify failure as a Markdown block to append to notes.
1172pub 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
1185// ---------------------------------------------------------------------------
1186// Hook helpers
1187// ---------------------------------------------------------------------------
1188
1189/// Run pre-close hook. Hook errors are returned as warnings but do not block close.
1190fn 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
1212/// Run post-close hook + on_close actions + config hooks.
1213fn 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    // Fire post-close hook
1222    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    // Process on_close actions
1236    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    // Fire on_close config hook
1276    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
1295/// Fire the on_fail config hook.
1296fn 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
1313// ---------------------------------------------------------------------------
1314// Worktree helpers
1315// ---------------------------------------------------------------------------
1316
1317/// Detect and validate worktree context.
1318fn 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
1330/// Commit worktree changes and merge to main.
1331fn 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
1354/// Clean up worktree after successful close.
1355fn 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
1363/// Expand a commit template with placeholder values.
1364///
1365/// Supported placeholders: `{id}`, `{title}`, `{parent_id}`, `{labels}`.
1366fn 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
1454/// Auto-commit changes on close (non-worktree mode).
1455fn 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
1494/// Rebuild the index.
1495fn 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    // =====================================================================
1642    // close() tests
1643    // =====================================================================
1644
1645    #[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    // =====================================================================
1940    // close_failed() tests
1941    // =====================================================================
1942
1943    #[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    // =====================================================================
1968    // all_children_closed() tests
1969    // =====================================================================
1970
1971    #[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    // =====================================================================
1999    // auto_close_parents() tests
2000    // =====================================================================
2001
2002    #[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        // Close the child first
2013        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        // Parent should be auto-closed
2025        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        // Feature parent should still be open
2055        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    // =====================================================================
2062    // archive_unit() tests
2063    // =====================================================================
2064
2065    #[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    // =====================================================================
2081    // record_failure() tests
2082    // =====================================================================
2083
2084    #[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    // =====================================================================
2127    // process_on_fail() tests
2128    // =====================================================================
2129
2130    #[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    // =====================================================================
2200    // check_circuit_breaker() tests
2201    // =====================================================================
2202
2203    #[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    // =====================================================================
2248    // Helper tests
2249    // =====================================================================
2250
2251    #[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    // =====================================================================
2411    // close_defer tests — deferred verify via defer_verify: true
2412    // =====================================================================
2413
2414    /// With defer_verify: true, close() skips the verify command and sets
2415    /// status to AwaitingVerify instead of Closed.
2416    #[test]
2417    fn close_defer_skips_verify() {
2418        let (_dir, mana_dir) = setup_mana_dir();
2419        let mut unit = Unit::new("1", "Task");
2420        // A failing verify — should NOT be run when defer_verify is true.
2421        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        // Unit should be AwaitingVerify, not Closed, and no verify failure recorded.
2436        match outcome {
2437            CloseOutcome::DeferredVerify { .. } => {}
2438            other => panic!("Expected DeferredVerify outcome, got {:?}", other),
2439        }
2440
2441        // Confirm the on-disk state reflects AwaitingVerify.
2442        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        // No verify was run — attempts counter stays at 0.
2455        assert_eq!(saved.attempts, 0);
2456    }
2457
2458    /// With defer_verify: true, the returned outcome is DeferredVerify containing
2459    /// the correct unit ID.
2460    #[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}