trusty-mpm 1.3.5

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
//! Per-turn file-change budget for `tm hook --pm-guard` (Bob's directive,
//! 2026-07-17; see issue #2918).
//!
//! Why: prior to this, a PM direct source-code edit ([`super::pm_guard::SOURCE_EDIT_REASON`])
//! or shell-based file edit ([`super::pm_guard_bash::SHELL_EDIT_REASON`]) was an
//! absolute, always-deny prohibition — forcing a full Task/Agent delegation
//! round-trip even for a trivial one-line fix. Bob's directive relaxes this to
//! a budget: the PM may make up to [`DEFAULT_FILE_CHANGE_BUDGET`] such file
//! changes in a turn before the guard hard-blocks. A `PreToolUse` hook is
//! stateless and per-invocation — Claude Code's hook payload carries a
//! `session_id` but no `turn_id` (confirmed against the live hooks reference,
//! <https://code.claude.com/docs/en/hooks>, 2026-07-17: hooks do not expose a
//! turn/message boundary). Wiring a true turn-boundary signal would mean
//! registering a NEW `UserPromptSubmit` hook entry solely to reset a counter —
//! a much larger, riskier change to `session_launch::settings::write_project_hooks`
//! for this PR's scope. Per the task's own documented fallback, this module
//! instead uses a short-TTL counter file keyed by `session_id`: the count
//! resets once [`TURN_WINDOW_SECS`] elapses since the window started. This is
//! a heuristic approximation of "per turn" (a very slow-paced single turn could
//! see its budget silently reset mid-turn, and a rapid-fire sequence of
//! genuinely separate turns within the window shares one budget) — documented
//! here as the known limitation; a precise fix is tracked as follow-up
//! (register a `UserPromptSubmit` reset hook) if the heuristic proves
//! insufficient in practice. This window heuristic is deliberately left
//! UNCHANGED pending Bob's sign-off — see #2918.
//! What: [`record_file_change`] is the I/O-performing entry point `pm_guard`
//! calls once it has already decided a call WOULD be denied as a budget-
//! eligible file change; it loads (or initializes) the session's
//! [`BudgetState`] from `<FrameworkPaths::default().root>/state/pm_guard_turn_budget/<session>.json`,
//! runs the pure [`advance_budget`] core, persists the updated state, and
//! returns a [`BudgetDecision`].
//!
//! **Storage-failure posture (fails toward DENY, not allow — code-critic
//! finding on PR #2928):** an earlier version of this module failed OPEN on
//! any persist error, silently returning `Allowed` forever — that is a NEW
//! fail-open vector for P1/P5: a state dir that is unwritable (permissions,
//! disk full, `rm -rf`'d mid-session by an ALLOWED command) meant every call
//! re-read a missing file, saw `prev = None`, and unconditionally allowed —
//! unlimited silent source-code/shell edits with zero telemetry. Persisting
//! now goes through [`persist_and_verify`], which writes AND reads the file
//! back to confirm the write actually landed (catches a write that silently
//! no-ops, e.g. a read-only bind mount) before trusting the decision; any
//! failure in that path is logged via `tracing::warn!` and the call is
//! downgraded to [`BudgetDecision::Exhausted`] regardless of what
//! [`advance_budget`] computed — i.e. storage trouble reverts to the
//! pre-#2918 unconditional-deny behavior, never to unconditional-allow. This
//! only affects the rare storage-failure branch; the common path (storage
//! healthy) is unaffected.
//!
//! **Read-side posture (round 2, code-critic PR #2928 HIGH):** the paragraph
//! above covers the WRITE side; the read side had the identical fail-open bug
//! in the opposite direction — [`record_file_change_at`] loaded the prior
//! state via `.ok()`, which collapsed "no prior session" (legitimately fresh —
//! `ErrorKind::NotFound`) and "the file exists but is unreadable or corrupt"
//! (permissions, a directory sitting at the file's path, truncated/garbage
//! JSON) into the same `None`, silently granting a fresh window with zero
//! telemetry. [`read_prev_state`] now distinguishes the two: only
//! `NotFound` yields `Ok(None)`; every other read or parse failure is
//! `Err(_)`, logged via `tracing::warn!`, and downgraded to
//! [`BudgetDecision::Exhausted`] — symmetric with the write-side posture
//! above.
//!
//! **Concurrency:** two `tm hook --pm-guard` processes for the SAME session
//! can fire close together (parallel tool calls in one turn). The
//! read-advance-write sequence is protected by [`with_session_lock`], a
//! bounded-retry `O_EXCL` lockfile (`<file>.lock`) so concurrent
//! read-modify-write cycles serialize instead of racing a lost update (which
//! would let more than the budget through). Lock acquisition is capped well
//! under the hook's 10s `PreToolUse` timeout; on a timeout (e.g. a stale lock
//! from a crashed process older than [`STALE_LOCK_SECS`] is atomically cleared
//! first via [`try_clear_if_stale`], so this should only fire under genuine
//! contention or a wedged holder) the call ALSO fails toward `Exhausted`,
//! consistent with the storage-failure posture above. **Round 2 (code-critic
//! PR #2928 MEDIUM):** the original stale-clear was a `metadata`-check +
//! unconditional `remove_file` — a TOCTOU race where two racers could both
//! judge the same lock stale, and a loser's `remove_file` could delete a
//! winner's freshly-recreated lock purely by path, letting a third racer also
//! acquire (double acquisition). [`try_clear_if_stale`] now claims the clear
//! atomically via `fs::rename` to a unique per-racer sibling path: `rename`'s
//! atomicity means at most one racer's rename can observe and move the
//! original path, so only that racer proceeds to delete it and retry.
//! Test: `advance_budget_*` (pure core), `record_file_change_*` (I/O
//! round-trip, using a temp dir), `persist_and_verify_*`,
//! `verify_roundtrip_*`, `with_session_lock_*`.

use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};
use trusty_mpm::core::paths::FrameworkPaths;

/// Default per-turn file-change budget (Bob's directive: 3).
///
/// Why: no pm_guard-specific entry exists yet in the general
/// `TrustyToolsConfig`/`config_read` surface (issue #2918 tracks adding one if
/// operators need to tune this); per the task's own fallback this ships as a
/// hardcoded constant.
/// What: the number of budget-eligible file changes (source-code Edit/Write/
/// MultiEdit/NotebookEdit, or a shell-based file edit) allowed per turn-window
/// before [`advance_budget`] returns [`BudgetDecision::Exhausted`].
pub(crate) const DEFAULT_FILE_CHANGE_BUDGET: usize = 3;

/// Width of the turn-window heuristic, in seconds.
///
/// Why: see the module doc — this approximates "since the PM's current turn
/// began" in the absence of a true turn-boundary hook signal. Ten minutes
/// comfortably spans a burst of same-turn tool calls (which fire seconds
/// apart) while still resetting well within an operator's normal between-turn
/// pacing. Left unchanged pending Bob's sign-off (see module doc).
const TURN_WINDOW_SECS: u64 = 600;

/// Total wall-clock budget for acquiring a session's lockfile, in
/// milliseconds.
///
/// Why: `tm hook --pm-guard` runs under a 10s `PreToolUse` timeout
/// (`session_launch::settings::pm_guard_hook_value`); this must stay a small
/// fraction of that so lock contention can never itself wedge a tool call.
const LOCK_ACQUIRE_BUDGET_MS: u64 = 200;

/// Sleep between lock-acquisition retries, in milliseconds.
const LOCK_RETRY_SLEEP_MS: u64 = 5;

/// A lockfile older than this is treated as abandoned (left behind by a
/// crashed/killed `tm hook --pm-guard` process, which — being a short-lived
/// per-invocation process — can never itself release the lock) and is force-
/// removed before the next acquisition attempt.
const STALE_LOCK_SECS: u64 = 5;

/// Persisted per-session budget counter.
///
/// Why: must survive across the short-lived `tm hook --pm-guard` process
/// invocations (one per tool call), so it is a small JSON file, not in-memory
/// state.
/// What: `count` is the number of budget-eligible file changes recorded so far
/// in the current window; `window_started_at` is the unix-seconds timestamp
/// the window opened. [`advance_budget`] resets both when the window has
/// expired.
/// Test: covered via `advance_budget_*` and the round-trip
/// `record_file_change_*` tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
struct BudgetState {
    count: usize,
    window_started_at: u64,
}

/// Outcome of recording one budget-eligible file change.
///
/// Why: [`super::pm_guard::pm_guard`] needs to know both whether to allow the
/// call AND the exact count to splice into the budget-exhausted deny message
/// ("file-change budget 3/3 used this turn").
/// What: `Allowed { used, budget }` — the call is within budget, `used` is the
/// 1-based count AFTER recording this change; `Exhausted { budget }` — the
/// call is the `budget + 1`-th (or later) in the window and must be denied.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BudgetDecision {
    Allowed { used: usize, budget: usize },
    Exhausted { budget: usize },
}

/// Pure core: advance a (possibly absent/expired) [`BudgetState`] by one
/// file-change and decide allow/exhaust.
///
/// Why: separated from all I/O so the turn-window and budget-boundary logic is
/// fully unit-testable without touching the filesystem.
/// What: `prev == None`, or `prev` whose `window_started_at` is more than
/// [`TURN_WINDOW_SECS`] before `now`, starts a fresh window at count 1
/// (`Allowed { used: 1, .. }`). Otherwise increments `prev.count`; a result
/// `<= budget` is `Allowed`, a result `> budget` is `Exhausted` and the state
/// is NOT incremented past `budget` (so a sustained stream of denied calls
/// doesn't overflow `count` — it saturates at `budget`, keeping the persisted
/// state small and the decision idempotent for repeated denies in the same
/// window).
/// Test: `advance_budget_starts_fresh_window`,
/// `advance_budget_allows_up_to_budget`,
/// `advance_budget_denies_past_budget`,
/// `advance_budget_resets_after_window_expiry`,
/// `advance_budget_saturates_count_on_repeated_denial`.
fn advance_budget(
    prev: Option<BudgetState>,
    now: u64,
    budget: usize,
) -> (BudgetState, BudgetDecision) {
    let window_live =
        prev.is_some_and(|s| now.saturating_sub(s.window_started_at) < TURN_WINDOW_SECS);
    let base = if window_live {
        prev.expect("window_live implies prev is Some")
    } else {
        BudgetState {
            count: 0,
            window_started_at: now,
        }
    };
    let next_count = base.count + 1;
    if next_count <= budget {
        let state = BudgetState {
            count: next_count,
            window_started_at: base.window_started_at,
        };
        (
            state,
            BudgetDecision::Allowed {
                used: next_count,
                budget,
            },
        )
    } else {
        // Saturate: don't let count grow unbounded across repeated denials.
        let state = BudgetState {
            count: budget,
            window_started_at: base.window_started_at,
        };
        (state, BudgetDecision::Exhausted { budget })
    }
}

/// Sanitize a `session_id` into a safe filename component.
///
/// Why: `session_id` is attacker-influenced-in-principle payload data (it
/// comes from the `PreToolUse` stdin JSON); it must never be used to construct
/// a path that escapes the state directory.
/// What: keeps ASCII alphanumerics, `-`, and `_`; replaces every other byte
/// with `_`. An empty input becomes `"_default"` so the file is still
/// resolvable (rather than colliding with the directory itself).
/// Test: `sanitize_session_id_*`.
fn sanitize_session_id(session_id: &str) -> String {
    if session_id.is_empty() {
        return "_default".to_string();
    }
    session_id
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

/// The state directory budget counter files live under.
///
/// What: `<FrameworkPaths::default().root>/state/pm_guard_turn_budget/`.
fn state_dir() -> PathBuf {
    FrameworkPaths::default()
        .root
        .join("state/pm_guard_turn_budget")
}

/// Load, advance, and persist the budget counter for `session_id` — the I/O
/// entry point `pm_guard` calls for each budget-eligible file-change denial.
///
/// Why: thin wrapper that supplies the real state dir and clock to
/// [`record_file_change_at`] — see that function's doc, and the module doc's
/// storage-failure/concurrency sections, for the actual fail-toward-deny
/// posture (code-critic PR #2928 HIGH finding).
/// What: delegates to [`record_file_change_at`] with [`state_dir`] and
/// [`now_unix`].
/// Test: `record_file_change_allows_first_three_then_exhausts`,
/// `record_file_change_isolates_by_session`,
/// `record_file_change_denies_on_persist_failure`.
pub(crate) fn record_file_change(session_id: &str, budget: usize) -> BudgetDecision {
    record_file_change_at(&state_dir(), session_id, budget, now_unix())
}

/// Testable core of [`record_file_change`] with the state dir and clock
/// injected.
///
/// Why/What: see the module doc's storage-failure and concurrency sections —
/// this is where both are enforced. Acquires [`with_session_lock`] around the
/// full read-advance-persist cycle; on a lock timeout OR a
/// [`persist_and_verify`] failure, logs via `tracing::warn!` and returns
/// [`BudgetDecision::Exhausted`] regardless of what [`advance_budget`]
/// computed — storage/lock trouble fails toward deny, never toward the
/// unconditional-allow the pre-fix version risked.
/// Test: `record_file_change_allows_first_three_then_exhausts`,
/// `record_file_change_isolates_by_session`,
/// `record_file_change_denies_on_persist_failure`.
fn record_file_change_at(dir: &Path, session_id: &str, budget: usize, now: u64) -> BudgetDecision {
    let sanitized = sanitize_session_id(session_id);
    let file = dir.join(format!("{sanitized}.json"));
    let lock_path = dir.join(format!("{sanitized}.lock"));

    let Some(_guard) = with_session_lock(&lock_path) else {
        tracing::warn!(
            session_id,
            "pm_guard_budget: could not acquire the turn-budget lockfile within \
             {LOCK_ACQUIRE_BUDGET_MS}ms; failing toward deny (treating as \
             budget-exhausted) rather than racing an unprotected read-modify-write"
        );
        return BudgetDecision::Exhausted { budget };
    };

    let prev = match read_prev_state(&file) {
        Ok(prev) => prev,
        Err(err) => {
            tracing::warn!(
                session_id,
                error = %err,
                "pm_guard_budget: failed to read/parse prior turn-budget state; \
                 failing toward deny (treating as budget-exhausted) rather than \
                 silently treating a corrupt/unreadable state file as a fresh window"
            );
            return BudgetDecision::Exhausted { budget };
        }
    };
    let (state, decision) = advance_budget(prev, now, budget);

    if let Err(err) = persist_and_verify(dir, &file, &state) {
        tracing::warn!(
            session_id,
            error = %err,
            "pm_guard_budget: failed to persist/verify turn-budget state; \
             failing toward deny (treating as budget-exhausted) rather than \
             silently allowing unlimited file changes"
        );
        return BudgetDecision::Exhausted { budget };
    }
    decision
}

/// Read and parse the prior [`BudgetState`] from `file`, distinguishing a
/// legitimately-fresh session (file doesn't exist yet) from any other read or
/// parse failure.
///
/// Why (HIGH, code-critic PR #2928): the prior version collapsed BOTH cases
/// via `.ok()` — an existing-but-unreadable file (permissions, `rm -rf`'d
/// mid-session, a directory sitting at the file's path) or a corrupt/truncated
/// JSON payload was silently treated the same as "no prior session", handing
/// back a fresh window and an `Allowed` decision with zero telemetry. That is
/// asymmetric with the write side's fail-toward-deny posture
/// ([`persist_and_verify`]) — a read-side failure must fail the same way, not
/// quietly grant a new budget.
/// What: `Ok(None)` only when the read fails with
/// [`std::io::ErrorKind::NotFound`] (no prior state — genuinely fresh).
/// `Ok(Some(state))` when the file reads and parses cleanly. `Err(_)` for
/// every other read error (permission denied, is-a-directory, …) or a JSON
/// parse failure — the caller must treat this as budget-exhausted, never as a
/// fresh window.
/// Test: `record_file_change_denies_on_corrupt_state_json`,
/// `record_file_change_denies_on_unreadable_state_file`,
/// `record_file_change_allows_fresh_window_when_file_absent`.
fn read_prev_state(file: &Path) -> Result<Option<BudgetState>, String> {
    match std::fs::read_to_string(file) {
        Ok(s) => serde_json::from_str::<BudgetState>(&s)
            .map(Some)
            .map_err(|e| format!("parse({}): {e}", file.display())),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(format!("read({}): {e}", file.display())),
    }
}

/// Persist `state` to `file` and read it back to confirm the write actually
/// landed durably.
///
/// Why: a bare `fs::write` that silently no-ops (a read-only bind mount, a
/// filesystem that accepts the write call but doesn't commit it) would leave
/// [`record_file_change_at`] believing storage is healthy when it is not —
/// the write-verify round-trip is what lets the caller detect that and fail
/// toward deny instead.
/// What: `create_dir_all(dir)`, serialize `state`, write `file`, read `file`
/// back, and delegate the round-trip comparison to [`verify_roundtrip`]. Every
/// step's error is folded into a single `Err(String)` the caller logs and
/// treats as a persist failure.
/// Test: `persist_and_verify_round_trips`.
fn persist_and_verify(dir: &Path, file: &Path, state: &BudgetState) -> Result<(), String> {
    std::fs::create_dir_all(dir).map_err(|e| format!("create_dir_all({}): {e}", dir.display()))?;
    let json = serde_json::to_string(state).map_err(|e| format!("serialize: {e}"))?;
    std::fs::write(file, &json).map_err(|e| format!("write({}): {e}", file.display()))?;
    let readback =
        std::fs::read_to_string(file).map_err(|e| format!("read-back({}): {e}", file.display()))?;
    verify_roundtrip(state, &readback)
}

/// Pure comparison core of [`persist_and_verify`]'s read-back check —
/// separated out so the mismatch/parse-failure branches are unit-testable
/// without needing to provoke a real filesystem race.
///
/// What: parses `readback_json`; `Err` on a parse failure, or when the parsed
/// value differs from `written` (proving the persisted bytes do not represent
/// what was just written — a silently-dropped or corrupted write).
/// Test: `verify_roundtrip_ok_on_match`, `verify_roundtrip_detects_mismatch`,
/// `verify_roundtrip_detects_parse_failure`.
fn verify_roundtrip(written: &BudgetState, readback_json: &str) -> Result<(), String> {
    let parsed: BudgetState =
        serde_json::from_str(readback_json).map_err(|e| format!("read-back parse: {e}"))?;
    if parsed != *written {
        return Err(format!(
            "read-back mismatch: wrote {written:?}, read {parsed:?}"
        ));
    }
    Ok(())
}

/// RAII guard for a held session lockfile — [`Drop`] removes it, releasing
/// the lock even on an early return.
struct SessionLock {
    path: PathBuf,
}

impl Drop for SessionLock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

/// Acquire an advisory `O_EXCL` lockfile at `lock_path`, bounded by
/// [`LOCK_ACQUIRE_BUDGET_MS`].
///
/// Why (MEDIUM, code-critic PR #2928): two `tm hook --pm-guard` processes for
/// the same session firing close together would otherwise race the
/// read-advance-write cycle in [`record_file_change_at`], a lost update
/// letting more than [`DEFAULT_FILE_CHANGE_BUDGET`] changes through in one
/// window. `O_EXCL`-create (`create_new(true)`) needs no extra crate — the
/// file's very existence IS the lock.
/// What: loops attempting `OpenOptions::new().write(true).create_new(true)`
/// on `lock_path`; on `AlreadyExists`, attempts to atomically clear the lock
/// via [`try_clear_if_stale`] if its mtime is older than [`STALE_LOCK_SECS`] (a
/// `tm hook --pm-guard` process is short-lived and per-invocation — it can
/// never itself hold a lock past its own exit, so a stale file only means a
/// prior holder crashed/was killed mid-critical-section), then sleeps
/// [`LOCK_RETRY_SLEEP_MS`] and retries. Gives up once the elapsed time exceeds
/// [`LOCK_ACQUIRE_BUDGET_MS`], returning `None`. A directory-creation failure
/// (parent dir missing/unwritable) also yields `None` immediately rather than
/// retrying.
/// Test: `with_session_lock_acquires_when_free`,
/// `with_session_lock_serializes_against_a_held_lock`,
/// `with_session_lock_clears_a_stale_lock`,
/// `with_session_lock_concurrent_stale_clear_is_race_free`.
fn with_session_lock(lock_path: &Path) -> Option<SessionLock> {
    if let Some(parent) = lock_path.parent() {
        std::fs::create_dir_all(parent).ok()?;
    }
    let deadline = Instant::now() + Duration::from_millis(LOCK_ACQUIRE_BUDGET_MS);
    loop {
        match std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(lock_path)
        {
            Ok(_) => {
                return Some(SessionLock {
                    path: lock_path.to_path_buf(),
                });
            }
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                try_clear_if_stale(lock_path);
            }
            Err(_) => return None,
        }
        if Instant::now() >= deadline {
            return None;
        }
        std::thread::sleep(Duration::from_millis(LOCK_RETRY_SLEEP_MS));
    }
}

/// Attempt to atomically clear `lock_path` if — and only if — it looks stale,
/// claiming the clear via `rename` rather than a `metadata`-check +
/// unconditional `remove_file` pair.
///
/// Why (MEDIUM, code-critic PR #2928): a `metadata` snapshot followed by an
/// unconditional `remove_file` is a classic TOCTOU race. Two racers can both
/// observe the same lock as stale via `metadata` and both decide to clear it;
/// if racer A's `remove_file` executes only AFTER racer A (or a third racer)
/// has already looped back around and created a FRESH lock at the same path,
/// that `remove_file` deletes the live, currently-held lock out from under its
/// holder purely by path — a later racer's `create_new` then succeeds too,
/// so two callers both believe they hold the lock simultaneously. `rename` on
/// a POSIX filesystem is atomic with respect to concurrent access to the same
/// source path: of any number of racers concurrently calling
/// `rename(lock_path, <unique per-racer path>)`, at most one can observe
/// `lock_path` and succeed — every other racer's `rename` fails (the source
/// is already gone) and that racer does nothing further this attempt, simply
/// falling through to the next `create_new` retry in [`with_session_lock`]'s
/// loop. Only the winning racer — the one whose `rename` succeeded — is
/// permitted to remove the file it privately renamed to (no other racer has
/// that path) and retry acquisition.
/// What: re-checks staleness via a fresh `metadata` call as the
/// *precondition* for even attempting the rename (an unstale/live lock is left
/// alone — this metadata read can itself be racy against a concurrent
/// clear/recreate, but that's fine: it only gates whether to ATTEMPT the
/// atomic claim, never grants the claim itself). If stale, renames
/// `lock_path` to a process+thread+time-unique sibling path via
/// [`unique_claim_path`] and, only on rename success, removes that renamed
/// file. A failed rename (the file was already moved/removed by another
/// racer, or recreated fresh by the true holder in between) is treated as
/// "someone else is handling it" — this function simply returns and the
/// caller's normal retry loop takes over.
/// Test: `with_session_lock_clears_a_stale_lock`,
/// `with_session_lock_concurrent_stale_clear_is_race_free`.
fn try_clear_if_stale(lock_path: &Path) {
    let Ok(metadata) = std::fs::metadata(lock_path) else {
        return;
    };
    let Ok(modified) = metadata.modified() else {
        return;
    };
    let age = SystemTime::now()
        .duration_since(modified)
        .unwrap_or(Duration::ZERO);
    if age < Duration::from_secs(STALE_LOCK_SECS) {
        return;
    }
    let claim_path = unique_claim_path(lock_path);
    if std::fs::rename(lock_path, &claim_path).is_ok() {
        let _ = std::fs::remove_file(&claim_path);
    }
}

/// Build a sibling path for `lock_path` that is unique to this process,
/// thread, and instant — used by [`try_clear_if_stale`] as the atomic-rename
/// claim target so no two racers can collide on the same claim path.
fn unique_claim_path(lock_path: &Path) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let base = lock_path
        .file_name()
        .and_then(|f| f.to_str())
        .unwrap_or("lock");
    lock_path.with_file_name(format!(
        "{base}.stale-{}-{:?}-{nanos}",
        std::process::id(),
        std::thread::current().id()
    ))
}

/// Current unix time in seconds, saturating to 0 on a pre-epoch clock (never
/// realistically hit — a defensive fallback only).
fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

#[cfg(test)]
mod tests;