1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
//! One writer at a time, per path.
//!
//! Agents sharing a working tree write the same bytes with no arbitration.
//! Sub-agents get their own checkout now (#1151), but that isolation degrades
//! to the parent's directory outside a repository or when git refuses, and in
//! those cases a fan-out is back to sharing one tree.
//!
//! Nothing else here guards a file. The locks that exist are process- or
//! session-scoped: one instance per profile, one scheduler, one credential
//! holder, one turn per Telegram session.
//!
//! The rule is enforced by the write itself rather than by asking agents to
//! check first. A prompt-layer gate would not reach two sub-agents colliding
//! in the degraded path, since neither reads the parent's instructions, and
//! nothing would reveal that one had skipped the check.
//!
//! Deliberately weak in three ways, because a lock that can block real work is
//! worse than the collision it prevents:
//!
//! - Advisory and held across one write, never across a turn.
//! - A contended write waits briefly, then proceeds with a warning.
//! - Every failure to lock degrades to writing anyway.
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// How long to wait for another writer before going ahead regardless. Long
/// enough to serialise two agents saving the same file, short enough that a
/// crashed holder costs a pause rather than a wedge.
const WAIT_FOR_HOLDER: Duration = Duration::from_millis(750);
/// Gap between attempts while waiting.
const RETRY_EVERY: Duration = Duration::from_millis(25);
/// Held for the duration of one write. The lock is released when this drops,
/// including on a panic, so a writer that dies mid-write does not keep it.
#[derive(Debug)]
pub(crate) struct PathWriteLock {
/// Kept because the kernel releases the `flock` when this descriptor
/// closes. Nothing reads it.
#[allow(dead_code)]
file: std::fs::File,
/// Whether this writer actually holds the lock. `false` means it waited,
/// gave up, and is writing anyway.
held: bool,
}
impl PathWriteLock {
/// Did this writer get the lock, or is it proceeding uncontended-anyway?
pub(crate) fn is_held(&self) -> bool {
self.held
}
}
/// Where a path's lock file lives: under the profile home, keyed by a hash of
/// the absolute path.
///
/// Beside the target would be simpler and wrong: it would litter the user's
/// tree with lock files, and a repository would see them as untracked
/// additions the agent then tries to explain.
fn lock_path_for(target: &Path) -> Option<PathBuf> {
let absolute = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&absolute, &mut hasher);
let key = format!("{:016x}", std::hash::Hasher::finish(&hasher));
let dir = crate::config::profile::resolve_profile_home().join("locks/paths");
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::debug!("path lock: cannot create {}: {e}", dir.display());
return None;
}
Some(dir.join(format!("{key}.lock")))
}
/// Take the write lock for `target`, waiting briefly for another writer.
///
/// Never fails: the returned guard reports whether the lock was actually
/// acquired, and the caller writes either way. On a platform or filesystem
/// where locking is unavailable this is a no-op that reports `is_held()`
/// false, which is exactly today's behaviour.
pub(crate) fn acquire(target: &Path) -> Option<PathWriteLock> {
let lock_path = lock_path_for(target)?;
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.ok()?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let deadline = Instant::now() + WAIT_FOR_HOLDER;
loop {
match crate::config::flock::exclusive(fd, true) {
crate::config::flock::FlockOutcome::Acquired => {
return Some(PathWriteLock { file, held: true });
}
crate::config::flock::FlockOutcome::Held => {
if Instant::now() >= deadline {
// Another writer is still in there. Proceed rather
// than refuse: a blocked write is worse than an
// interleaved one, and the caller reports the overlap.
return Some(PathWriteLock { file, held: false });
}
std::thread::sleep(RETRY_EVERY);
}
crate::config::flock::FlockOutcome::Failed(e) => {
tracing::debug!(
"path lock: cannot lock {}: {e} — writing without it",
lock_path.display()
);
return Some(PathWriteLock { file, held: false });
}
}
}
}
#[cfg(not(unix))]
{
Some(PathWriteLock { file, held: false })
}
}
/// The note appended to a tool result when a write went ahead without the
/// lock, so an overlap is visible instead of silent.
pub(crate) fn contention_notice(target: &Path) -> String {
format!(
"\n\nNote: another agent was writing `{}` at the same time. Both writes went \
through, so this file may not contain what either intended — re-read it before \
relying on it.",
target.display()
)
}