ralph/cli/queue/
unlock.rs1use anyhow::{Context, Result, bail};
17use clap::Args;
18use std::io::{self, IsTerminal, Write};
19use std::path::Path;
20
21use crate::config::Resolved;
22use crate::lock::{self, PidLiveness};
23
24#[derive(Args)]
26#[command(after_long_help = "Safely remove the queue lock directory.\n\n\
27Safety:\n - Checks if the lock holder process is still running\n - Blocks if process is active (override with --force)\n - Requires confirmation in interactive mode (bypass with --yes)\n\n\
28Examples:\n ralph queue unlock --dry-run\n ralph queue unlock --yes\n ralph queue unlock --force --yes\n ralph queue unlock --force # Still requires confirmation")]
29pub struct QueueUnlockArgs {
30 #[arg(long)]
32 pub force: bool,
33
34 #[arg(long)]
36 pub yes: bool,
37
38 #[arg(long)]
40 pub dry_run: bool,
41}
42
43pub(crate) fn handle(resolved: &Resolved, args: QueueUnlockArgs) -> Result<()> {
44 let lock_dir = lock::queue_lock_dir(&resolved.repo_root);
45
46 if !lock_dir.exists() {
47 log::info!("Queue is not locked.");
48 return Ok(());
49 }
50
51 let owner = lock::read_lock_owner(&lock_dir)?;
53 let owner_info = format_owner_info(&owner);
54
55 let liveness = owner.as_ref().map(|o| lock::pid_liveness(o.pid));
57 let is_active = liveness
58 .as_ref()
59 .is_some_and(|l| l.is_running_or_indeterminate());
60
61 if args.dry_run {
63 handle_dry_run(&lock_dir, &owner_info, is_active, liveness.as_ref())?;
64 return Ok(());
65 }
66
67 if is_active && !args.force {
69 let pid_str = owner
70 .as_ref()
71 .map(|o| o.pid.to_string())
72 .unwrap_or_else(|| "unknown".to_string());
73 bail!(
74 "Refusing to unlock: lock holder process (PID {}) appears to be still running.\n\
75 Lock holder: {}\n\n\
76 Use --force to override this check, or verify the process has exited.\n\
77 Example: ralph queue unlock --force --yes",
78 pid_str,
79 owner_info
80 );
81 }
82
83 if !args.yes
85 && is_terminal_context()
86 && !confirm_unlock(&lock_dir, &owner_info, is_active, args.force)?
87 {
88 log::info!("Unlock cancelled.");
89 return Ok(());
90 }
91
92 if is_active && args.force {
94 log::warn!(
95 "Force-removing lock for active process (PID: {}). Queue corruption may occur if the process is still writing.",
96 owner.as_ref().map(|o| o.pid).unwrap_or(0)
97 );
98 }
99
100 std::fs::remove_dir_all(&lock_dir)
102 .with_context(|| format!("remove lock dir {}", lock_dir.display()))?;
103
104 log::info!("Queue unlocked (removed {}).", lock_dir.display());
105 Ok(())
106}
107
108fn format_owner_info(owner: &Option<lock::LockOwner>) -> String {
109 match owner {
110 Some(o) => format!(
111 "PID={}, label={}, started={}, command={}",
112 o.pid, o.label, o.started_at, o.command
113 ),
114 None => "(owner metadata missing)".to_string(),
115 }
116}
117
118fn handle_dry_run(
119 lock_dir: &Path,
120 owner_info: &str,
121 is_active: bool,
122 liveness: Option<&PidLiveness>,
123) -> Result<()> {
124 println!("Lock directory: {}", lock_dir.display());
125 println!("Lock holder: {}", owner_info);
126
127 match liveness {
128 Some(PidLiveness::Running) => {
129 println!("Process status: RUNNING (unlock would be blocked without --force)");
130 }
131 Some(PidLiveness::NotRunning) => {
132 println!("Process status: NOT RUNNING (safe to unlock)");
133 }
134 Some(PidLiveness::Indeterminate) => {
135 println!("Process status: INDETERMINATE (unlock would be blocked without --force)");
136 }
137 None => {
138 println!("Process status: UNKNOWN (no owner metadata)");
139 }
140 }
141
142 if is_active {
143 println!(
144 "Would remove: {} (blocked without --force)",
145 lock_dir.display()
146 );
147 } else {
148 println!("Would remove: {} (safe to unlock)", lock_dir.display());
149 }
150
151 println!("Dry run: no changes made.");
152 Ok(())
153}
154
155fn is_terminal_context() -> bool {
156 io::stdin().is_terminal() && io::stdout().is_terminal()
157}
158
159fn confirm_unlock(lock_dir: &Path, owner_info: &str, is_active: bool, force: bool) -> Result<bool> {
160 println!("About to remove lock directory: {}", lock_dir.display());
161 println!("Lock holder: {}", owner_info);
162 if is_active {
163 if force {
164 println!("WARNING: Force-removing lock for ACTIVE process!");
165 println!(" Data corruption may occur if the process is still writing.");
166 } else {
167 println!("WARNING: Lock holder process may still be active!");
168 }
169 }
170 print!("Proceed with unlock? [y/N]: ");
171 io::stdout().flush().context("flush confirmation prompt")?;
172
173 let mut response = String::new();
174 io::stdin()
175 .read_line(&mut response)
176 .context("read confirmation input")?;
177
178 Ok(matches!(
179 response.trim().to_lowercase().as_str(),
180 "y" | "yes"
181 ))
182}