trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! Unit tests for the per-turn file-change budget (`super`), relocated out of
//! `mod.rs` (issue #2918) so the production file stays under the 500-SLOC cap.
//! `tests.rs` is classified as a test file (1500-SLOC cap).
//!
//! Temp dirs come from `crate::test_support::hermetic_temp_dir`, never a bare
//! `tempfile::tempdir()`: the bare constructor honors `$TMPDIR`, so any sibling
//! test in this binary that mutates it reddens these tests instead. That is how
//! five of them failed on PR #4914 (run 31023632348) — see `test_support`.

use super::*;

#[test]
fn advance_budget_starts_fresh_window() {
    let (state, decision) = advance_budget(None, 1000, 3);
    assert_eq!(
        state,
        BudgetState {
            count: 1,
            window_started_at: 1000
        }
    );
    assert_eq!(decision, BudgetDecision::Allowed { used: 1, budget: 3 });
}

#[test]
fn advance_budget_allows_up_to_budget() {
    let mut prev = None;
    for expected_used in 1..=3 {
        let (state, decision) = advance_budget(prev, 1000, 3);
        assert_eq!(
            decision,
            BudgetDecision::Allowed {
                used: expected_used,
                budget: 3
            }
        );
        prev = Some(state);
    }
}

#[test]
fn advance_budget_denies_past_budget() {
    let mut prev = None;
    for _ in 1..=3 {
        let (state, _) = advance_budget(prev, 1000, 3);
        prev = Some(state);
    }
    let (state, decision) = advance_budget(prev, 1000, 3);
    assert_eq!(decision, BudgetDecision::Exhausted { budget: 3 });
    // Saturates at budget, doesn't keep growing.
    assert_eq!(state.count, 3);
}

#[test]
fn advance_budget_resets_after_window_expiry() {
    let prev = Some(BudgetState {
        count: 3,
        window_started_at: 1000,
    });
    // Exactly at the boundary is still "expired" (>= window, not <).
    let (state, decision) = advance_budget(prev, 1000 + TURN_WINDOW_SECS, 3);
    assert_eq!(decision, BudgetDecision::Allowed { used: 1, budget: 3 });
    assert_eq!(state.count, 1);
}

#[test]
fn advance_budget_saturates_count_on_repeated_denial() {
    let prev = Some(BudgetState {
        count: 3,
        window_started_at: 1000,
    });
    let (state1, d1) = advance_budget(prev, 1005, 3);
    let (state2, d2) = advance_budget(Some(state1), 1010, 3);
    assert_eq!(d1, BudgetDecision::Exhausted { budget: 3 });
    assert_eq!(d2, BudgetDecision::Exhausted { budget: 3 });
    assert_eq!(state1.count, 3);
    assert_eq!(state2.count, 3);
}

#[test]
fn sanitize_session_id_replaces_unsafe_bytes() {
    assert_eq!(sanitize_session_id("abc-123_XYZ"), "abc-123_XYZ");
    assert_eq!(sanitize_session_id("../../etc/passwd"), "______etc_passwd");
    assert_eq!(sanitize_session_id(""), "_default");
}

#[test]
fn record_file_change_allows_first_three_then_exhausts() {
    let dir = crate::test_support::hermetic_temp_dir();
    let now = 5000;
    for expected_used in 1..=3 {
        let decision = record_file_change_at(dir.path(), "sess-a", 3, now);
        assert_eq!(
            decision,
            BudgetDecision::Allowed {
                used: expected_used,
                budget: 3
            }
        );
    }
    let decision = record_file_change_at(dir.path(), "sess-a", 3, now);
    assert_eq!(decision, BudgetDecision::Exhausted { budget: 3 });
}

#[test]
fn record_file_change_isolates_by_session() {
    let dir = crate::test_support::hermetic_temp_dir();
    let now = 5000;
    for _ in 1..=3 {
        record_file_change_at(dir.path(), "sess-a", 3, now);
    }
    // A different session has its own independent budget.
    let decision = record_file_change_at(dir.path(), "sess-b", 3, now);
    assert_eq!(decision, BudgetDecision::Allowed { used: 1, budget: 3 });
}

// ---- code-critic PR #2928 HIGH: storage failure fails toward DENY --------

#[test]
fn record_file_change_denies_on_persist_failure() {
    // Force a persist failure by pre-creating a DIRECTORY at the exact path
    // record_file_change_at will try to std::fs::write the state JSON to —
    // the write then fails ("Is a directory"). Before the fix, this scenario
    // silently returned Allowed{used:1,..} forever (unlimited fail-open).
    let dir = crate::test_support::hermetic_temp_dir();
    let now = 5000;
    let sanitized = sanitize_session_id("sess-fail");
    std::fs::create_dir_all(dir.path().join(format!("{sanitized}.json")))
        .expect("pre-create collision dir");

    let decision = record_file_change_at(dir.path(), "sess-fail", 3, now);
    assert_eq!(
        decision,
        BudgetDecision::Exhausted { budget: 3 },
        "a persist failure must fail toward deny, not silently allow"
    );
    // Repeating the call must keep denying — it must never "recover" into an
    // unlimited-allow state just because the write keeps failing the same way.
    let decision2 = record_file_change_at(dir.path(), "sess-fail", 3, now);
    assert_eq!(decision2, BudgetDecision::Exhausted { budget: 3 });
}

// ---- code-critic PR #2928 HIGH (round 2): read-side fails toward DENY ----

#[test]
fn record_file_change_denies_on_corrupt_state_json() {
    // Pre-write garbage (not valid JSON) at the exact path
    // record_file_change_at will try to read the state from. Before the fix,
    // `.ok()` collapsed a parse failure into `prev = None`, silently granting
    // a fresh window (Allowed) with zero telemetry.
    let dir = crate::test_support::hermetic_temp_dir();
    let now = 5000;
    let sanitized = sanitize_session_id("sess-corrupt");
    std::fs::write(
        dir.path().join(format!("{sanitized}.json")),
        b"not valid json{{{",
    )
    .expect("pre-write corrupt state");

    let decision = record_file_change_at(dir.path(), "sess-corrupt", 3, now);
    assert_eq!(
        decision,
        BudgetDecision::Exhausted { budget: 3 },
        "a corrupt/unparseable state file must fail toward deny, not be treated as fresh"
    );
}

#[test]
fn record_file_change_denies_on_unreadable_state_file() {
    // Pre-create a DIRECTORY at the state-file path so the read
    // (`std::fs::read_to_string`) fails with a non-NotFound error ("Is a
    // directory"), not the legitimate "file doesn't exist yet" case.
    let dir = crate::test_support::hermetic_temp_dir();
    let now = 5000;
    let sanitized = sanitize_session_id("sess-unreadable");
    std::fs::create_dir_all(dir.path().join(format!("{sanitized}.json")))
        .expect("pre-create collision dir at state-file path");

    let decision = record_file_change_at(dir.path(), "sess-unreadable", 3, now);
    assert_eq!(
        decision,
        BudgetDecision::Exhausted { budget: 3 },
        "an unreadable (non-NotFound) state file must fail toward deny"
    );
}

#[test]
fn record_file_change_allows_fresh_window_when_file_absent() {
    // The legitimate case `read_prev_state` must still short-circuit to
    // `Ok(None)` for: no state file has ever been written for this session.
    let dir = crate::test_support::hermetic_temp_dir();
    let now = 5000;
    assert!(
        !dir.path()
            .join(format!("{}.json", sanitize_session_id("sess-fresh")))
            .exists()
    );

    let decision = record_file_change_at(dir.path(), "sess-fresh", 3, now);
    assert_eq!(
        decision,
        BudgetDecision::Allowed { used: 1, budget: 3 },
        "an absent state file (never-seen session) must still start a fresh, allowed window"
    );
}

// ---- persist_and_verify / verify_roundtrip --------------------------------

#[test]
fn persist_and_verify_round_trips() {
    let dir = crate::test_support::hermetic_temp_dir();
    let file = dir.path().join("state.json");
    let state = BudgetState {
        count: 2,
        window_started_at: 1234,
    };
    assert!(persist_and_verify(dir.path(), &file, &state).is_ok());
    let on_disk = std::fs::read_to_string(&file).expect("read back");
    let parsed: BudgetState = serde_json::from_str(&on_disk).expect("parse");
    assert_eq!(parsed, state);
}

#[test]
fn verify_roundtrip_ok_on_match() {
    let state = BudgetState {
        count: 1,
        window_started_at: 100,
    };
    let json = serde_json::to_string(&state).expect("serialize");
    assert!(verify_roundtrip(&state, &json).is_ok());
}

#[test]
fn verify_roundtrip_detects_mismatch() {
    let written = BudgetState {
        count: 3,
        window_started_at: 100,
    };
    let different = BudgetState {
        count: 1,
        window_started_at: 100,
    };
    let readback_json = serde_json::to_string(&different).expect("serialize");
    let err = verify_roundtrip(&written, &readback_json).expect_err("must detect mismatch");
    assert!(err.contains("mismatch"), "error should say mismatch: {err}");
}

#[test]
fn verify_roundtrip_detects_parse_failure() {
    let written = BudgetState {
        count: 1,
        window_started_at: 100,
    };
    let err = verify_roundtrip(&written, "not json").expect_err("must detect parse failure");
    assert!(err.contains("parse"), "error should say parse: {err}");
}

// ---- code-critic PR #2928 MEDIUM: session-lock serialization -------------

#[test]
fn with_session_lock_acquires_when_free() {
    let dir = crate::test_support::hermetic_temp_dir();
    let lock_path = dir.path().join("sess.lock");
    assert!(!lock_path.exists());
    let guard = with_session_lock(&lock_path);
    assert!(guard.is_some(), "an unheld lock must be acquired");
    assert!(lock_path.exists(), "the lockfile must exist while held");
    drop(guard);
    assert!(
        !lock_path.exists(),
        "dropping the guard must release (remove) the lockfile"
    );
}

#[test]
fn with_session_lock_serializes_against_a_held_lock() {
    let dir = crate::test_support::hermetic_temp_dir();
    let lock_path = dir.path().join("sess.lock");
    let held = with_session_lock(&lock_path).expect("first acquire must succeed");

    // A second attempt, while the first guard is still alive, must fail
    // (bounded retry, then give up) rather than double-acquiring.
    let second = with_session_lock(&lock_path);
    assert!(
        second.is_none(),
        "a lock already held by another guard must not be re-acquired"
    );

    drop(held);
    // Once released, acquisition succeeds again.
    assert!(with_session_lock(&lock_path).is_some());
}

#[test]
fn with_session_lock_clears_a_stale_lock() {
    let dir = crate::test_support::hermetic_temp_dir();
    let lock_path = dir.path().join("sess.lock");
    // Simulate an abandoned lock left behind by a crashed/killed
    // `tm hook --pm-guard` process: create it, then backdate its mtime past
    // the staleness threshold.
    let file = std::fs::File::create(&lock_path).expect("create lock");
    let stale_time = SystemTime::now() - Duration::from_secs(STALE_LOCK_SECS + 1);
    file.set_modified(stale_time).expect("set_modified");
    drop(file);

    let guard = with_session_lock(&lock_path);
    assert!(
        guard.is_some(),
        "a stale lock must be cleared and re-acquired, not treated as held forever"
    );
}

/// Staleness threshold used only by
/// `with_session_lock_concurrent_stale_clear_is_race_free`, in place of the
/// production [`STALE_LOCK_SECS`] (5s).
///
/// Why (#6339): the test races two threads against a pre-staled lock to prove
/// the atomic-rename claim in `try_clear_if_stale` lets at most one racer win.
/// That race normally settles in low single-digit milliseconds, but nothing
/// bounds how long the OS scheduler can preempt a racer mid-call — under real
/// load one was observed landing past the production 5s window. When that
/// happens the "loser" clearing the lock is not a bug: by then the winner's
/// own fresh lock genuinely IS more than 5s old, so reclaiming it is correct
/// per [`STALE_LOCK_SECS`]'s design. The test was asserting a stronger
/// property ("exactly one winner") than a 5s threshold can promise once a
/// descheduling delay is allowed to approach that same 5s. Widening the
/// threshold to one hour keeps the exact same atomic-rename logic under test
/// while making a false failure require a racer thread to be starved of CPU
/// for a full hour — not a real machine-load scenario, so the assertion no
/// longer depends on a tight wall-clock window holding under load.
const RACE_TEST_STALE_SECS: u64 = 3600;

#[test]
fn with_session_lock_concurrent_stale_clear_is_race_free() {
    // Reproduces the TOCTOU race code-critic flagged (PR #2928 MEDIUM, round
    // 2): multiple racers observing the SAME stale lock must not both end up
    // believing they hold it. With the metadata-check + unconditional
    // remove_file version, a losing racer's remove_file could delete a
    // winner's freshly-recreated lock file purely by path, letting a THIRD
    // racer also acquire — a double acquisition. The atomic rename-based claim
    // guarantees at most one racer ever wins the clear-and-reacquire.
    let dir = crate::test_support::hermetic_temp_dir();
    let lock_path = std::sync::Arc::new(dir.path().join("sess.lock"));

    // Pre-stale the lock relative to `RACE_TEST_STALE_SECS`, not the
    // production `STALE_LOCK_SECS` — see that constant's doc comment (#6339).
    let file = std::fs::File::create(lock_path.as_path()).expect("create lock");
    let stale_time = SystemTime::now() - Duration::from_secs(RACE_TEST_STALE_SECS + 1);
    file.set_modified(stale_time).expect("set_modified");
    drop(file);

    let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
    let handles: Vec<_> = (0..2)
        .map(|_| {
            let lock_path = std::sync::Arc::clone(&lock_path);
            let barrier = std::sync::Arc::clone(&barrier);
            std::thread::spawn(move || {
                barrier.wait();
                with_session_lock_with_stale_threshold(&lock_path, RACE_TEST_STALE_SECS)
            })
        })
        .collect();

    let results: Vec<Option<SessionLock>> = handles
        .into_iter()
        .map(|h| h.join().expect("racer thread panicked"))
        .collect();

    let acquired = results.iter().filter(|r| r.is_some()).count();
    assert_eq!(
        acquired, 1,
        "exactly one racer must win the stale-lock clear + re-acquire, got {acquired} winners"
    );
    // The winner's guard (still alive in `results`) proves the fresh lock
    // survived the race — a losing racer must never have deleted it out from
    // under the live holder.
    assert!(
        lock_path.exists(),
        "the winner's freshly-acquired lock must still exist after the race settles"
    );
}

/// 🔴 #7766 pin: PM-owned worktree lifecycle commands never reach the budget.
///
/// Why: the budget gate in `pm_guard` counts a call only when `evaluate_tool`
/// returns a file-change reason, so a `None` here means these commands consume
/// no slot and are allowed with the budget exhausted. Already true on
/// `origin/main` at `1c1ce516a`; this test keeps it true.
#[test]
fn worktree_lifecycle_commands_consume_no_budget_slot() {
    use crate::commands::pm_guard::evaluate_tool;
    for command in [
        "git worktree remove .claude/worktrees/agent-ab542d7b51b9a2944",
        "git worktree remove /repo/.claude/worktrees/agent-x",
        "git worktree prune",
        "git branch -D fix/merged-branch",
        "git worktree remove .claude/worktrees/agent-x && git branch -D worktree-agent-x",
    ] {
        let input = serde_json::json!({ "command": command });
        assert_eq!(
            evaluate_tool("Bash", Some(&input)),
            None,
            "a worktree lifecycle command must not be budget-eligible: {command}"
        );
    }
}