Skip to main content

edict/commands/protocol/
cleanup.rs

1//! Protocol cleanup command: check for held resources and suggest cleanup.
2//!
3//! Reads agent's active claims (from bus) and stale workspaces (from maw)
4//! to produce cleanup guidance. Skips release commands for active bone claims.
5//!
6//! Exit policy: always exits 0 with status in stdout (clean or has-resources).
7//! Operational failures (bus/maw unavailable) propagate as anyhow errors → exit 1.
8
9use super::context::ProtocolContext;
10use super::executor;
11use super::exit_policy;
12use super::render::{ProtocolGuidance, ProtocolStatus};
13use super::shell;
14use crate::commands::doctor::OutputFormat;
15
16/// Execute cleanup protocol: check for held resources and output cleanup guidance.
17///
18/// Returns Ok(()) with guidance on stdout (exit 0) for all status outcomes.
19/// ProtocolContext::collect errors propagate as anyhow::Error → exit 1.
20///
21/// When `execute` is true and status is HasResources, runs the cleanup steps
22/// via the executor instead of outputting them as guidance.
23pub fn execute(
24    execute: bool,
25    agent: &str,
26    project: &str,
27    format: OutputFormat,
28) -> anyhow::Result<()> {
29    // Collect state from bus and maw
30    let ctx = ProtocolContext::collect(project, agent)?;
31
32    // Build guidance
33    let mut guidance = ProtocolGuidance::new("cleanup");
34    guidance.bone = None;
35    guidance.workspace = None;
36    guidance.review = None;
37
38    // Analyze active claims
39    let bone_claims = ctx.held_bone_claims();
40    let workspace_claims = ctx.held_workspace_claims();
41
42    // If no resources held, we're clean
43    if bone_claims.is_empty() && workspace_claims.is_empty() {
44        guidance.status = ProtocolStatus::Ready;
45        guidance.advise("No cleanup needed.".to_string());
46        // If execute is true but we're already clean, just report status (no execution needed)
47        return render_cleanup(&guidance, format, execute);
48    }
49
50    // We have resources held
51    guidance.status = ProtocolStatus::HasResources;
52
53    // Build cleanup steps
54    let mut steps = Vec::new();
55
56    // Step 1: Post agent idle message
57    steps.push(shell::bus_send_cmd(
58        "agent",
59        project,
60        "Agent idle",
61        "agent-idle",
62    ));
63
64    // Step 2: Clear statuses
65    steps.push(shell::bus_statuses_clear_cmd("agent"));
66
67    // Step 3: Release claims (but warn if bone claims are active)
68    if !bone_claims.is_empty() {
69        // Add diagnostic warning
70        let bone_list = bone_claims
71            .iter()
72            .map(|(id, _)| id.to_string())
73            .collect::<Vec<_>>()
74            .join(", ");
75        guidance.diagnostic(format!(
76            "WARNING: Active bone claim(s) held: {}. Releasing these marks them as unowned in doing state.",
77            bone_list
78        ));
79    }
80    steps.push(shell::claims_release_all_cmd("agent"));
81
82    guidance.steps(steps);
83
84    // Build summary for advice
85    let summary = format!(
86        "Agent {} has {} bone claim(s) and {} workspace claim(s). \
87         Run these commands to clean up and mark as idle.",
88        agent,
89        bone_claims.len(),
90        workspace_claims.len()
91    );
92    guidance.advise(summary);
93
94    render_cleanup(&guidance, format, execute)
95}
96
97/// Render cleanup guidance in the requested format.
98///
99/// For JSON format, delegates to the standard render path (exit_policy::render_guidance).
100/// For text/pretty formats, uses cleanup-specific rendering optimized for
101/// the cleanup use case (tab-delimited status, claim counts, etc.).
102///
103/// When execute is true and status is HasResources, runs the steps via the executor.
104/// If execute is true but status is Ready (clean), just reports clean status.
105///
106/// All formats exit 0 — status is communicated via stdout content.
107fn render_cleanup(
108    guidance: &ProtocolGuidance,
109    format: OutputFormat,
110    execute: bool,
111) -> anyhow::Result<()> {
112    // If execute flag is set and we have resources to clean up, run the executor
113    if execute && matches!(guidance.status, ProtocolStatus::HasResources) {
114        let report = executor::execute_steps(&guidance.steps)?;
115        let output = executor::render_report(&report, format);
116        println!("{}", output);
117        return Ok(());
118    }
119
120    // Otherwise, render guidance as usual (including when execute=true but status=Ready)
121    match format {
122        OutputFormat::Text => {
123            // Text format: machine-readable, token-efficient
124            let status_text = match guidance.status {
125                ProtocolStatus::Ready => "clean",
126                ProtocolStatus::HasResources => "has-resources",
127                _ => "unknown",
128            };
129            println!("status\t{}", status_text);
130
131            // Count claims if has-resources
132            if matches!(guidance.status, ProtocolStatus::HasResources) {
133                let claim_count = guidance
134                    .diagnostics
135                    .iter()
136                    .find(|d| d.contains("Active bone claim"))
137                    .map(|_| guidance.diagnostics.len())
138                    .unwrap_or(0);
139                println!("claims\t{} active", claim_count);
140                println!();
141                println!("Run these commands to clean up:");
142                for step in &guidance.steps {
143                    println!("  {}", step);
144                }
145            } else {
146                println!("claims\t0 active");
147                println!();
148                println!("No cleanup needed.");
149            }
150            Ok(())
151        }
152        OutputFormat::Pretty => {
153            // Pretty format: human-readable with formatting
154            let status_text = match guidance.status {
155                ProtocolStatus::Ready => "✓ clean",
156                ProtocolStatus::HasResources => "⚠ has-resources",
157                _ => "? unknown",
158            };
159            println!("Status: {}", status_text);
160
161            if matches!(guidance.status, ProtocolStatus::HasResources) {
162                println!();
163                println!("Run these commands to clean up:");
164                for step in &guidance.steps {
165                    println!("  {}", step);
166                }
167
168                if !guidance.diagnostics.is_empty() {
169                    println!();
170                    println!("Warnings:");
171                    for diagnostic in &guidance.diagnostics {
172                        println!("  ⚠ {}", diagnostic);
173                    }
174                }
175            } else {
176                println!("No cleanup needed.");
177            }
178
179            if let Some(advice) = &guidance.advice {
180                println!();
181                println!("Notes: {}", advice);
182            }
183            Ok(())
184        }
185        OutputFormat::Json => {
186            // JSON format: use standard render path for consistency
187            exit_policy::render_guidance(guidance, format)
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_cleanup_status_clean() {
198        // When no resources held, status should be Ready
199        let mut guidance = ProtocolGuidance::new("cleanup");
200        guidance.status = ProtocolStatus::Ready;
201        guidance.advise("No cleanup needed.".to_string());
202
203        assert_eq!(format!("{:?}", guidance.status), "Ready");
204        assert!(guidance.steps.is_empty());
205    }
206
207    #[test]
208    fn test_cleanup_status_has_resources() {
209        // When resources held, status should be HasResources
210        let mut guidance = ProtocolGuidance::new("cleanup");
211        guidance.status = ProtocolStatus::HasResources;
212        guidance.steps(vec![
213            "bus send --agent test-agent test-project \"Agent idle\" -L agent-idle".to_string(),
214            "bus statuses clear --agent test-agent".to_string(),
215            "bus claims release --agent test-agent --all".to_string(),
216        ]);
217
218        assert_eq!(format!("{:?}", guidance.status), "HasResources");
219        assert_eq!(guidance.steps.len(), 3);
220        assert!(guidance.steps.iter().any(|s| s.contains("bus send")));
221        assert!(
222            guidance
223                .steps
224                .iter()
225                .any(|s| s.contains("bus statuses clear"))
226        );
227        assert!(
228            guidance
229                .steps
230                .iter()
231                .any(|s| s.contains("bus claims release"))
232        );
233    }
234
235    #[test]
236    fn test_cleanup_warning_for_active_bones() {
237        // When active bone claims exist, should add warning diagnostic
238        let mut guidance = ProtocolGuidance::new("cleanup");
239        guidance.diagnostic(
240            "WARNING: Active bone claim(s) held: bd-3cqv. \
241             Releasing these marks them as unowned in doing state."
242                .to_string(),
243        );
244
245        assert!(guidance.diagnostics.iter().any(|d| d.contains("WARNING")));
246        assert!(guidance.diagnostics.iter().any(|d| d.contains("bd-3cqv")));
247    }
248}