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 staleness = owner.as_ref().map(lock::classify_lock_owner);
57 let is_active = staleness
58 .as_ref()
59 .is_some_and(|staleness| !staleness.is_stale());
60
61 if args.dry_run {
63 handle_dry_run(&lock_dir, &owner_info, is_active, staleness)?;
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 staleness: Option<lock::LockStaleness>,
123) -> Result<()> {
124 println!("Lock directory: {}", lock_dir.display());
125 println!("Lock holder: {}", owner_info);
126
127 match staleness.map(|staleness| staleness.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 let Some(note) = staleness.and_then(lock::LockStaleness::advisory_note) {
143 println!("Staleness policy: {}", note.trim());
144 }
145
146 if is_active {
147 println!(
148 "Would remove: {} (blocked without --force)",
149 lock_dir.display()
150 );
151 } else {
152 println!("Would remove: {} (safe to unlock)", lock_dir.display());
153 }
154
155 println!("Dry run: no changes made.");
156 Ok(())
157}
158
159fn is_terminal_context() -> bool {
160 io::stdin().is_terminal() && io::stdout().is_terminal()
161}
162
163fn confirm_unlock(lock_dir: &Path, owner_info: &str, is_active: bool, force: bool) -> Result<bool> {
164 println!("About to remove lock directory: {}", lock_dir.display());
165 println!("Lock holder: {}", owner_info);
166 if is_active {
167 if force {
168 println!("WARNING: Force-removing lock for ACTIVE process!");
169 println!(" Data corruption may occur if the process is still writing.");
170 } else {
171 println!("WARNING: Lock holder process may still be active!");
172 }
173 }
174 print!("Proceed with unlock? [y/N]: ");
175 io::stdout().flush().context("flush confirmation prompt")?;
176
177 let mut response = String::new();
178 io::stdin()
179 .read_line(&mut response)
180 .context("read confirmation input")?;
181
182 Ok(matches!(
183 response.trim().to_lowercase().as_str(),
184 "y" | "yes"
185 ))
186}