1use anyhow::{bail, Result};
2use std::path::Path;
3
4use crate::db::Database;
5use crate::identity::AgentConfig;
6use crate::sync::SyncManager;
7
8#[derive(Debug, PartialEq)]
10pub enum LockStatus {
11 NotConfigured,
13 Available,
15 LockedBySelf,
17 LockedByOther { agent_id: String, stale: bool },
19}
20
21pub fn check_lock(chainlink_dir: &Path, issue_id: i64) -> Result<LockStatus> {
27 let agent = match AgentConfig::load(chainlink_dir)? {
29 Some(a) => a,
30 None => return Ok(LockStatus::NotConfigured),
31 };
32
33 let sync = match SyncManager::new(chainlink_dir) {
35 Ok(s) => s,
36 Err(_) => return Ok(LockStatus::NotConfigured),
37 };
38
39 let _ = sync.init_cache();
41 let _ = sync.fetch();
42
43 if !sync.is_initialized() {
45 return Ok(LockStatus::NotConfigured);
46 }
47
48 let locks = match sync.read_locks() {
49 Ok(l) => l,
50 Err(_) => return Ok(LockStatus::NotConfigured),
51 };
52
53 if !locks.is_locked(issue_id) {
55 return Ok(LockStatus::Available);
56 }
57
58 if locks.is_locked_by(issue_id, &agent.agent_id) {
60 return Ok(LockStatus::LockedBySelf);
61 }
62
63 match locks.get_lock(issue_id) {
65 Some(lock) => {
66 let stale = sync
67 .find_stale_locks()
68 .unwrap_or_default()
69 .iter()
70 .any(|(id, _)| *id == issue_id);
71 Ok(LockStatus::LockedByOther {
72 agent_id: lock.agent_id.clone(),
73 stale,
74 })
75 }
76 None => Ok(LockStatus::Available),
77 }
78}
79
80fn read_auto_steal_config(chainlink_dir: &Path) -> Option<u64> {
84 let config_path = chainlink_dir.join("hook-config.json");
85 let content = std::fs::read_to_string(&config_path).ok()?;
86 let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
87 match parsed.get("auto_steal_stale_locks")? {
88 serde_json::Value::Bool(false) => None,
89 serde_json::Value::Number(n) => n.as_u64().filter(|&v| v > 0),
90 serde_json::Value::String(s) if s == "false" => None,
91 serde_json::Value::String(s) => s.parse::<u64>().ok().filter(|&v| v > 0),
92 _ => None,
93 }
94}
95
96fn auto_steal_if_configured(
100 chainlink_dir: &Path,
101 issue_id: i64,
102 stale_agent_id: &str,
103 db: &Database,
104) -> Result<bool> {
105 let multiplier = match read_auto_steal_config(chainlink_dir) {
106 Some(m) => m,
107 None => return Ok(false),
108 };
109
110 let sync = match SyncManager::new(chainlink_dir) {
111 Ok(s) => s,
112 Err(_) => return Ok(false),
113 };
114
115 if !sync.is_initialized() {
116 return Ok(false);
117 }
118
119 let stale_locks = sync.find_stale_locks_with_age()?;
120 let stale_minutes = match stale_locks.iter().find(|(id, _, _)| *id == issue_id) {
121 Some((_, _, mins)) => *mins,
122 None => return Ok(false),
123 };
124
125 let stale_timeout = sync
126 .read_locks()
127 .map(|l| l.settings.stale_lock_timeout_minutes)
128 .unwrap_or(60);
129 let auto_steal_threshold = multiplier.saturating_mul(stale_timeout);
130
131 if stale_minutes < auto_steal_threshold {
132 return Ok(false);
133 }
134
135 let agent = match AgentConfig::load(chainlink_dir)? {
137 Some(a) => a,
138 None => return Ok(false),
139 };
140 sync.claim_lock(&agent, issue_id, None, true)?;
141 let comment = format!(
142 "[auto-steal] Lock auto-stolen from agent '{}' (stale for {} min, threshold: {} min)",
143 stale_agent_id, stale_minutes, auto_steal_threshold
144 );
145 if let Err(e) = db.add_comment(issue_id, &comment, "system") {
146 tracing::warn!("could not add audit comment for lock steal: {e}");
147 }
148
149 Ok(true)
150}
151
152pub fn enforce_lock(chainlink_dir: &Path, issue_id: i64, db: &Database) -> Result<()> {
157 match check_lock(chainlink_dir, issue_id)? {
158 LockStatus::NotConfigured | LockStatus::Available | LockStatus::LockedBySelf => Ok(()),
159 LockStatus::LockedByOther { agent_id, stale } => {
160 if stale {
161 match auto_steal_if_configured(chainlink_dir, issue_id, &agent_id, db) {
162 Ok(true) => {
163 tracing::info!(
164 "Auto-stole stale lock on issue #{} from '{}'.",
165 issue_id,
166 agent_id
167 );
168 return Ok(());
169 }
170 Ok(false) => {}
171 Err(e) => {
172 tracing::warn!(
173 "Auto-steal of stale lock on #{} failed: {}. Proceeding.",
174 issue_id,
175 e
176 );
177 }
178 }
179
180 tracing::warn!(
181 "Issue {} is locked by '{}' but the lock appears STALE. Proceeding.",
182 crate::utils::format_issue_id(issue_id),
183 agent_id
184 );
185 Ok(())
186 } else {
187 bail!(
188 "Issue {} is locked by agent '{}'. \
189 Use 'chainlink locks check {}' for details. \
190 Ask the human to release it or wait for the lock to expire.",
191 crate::utils::format_issue_id(issue_id),
192 agent_id,
193 issue_id
194 )
195 }
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use tempfile::tempdir;
204
205 fn temp_db() -> Database {
206 Database::open(Path::new(":memory:")).unwrap()
207 }
208
209 fn write_agent_config(chainlink_dir: &Path, agent_id: &str) {
210 let agent_json = serde_json::json!({
211 "agent_id": agent_id,
212 "machine_id": "test-machine"
213 });
214 std::fs::write(
215 chainlink_dir.join("agent.json"),
216 serde_json::to_string(&agent_json).unwrap(),
217 )
218 .unwrap();
219 }
220
221 #[test]
222 fn test_no_agent_config_returns_not_configured() {
223 let dir = tempdir().unwrap();
224 let chainlink_dir = dir.path().join(".chainlink");
225 std::fs::create_dir_all(&chainlink_dir).unwrap();
226
227 let status = check_lock(&chainlink_dir, 1).unwrap();
228 assert_eq!(status, LockStatus::NotConfigured);
229 }
230
231 #[test]
232 fn test_enforce_not_configured_allows() {
233 let dir = tempdir().unwrap();
234 let chainlink_dir = dir.path().join(".chainlink");
235 std::fs::create_dir_all(&chainlink_dir).unwrap();
236
237 let db = temp_db();
238 assert!(enforce_lock(&chainlink_dir, 1, &db).is_ok());
239 }
240
241 #[test]
242 fn test_check_lock_agent_config_no_cache_returns_not_configured() {
243 let dir = tempdir().unwrap();
244 let chainlink_dir = dir.path().join(".chainlink");
245 std::fs::create_dir_all(&chainlink_dir).unwrap();
246 write_agent_config(&chainlink_dir, "worker-1");
247
248 let status = check_lock(&chainlink_dir, 42).unwrap();
249 assert_eq!(status, LockStatus::NotConfigured);
250 }
251
252 #[test]
253 fn test_enforce_lock_agent_config_no_cache_allows() {
254 let dir = tempdir().unwrap();
255 let chainlink_dir = dir.path().join(".chainlink");
256 std::fs::create_dir_all(&chainlink_dir).unwrap();
257 write_agent_config(&chainlink_dir, "worker-1");
258
259 let db = temp_db();
260 assert!(enforce_lock(&chainlink_dir, 42, &db).is_ok());
261 }
262
263 #[test]
264 fn test_lock_status_debug() {
265 let statuses = vec![
266 LockStatus::NotConfigured,
267 LockStatus::Available,
268 LockStatus::LockedBySelf,
269 LockStatus::LockedByOther {
270 agent_id: "worker-1".to_string(),
271 stale: false,
272 },
273 LockStatus::LockedByOther {
274 agent_id: "worker-2".to_string(),
275 stale: true,
276 },
277 ];
278 for s in statuses {
279 let _ = format!("{:?}", s);
280 }
281 }
282
283 #[test]
284 fn test_lock_status_equality() {
285 assert_eq!(LockStatus::NotConfigured, LockStatus::NotConfigured);
286 assert_eq!(LockStatus::Available, LockStatus::Available);
287 assert_eq!(LockStatus::LockedBySelf, LockStatus::LockedBySelf);
288 assert_ne!(LockStatus::Available, LockStatus::NotConfigured);
289 assert_eq!(
290 LockStatus::LockedByOther {
291 agent_id: "a".to_string(),
292 stale: false
293 },
294 LockStatus::LockedByOther {
295 agent_id: "a".to_string(),
296 stale: false
297 }
298 );
299 assert_ne!(
300 LockStatus::LockedByOther {
301 agent_id: "a".to_string(),
302 stale: false
303 },
304 LockStatus::LockedByOther {
305 agent_id: "b".to_string(),
306 stale: false
307 }
308 );
309 assert_ne!(
310 LockStatus::LockedByOther {
311 agent_id: "a".to_string(),
312 stale: false
313 },
314 LockStatus::LockedByOther {
315 agent_id: "a".to_string(),
316 stale: true
317 }
318 );
319 }
320
321 #[test]
322 fn test_auto_steal_config_disabled() {
323 let dir = tempdir().unwrap();
324 std::fs::write(
325 dir.path().join("hook-config.json"),
326 r#"{"auto_steal_stale_locks": false}"#,
327 )
328 .unwrap();
329 assert_eq!(read_auto_steal_config(dir.path()), None);
330 }
331
332 #[test]
333 fn test_auto_steal_config_enabled_int() {
334 let dir = tempdir().unwrap();
335 std::fs::write(
336 dir.path().join("hook-config.json"),
337 r#"{"auto_steal_stale_locks": 2}"#,
338 )
339 .unwrap();
340 assert_eq!(read_auto_steal_config(dir.path()), Some(2));
341 }
342
343 #[test]
344 fn test_auto_steal_config_enabled_string() {
345 let dir = tempdir().unwrap();
346 std::fs::write(
347 dir.path().join("hook-config.json"),
348 r#"{"auto_steal_stale_locks": "3"}"#,
349 )
350 .unwrap();
351 assert_eq!(read_auto_steal_config(dir.path()), Some(3));
352 }
353
354 #[test]
355 fn test_auto_steal_config_string_false() {
356 let dir = tempdir().unwrap();
357 std::fs::write(
358 dir.path().join("hook-config.json"),
359 r#"{"auto_steal_stale_locks": "false"}"#,
360 )
361 .unwrap();
362 assert_eq!(read_auto_steal_config(dir.path()), None);
363 }
364
365 #[test]
366 fn test_auto_steal_config_missing() {
367 let dir = tempdir().unwrap();
368 assert_eq!(read_auto_steal_config(dir.path()), None);
369 }
370
371 #[test]
372 fn test_auto_steal_config_zero() {
373 let dir = tempdir().unwrap();
374 std::fs::write(
375 dir.path().join("hook-config.json"),
376 r#"{"auto_steal_stale_locks": 0}"#,
377 )
378 .unwrap();
379 assert_eq!(read_auto_steal_config(dir.path()), None);
380 }
381}