diff --git a/crates/tracedecay-agent-hosts/src/automation/scheduler.rs b/crates/tracedecay-agent-hosts/src/automation/scheduler.rs index fd99797179..d151915c56 100644 --- a/crates/tracedecay-agent-hosts/src/automation/scheduler.rs +++ b/crates/tracedecay-agent-hosts/src/automation/scheduler.rs @@ -725,11 +725,33 @@ async fn lock_is_stale(path: &Path, stale_after_secs: Option, now_secs: i64 } } let Some(created_at) = lock_created_at(path).await? else { - return Ok(true); + // `lock_created_at` yields no timestamp when the lock vanished after + // the contender's `create_new` failed (the holder finished and + // released mid-check) or when no mtime is readable. Treating that as + // stale let the contender "reclaim" a released lock and run a second + // time (the Windows double-execution flake). Report stale only for a + // provably old on-disk lock; a vanished lock stays contended. + return Ok(lock_mtime_elapsed_secs(path, now_secs) + .await + .is_some_and(|elapsed| elapsed >= stale_after_secs)); }; Ok(elapsed_secs(created_at, now_secs) >= stale_after_secs) } +/// Seconds since the lock file was last modified, or `None` when the file or +/// its timestamps cannot be read (treated as not stale by the caller). +async fn lock_mtime_elapsed_secs(path: &Path, now_secs: i64) -> Option { + let metadata = tokio::fs::symlink_metadata(path).await.ok()?; + let modified = metadata.modified().ok()?; + let modified_secs: i64 = modified + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs() + .try_into() + .ok()?; + Some(elapsed_secs(modified_secs, now_secs)) +} + async fn lock_pid(path: &Path) -> Result> { let contents = match tokio::fs::read_to_string(path).await { Ok(contents) => contents, diff --git a/tests/automation_runner_test/scheduler.rs b/tests/automation_runner_test/scheduler.rs index bfcd269406..5d66b45b60 100644 --- a/tests/automation_runner_test/scheduler.rs +++ b/tests/automation_runner_test/scheduler.rs @@ -846,6 +846,72 @@ async fn task_lock_reclaims_stale_dead_pid_lock_file() { assert!(!lock_path.exists()); } +#[tokio::test] +async fn task_lock_does_not_steal_fresh_empty_lock_file() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let temp = tempdir().unwrap(); + let lock_dir = temp.path().join("automation_locks"); + std::fs::create_dir_all(&lock_dir).unwrap(); + let lock_path = lock_dir.join("memory_curator.lock"); + // A concurrent winner has created the lock file but not yet written its + // payload: the file exists and is empty. It must not be treated as stale. + std::fs::write(&lock_path, b"").unwrap(); + let now_secs = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + .unwrap(); + + let lock = AutomationTaskLock::try_acquire( + temp.path(), + AgentTaskKind::MemoryCurator, + Some(3600), + now_secs, + ) + .await + .unwrap(); + + assert!(lock.is_none()); + assert!(lock_path.exists()); +} + +#[tokio::test] +async fn task_lock_reclaims_empty_lock_file_older_than_stale_window() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let temp = tempdir().unwrap(); + let lock_dir = temp.path().join("automation_locks"); + std::fs::create_dir_all(&lock_dir).unwrap(); + let lock_path = lock_dir.join("skill_writer.lock"); + // An empty lock whose mtime is far outside the stale window is a crash + // leftover and stays reclaimable. + std::fs::write(&lock_path, b"").unwrap(); + let now_secs = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + .unwrap() + + 7200; + + let lock = AutomationTaskLock::try_acquire( + temp.path(), + AgentTaskKind::SkillWriter, + Some(3600), + now_secs, + ) + .await + .unwrap(); + + assert!(lock.is_some()); + drop(lock); + assert!(!lock_path.exists()); +} + #[tokio::test] async fn task_lock_keeps_live_pid_lock_file() { let temp = tempdir().unwrap();