Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion crates/tracedecay-agent-hosts/src/automation/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,11 +725,33 @@ async fn lock_is_stale(path: &Path, stale_after_secs: Option<u64>, 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<u64> {
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<Option<u32>> {
let contents = match tokio::fs::read_to_string(path).await {
Ok(contents) => contents,
Expand Down
66 changes: 66 additions & 0 deletions tests/automation_runner_test/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading