Skip to main content

chainlink/
lock_check.rs

1use anyhow::{bail, Result};
2use std::path::Path;
3
4use crate::db::Database;
5use crate::identity::AgentConfig;
6use crate::sync::SyncManager;
7
8/// Result of checking whether an agent can work on an issue.
9#[derive(Debug, PartialEq)]
10pub enum LockStatus {
11    /// No lock system configured (no agent.json). Single-agent mode.
12    NotConfigured,
13    /// Issue is not locked by anyone.
14    Available,
15    /// Issue is locked by this agent. Proceed.
16    LockedBySelf,
17    /// Issue is locked by another agent.
18    LockedByOther { agent_id: String, stale: bool },
19}
20
21/// Check whether the current agent can work on the given issue.
22///
23/// Returns `LockStatus` without blocking — callers decide how to handle.
24/// Gracefully degrades: if agent config is missing, sync fails, or we're
25/// offline, returns `NotConfigured` so single-agent usage is unaffected.
26pub fn check_lock(chainlink_dir: &Path, issue_id: i64) -> Result<LockStatus> {
27    // If no agent config, we're in single-agent mode — no lock checking
28    let agent = match AgentConfig::load(chainlink_dir)? {
29        Some(a) => a,
30        None => return Ok(LockStatus::NotConfigured),
31    };
32
33    // Try to create sync manager. If it fails, don't block.
34    let sync = match SyncManager::new(chainlink_dir) {
35        Ok(s) => s,
36        Err(_) => return Ok(LockStatus::NotConfigured),
37    };
38
39    // INTENTIONAL: init and fetch are best-effort — don't fail if offline
40    let _ = sync.init_cache();
41    let _ = sync.fetch();
42
43    // If cache still isn't set up, can't check locks
44    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    // Fast-path: if not locked at all, it's available
54    if !locks.is_locked(issue_id) {
55        return Ok(LockStatus::Available);
56    }
57
58    // Check if locked by this agent
59    if locks.is_locked_by(issue_id, &agent.agent_id) {
60        return Ok(LockStatus::LockedBySelf);
61    }
62
63    // Must be locked by someone else (is_locked returned true above)
64    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
80/// Read the `auto_steal_stale_locks` setting from hook-config.json.
81///
82/// Returns `None` if disabled or missing, `Some(multiplier)` if enabled.
83fn 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
96/// Attempt to auto-steal a stale lock if configured.
97///
98/// Returns `Ok(true)` if the lock was auto-stolen, `Ok(false)` if not eligible.
99fn 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    // Perform the steal
136    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
152/// Enforce lock check. Bails if another agent holds the lock (unless stale).
153///
154/// When `auto_steal_stale_locks` is configured in hook-config.json and the lock
155/// has been stale long enough, automatically steals it and records an audit comment.
156pub 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}