trusty-mpm 1.3.6

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
//! Unit tests for `decommission.rs` (worktree-removal predicates, the #3649
//! owner gate, and the #4400 pending-decision clear).
//!
//! Why: `decommission.rs`'s own inline `#[cfg(test)] mod tests` pushed the
//! file to 528 SLOC — over the 500-SLOC production cap (issue #4400 review).
//! Extracting the test module to this sibling file follows the exact
//! precedent `dedup.rs`/`dedup_tests.rs` and `decommission_worktree_tests.rs`
//! already established for this crate: production logic stays under
//! `PROD_CAP`, its tests move to a sibling classified under the more
//! generous `TEST_CAP` (`scripts/check_line_cap.sh`). No behavior or
//! coverage change — this is a pure relocation plus de-nesting (the tests
//! were one indent level deeper inside `mod tests { .. }`; here they sit
//! at the top level of their own file).
//! What: `is_session_worktree`/`remove_session_worktree` sentinel-gate unit
//! tests, and the `#3649` decommission owner-gate + `#4400`
//! pending-decision-clear integration tests, all driving the public
//! `SessionManager` API via `decommission_with_root`.
//! Test: this file IS the test module; run with `cargo test -p trusty-mpm`.

use super::decommission::{
    WORKTREE_SENTINEL_FILE, is_session_worktree, remove_session_worktree, worktrees_dirname,
};
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};

#[test]
fn is_session_worktree_detects_dot_worktrees_component() {
    // Why (#1840): decommission must detect the .worktrees/ pattern to know
    // when to call git worktree remove even for workspace_owned=false sessions.
    // Checks the IMMEDIATE parent — not any ancestor — to avoid false positives.
    // parent is `.worktrees` → true
    assert!(is_session_worktree(std::path::Path::new(
        "/home/user/repo/.worktrees/session-abc"
    )));
    assert!(is_session_worktree(std::path::Path::new(
        "/some/base/.worktrees/session-id"
    )));
    // parent is `repo` (not `.worktrees`) → false
    assert!(!is_session_worktree(std::path::Path::new(
        "/home/user/repo/session-id"
    )));
    // parent is `deep` (not `.worktrees`), even though `.worktrees` is an ancestor → false
    assert!(!is_session_worktree(std::path::Path::new(
        "/base/.worktrees/deep/path"
    )));
    // parent is `worktrees` (no dot-prefix) → false
    assert!(!is_session_worktree(std::path::Path::new(
        "/base/worktrees/session"
    )));
}

/// Why (#5204): this module's `worktrees_dirname()` is NOT the same function as
/// `core::trusty_tools_config::worktrees_dirname(&cfg)` — that one is a pure
/// function of a config the caller already holds, while this one LOADS the host
/// config itself. The loading step is the part with nothing else covering it,
/// so it gets its own test rather than borrowing the other's name.
/// What: with the env override set the delegation returns it; with the override
/// cleared it returns `.worktrees`. Driving the env leg (rather than asserting a
/// bare default) keeps the test deterministic on a developer machine that has
/// `worktrees_dirname` set in its own `config.yaml`.
/// Test: itself.
#[test]
fn worktrees_dirname_delegates_to_the_shared_resolver() {
    let _guard = crate::core::trusty_tools_config::env_test_lock();
    // SAFETY: serialised by the crate-wide env lock; cleared below.
    unsafe { std::env::set_var("TRUSTY_MPM_WORKTREES_DIRNAME", ".sessions") };
    let configured = worktrees_dirname();
    // SAFETY: as above.
    unsafe { std::env::remove_var("TRUSTY_MPM_WORKTREES_DIRNAME") };

    assert_eq!(
        configured, ".sessions",
        "the loaded-config delegation must carry the resolved base through"
    );
}

/// #5204 REGRESSION: no configured worktree base may make a Claude Code agent
/// worktree look tm-owned.
///
/// Why: making the base configurable put `.claude/worktrees/<agent>` one config
/// value away from deletion. `is_session_worktree` asks only whether the
/// immediate parent is a worktree base, and `worktree_reclaim::tm_provisioned`
/// applies "exactly the predicate the remover applies" (#2919 removed the
/// classifier/remover asymmetry on purpose, so there is no second opinion left).
/// A dotless `worktrees` would therefore have both the survey and the remover
/// classify an agent worktree — out of scope per ADR-0020 — as reclaimable.
/// What: sets `TRUSTY_MPM_WORKTREES_DIRNAME=worktrees`, the adversarial value,
/// and asserts both predicates still refuse `.claude/worktrees/<agent>`. The
/// reserved-name guard in `trusty_common::workspace_layout` is what holds here.
/// Test: this test.
#[test]
fn configured_base_can_never_claim_a_claude_agent_worktree() {
    let _guard = crate::core::trusty_tools_config::env_test_lock();
    // SAFETY: serialised by the crate-wide env lock; cleared before returning.
    unsafe { std::env::set_var("TRUSTY_MPM_WORKTREES_DIRNAME", "worktrees") };

    let agent_wt = std::path::Path::new(
        "/Users/dev/trusty-mpm-projects/owner/repo/.claude/worktrees/agent-abc123",
    );
    let observed_session = is_session_worktree(agent_wt);
    let observed_provisioned = crate::session_manager::worktree_reclaim::tm_provisioned(agent_wt);

    // SAFETY: as above.
    unsafe { std::env::remove_var("TRUSTY_MPM_WORKTREES_DIRNAME") };

    assert!(
        !observed_session,
        "`.claude/worktrees/<agent>` must never be a tm session worktree, whatever \
         TRUSTY_MPM_WORKTREES_DIRNAME says"
    );
    assert!(
        !observed_provisioned,
        "the remover's own predicate must refuse a Claude Code agent worktree \
         (ADR-0020 puts that store out of scope)"
    );
}

#[test]
fn is_session_worktree_absent_path_is_noop() {
    // remove_session_worktree must return true without panicking when path is
    // already absent (#1840 D: idempotent — "already gone" is success).
    let absent = std::path::Path::new("/nonexistent/.worktrees/session-abc");
    // is_session_worktree: true (immediate parent is `.worktrees`)
    assert!(is_session_worktree(absent));
    // remove_session_worktree: reports Removed idempotently (path already absent)
    let result = remove_session_worktree(absent);
    assert!(
        result.removed(),
        "absent path should report Removed (idempotently removed)"
    );
}

/// Item 5 (#1845): sentinel gate refuses to delete directories that are NOT
/// under `.worktrees/` and have no sentinel file — they cannot be SM worktrees.
#[test]
fn sentinel_gates_worktree_removal_refuses_non_worktrees_dir_without_sentinel() {
    // Why: a directory outside the .worktrees/ convention and without the
    // `.trusty-mpm-worktree` sentinel must NEVER be deleted by the SM.
    // What: create a real temp dir (so path.exists() is true), confirm no
    // sentinel exists, confirm the parent is NOT `.worktrees`, and assert
    // that remove_session_worktree returns false (refused, dir untouched).
    // Test: this function is the sentinel_gates test.
    let dir = tempfile::tempdir().expect("tempdir");
    let wt_path = dir.path().to_path_buf();
    // Verify: no sentinel file, and parent is NOT `.worktrees`.
    assert!(!wt_path.join(WORKTREE_SENTINEL_FILE).exists());
    assert!(
        !is_session_worktree(&wt_path),
        "test invariant: parent must NOT be .worktrees for this branch"
    );
    let result = remove_session_worktree(&wt_path);
    assert!(
        !result.removed(),
        "remove_session_worktree must refuse a non-worktrees dir without a sentinel"
    );
    assert!(
        result.reason().is_some_and(|r| r.contains("sentinel")),
        "#4732: and must say WHY, so the caller can report it: {result:?}"
    );
    // The directory must NOT have been deleted.
    assert!(
        wt_path.exists(),
        "non-SM directory must remain on disk after refused removal"
    );
}

/// Item 5 (#1845): sentinel gate allows removal when the sentinel IS present.
///
/// Why: confirm the happy path — when the sentinel file exists, `remove_session_worktree`
/// passes the safety gate and removes the directory. We verify observable filesystem
/// state (`!path.exists()`) rather than the bool return value, which can vary with
/// filesystem permissions unrelated to the sentinel gate (Finding 2 #1845).
/// Test: create a temp dir, write sentinel, call remove_session_worktree, assert
/// the directory is gone — proving the gate was passed AND removal succeeded.
#[test]
fn sentinel_present_passes_safety_gate() {
    let dir = tempfile::tempdir().expect("tempdir");
    // Keep the TempDir alive but prevent auto-cleanup on drop: we call
    // remove_session_worktree (which deletes the directory) and then assert
    // it is gone. `keep()` suppresses the automatic deletion so Drop does not
    // fight with our explicit removal assertion.
    let wt_path = dir.keep();
    // Write the sentinel to simulate an SM-created worktree.
    std::fs::write(wt_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
    // The sentinel check must pass (not return false early). The git call will fail
    // because this is not a git worktree, but remove_session_worktree falls back
    // to remove_dir_all. Assert the observable outcome: the directory is gone.
    remove_session_worktree(&wt_path);
    assert!(
        !wt_path.exists(),
        "sentinel present: safety gate must pass and directory must be removed"
    );
}

// ── #3649: decommission owner gate ───────────────────────────────────────

/// Build a bare (no workspace) [`SessionRecord`] with `worktree_owner` set
/// to itself, mirroring what [`SessionManager::set_worktree_owner`] does
/// for a freshly-provisioned session.
fn owned_record(id: ManagedSessionId, state: ManagedSessionState) -> SessionRecord {
    SessionRecord {
        id,
        tmux_name: format!("tm-owner-gate-{id}"),
        cwd: std::path::PathBuf::from("/tmp"),
        task: "task".into(),
        state,
        created_at: chrono::Utc::now(),
        last_activity_at: None,
        workspace_path: None,
        repo_url: None,
        branch: None,
        pending_decision: None,
        proposed_default: None,
        correlation: Default::default(),
        runtime: Default::default(),
        ephemeral: false,
        workspace_owned: false,
        source_id: None,
        claude_session_id: None,
        scrollback_path: None,
        last_cwd: None,
        deliverable_id: None,
        pane_id: None,
        injection_status: Default::default(),
        worktree_owner: Some(id),
        terminal_at: None,
    }
}

/// Session A cannot decommission session B's worktree when B is a
/// live/resumable, KNOWN owner and A != B (#3649 item 3/8).
#[tokio::test]
async fn decommission_owner_gate_refuses_foreign_caller() {
    let dir = crate::test_support::hermetic_temp_dir();
    let mgr = SessionManager::new(
        dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let session_b = ManagedSessionId::new();
    mgr.store
        .write()
        .await
        .upsert(owned_record(session_b, ManagedSessionState::Active))
        .await
        .expect("upsert B");

    let session_a = ManagedSessionId::new();
    let managed_root = crate::test_support::hermetic_temp_dir();
    let err = mgr
        .decommission_with_root(&session_b, managed_root.path(), Some(session_a))
        .await
        .expect_err("session A must be refused decommissioning session B's live worktree");
    match err {
        ManagedError::WorktreeOwnerMismatch(caller, owner, target) => {
            assert_eq!(caller, session_a);
            assert_eq!(owner, session_b);
            assert_eq!(target, session_b);
        }
        other => panic!("expected WorktreeOwnerMismatch, got {other:?}"),
    }

    // The record must be untouched — still Active, not decommissioned.
    let record = mgr.get(&session_b).await.expect("get B");
    assert_eq!(record.state, ManagedSessionState::Active);
}

/// A caller MAY decommission its OWN record (`caller == owner`) — the
/// gate never fires for self-decommission (#3649 item 3).
#[tokio::test]
async fn decommission_owner_gate_allows_self() {
    let dir = crate::test_support::hermetic_temp_dir();
    let mgr = SessionManager::new(
        dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let session_id = ManagedSessionId::new();
    mgr.store
        .write()
        .await
        .upsert(owned_record(session_id, ManagedSessionState::Active))
        .await
        .expect("upsert");

    let managed_root = crate::test_support::hermetic_temp_dir();
    let (record, _workspace_removed) = mgr
        .decommission_with_root(&session_id, managed_root.path(), Some(session_id))
        .await
        .expect("self-decommission must be allowed");
    assert_eq!(record.state, ManagedSessionState::Decommissioned);
}

/// A caller MAY decommission a target whose known owner is provably
/// ownerless (a terminal-state, e.g. already-`Decommissioned`, record) —
/// the gate only refuses when the owner is genuinely contesting the
/// reclaim (#3649 item 5).
#[tokio::test]
async fn decommission_owner_gate_allows_terminal_owner() {
    let dir = crate::test_support::hermetic_temp_dir();
    let mgr = SessionManager::new(
        dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    // `target`'s OWN worktree_owner is itself, but its state is already
    // terminal (Decommissioned) — decommissioning it again (idempotent-ish,
    // e.g. a retry) must not be blocked by a foreign caller.
    let target = ManagedSessionId::new();
    mgr.store
        .write()
        .await
        .upsert(owned_record(target, ManagedSessionState::Decommissioned))
        .await
        .expect("upsert target");

    let caller = ManagedSessionId::new();
    let managed_root = crate::test_support::hermetic_temp_dir();
    mgr.decommission_with_root(&target, managed_root.path(), Some(caller))
        .await
        .expect("decommissioning an already-terminal, provably-ownerless target must be allowed");
}

/// `caller: None` (operator/daemon-internal) preserves full authority
/// regardless of ownership — the gate is bypassed entirely (#3649 item 3).
#[tokio::test]
async fn decommission_owner_gate_bypassed_for_none_caller() {
    let dir = crate::test_support::hermetic_temp_dir();
    let mgr = SessionManager::new(
        dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let session_id = ManagedSessionId::new();
    mgr.store
        .write()
        .await
        .upsert(owned_record(session_id, ManagedSessionState::Active))
        .await
        .expect("upsert");

    let managed_root = crate::test_support::hermetic_temp_dir();
    let (record, _workspace_removed) = mgr
        .decommission_with_root(&session_id, managed_root.path(), None)
        .await
        .expect("operator (caller=None) decommission must never be gated");
    assert_eq!(record.state, ManagedSessionState::Decommissioned);
}

/// #4400: decommissioning a session with an outstanding `pending_decision`
/// (and `proposed_default`) must clear both — otherwise `supervisor_status`
/// keeps reporting a terminal, un-actionable record in its human-confirmation
/// queue forever.
#[tokio::test]
async fn decommission_clears_pending_decision() {
    let dir = crate::test_support::hermetic_temp_dir();
    let mgr = SessionManager::new(
        dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let session_id = ManagedSessionId::new();
    let mut record = owned_record(session_id, ManagedSessionState::Active);
    record.pending_decision =
        Some("T4: irreversible operation; human confirmation required".into());
    record.proposed_default = Some("use cursor".into());
    mgr.store
        .write()
        .await
        .upsert(record)
        .await
        .expect("upsert");

    let managed_root = crate::test_support::hermetic_temp_dir();
    let (after, _workspace_removed) = mgr
        .decommission_with_root(&session_id, managed_root.path(), None)
        .await
        .expect("decommission");
    assert_eq!(after.state, ManagedSessionState::Decommissioned);
    assert!(
        after.pending_decision.is_none(),
        "pending_decision must be cleared on decommission"
    );
    assert!(
        after.proposed_default.is_none(),
        "proposed_default must be cleared on decommission"
    );

    // Persisted copy must reflect the clear too, not just the returned value.
    let stored = mgr.get(&session_id).await.expect("get");
    assert!(stored.pending_decision.is_none());
    assert!(stored.proposed_default.is_none());
}