trusty-mpm 1.0.2

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
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
//! Unit tests for the orphaned-worktree scan/reclaim logic in `prune.rs`
//! (#1840, #1845, extended #3649).
//!
//! Why: split out of `prune.rs` (as `orphan_tests`) so that file stays under
//! the 500-SLOC production cap — `prune.rs` mixes production code (the
//! walk, the ownership gate, the git cross-check) with a large test surface,
//! and only the production code counts against the cap once tests live in a
//! sibling `_tests.rs` file, mirroring the pattern already established by
//! `decommission_worktree_tests.rs` / `reap_orphaned_worktrees_tests.rs`.
//! What: orphan discovery (`find_orphaned_worktrees`, both worktree-store
//! shapes), the TOCTOU fresh-active-set safety net, and the #3649 ownership
//! gate (owner-unknown never deleted, terminal/absent owner reclaimed, live
//! owner spared, `git worktree list` agreement).
//! Test: this file IS the test module; run with `cargo test -p trusty-mpm`.

use super::*;

#[test]
fn prune_orphaned_worktrees_spares_active() {
    // A live session's worktree must never be returned as an orphan (#1840).
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let wt = root
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("live-session");
    std::fs::create_dir_all(&wt).unwrap();
    let active: std::collections::HashSet<_> =
        vec![std::fs::canonicalize(&wt).unwrap_or_else(|_| wt.clone())]
            .into_iter()
            .collect();
    let orphans = find_orphaned_worktrees(root, &active);
    assert!(
        orphans.is_empty(),
        "live session must not be listed as orphan"
    );
}

#[test]
fn prune_orphaned_worktrees_fresh_active_set_blocks_deletion() {
    // Simulates TOCTOU: a dir looks like an orphan in the initial snapshot
    // but appears in the fresh active set before deletion — must NOT be removed.
    // We test the `find_orphaned_worktrees` logic: with an empty initial set
    // the candidate IS found; then the Phase 2 TOCTOU check (re-querying the
    // store) is validated by confirming fresh set membership would block deletion.
    // The full async TOCTOU path is validated by the integration tests.
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let wt = root
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("session-xyz");
    std::fs::create_dir_all(&wt).unwrap();

    // Empty initial snapshot → the dir looks like an orphan candidate.
    let empty_initial: std::collections::HashSet<std::path::PathBuf> =
        std::collections::HashSet::new();
    let candidates = find_orphaned_worktrees(root, &empty_initial);
    assert!(
        candidates.contains(&wt),
        "empty initial set must find the dir as a candidate"
    );

    // Fresh active set contains the canonicalized dir path — mirrors Phase 2 of
    // prune_orphaned_worktrees, which canonicalizes workspace_paths before
    // inserting them into fresh_active (#1840 TOCTOU check).
    let canonical = std::fs::canonicalize(&wt).unwrap_or_else(|_| wt.clone());
    let fresh: std::collections::HashSet<std::path::PathBuf> =
        [canonical.clone()].into_iter().collect();
    assert!(
        fresh.contains(&canonical),
        "fresh active set must contain the canonicalized worktree path"
    );
    // The directory still exists — nothing deleted it.
    assert!(wt.exists(), "worktree must survive the TOCTOU check");
}

#[test]
fn prune_orphaned_worktrees_collects_orphan() {
    // A worktree with no active session must be listed as an orphan (#1840).
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let wt1 = root
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("live");
    let wt2 = root
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("dead");
    std::fs::create_dir_all(&wt1).unwrap();
    std::fs::create_dir_all(&wt2).unwrap();
    let active: std::collections::HashSet<_> =
        vec![std::fs::canonicalize(&wt1).unwrap_or_else(|_| wt1.clone())]
            .into_iter()
            .collect();
    let orphans = find_orphaned_worktrees(root, &active);
    assert_eq!(orphans.len(), 1);
    // Ordering not guaranteed — use contains rather than indexed access.
    assert!(
        orphans.contains(&wt2),
        "expected {wt2:?} to be the orphan, got {orphans:?}"
    );
}

/// Item 1 (#1845): async test that genuinely exercises the Phase 2 fresh-store
/// snapshot path in `prune_orphaned_worktrees`.
///
/// Why: the existing sync test at `prune_orphaned_worktrees_fresh_active_set_blocks_deletion`
/// only calls `find_orphaned_worktrees` directly, giving zero executed coverage of
/// the Phase 2 `fresh_active` snapshot logic in the async method. This test goes
/// end-to-end through `prune_orphaned_worktrees` with a real `SessionManager`:
/// Phase 1 finds the worktree as a candidate (empty initial snapshot), then Phase 2
/// reads the live store and finds the matching record — skipping deletion.
/// Test: this function IS the test.
#[tokio::test]
async fn prune_orphaned_worktrees_store_snapshot_blocks_deletion() {
    use std::path::PathBuf;
    use std::sync::Arc;

    // Minimal driver: all ops are no-ops — we never actually need tmux.
    struct NoopDriver;
    impl super::super::manager::ManagedTmuxDriver for NoopDriver {
        fn create_session(
            &self,
            _: &str,
            _: &str,
        ) -> Result<(), super::super::manager::ManagedError> {
            Ok(())
        }
        fn kill_session(&self, _: &str) -> Result<(), super::super::manager::ManagedError> {
            Ok(())
        }
        fn send_line(&self, _: &str, _: &str) -> Result<(), super::super::manager::ManagedError> {
            Ok(())
        }
        fn capture(
            &self,
            _: &str,
            _: usize,
        ) -> Result<String, super::super::manager::ManagedError> {
            Ok(String::new())
        }
        fn list_sessions(&self) -> Result<Vec<String>, super::super::manager::ManagedError> {
            Ok(Vec::new())
        }
    }

    let store_dir = tempfile::tempdir().unwrap();
    let repos_tmp = tempfile::tempdir().unwrap();

    // Build a real .worktrees/<id>/ dir so Phase 1 finds it as a candidate.
    let session_id = super::super::record::ManagedSessionId::new();
    let wt_path = repos_tmp
        .path()
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join(session_id.to_string());
    std::fs::create_dir_all(&wt_path).expect("create worktree dir");

    // Create the SessionManager and insert a live record for the worktree.
    let mgr = super::super::manager::SessionManager::new(store_dir.path(), Arc::new(NoopDriver))
        .await
        .expect("SessionManager::new");

    let canonical_wt = std::fs::canonicalize(&wt_path).unwrap_or_else(|_| wt_path.clone());
    let record = super::super::record::SessionRecord {
        id: session_id,
        tmux_name: "test-toctou".into(),
        cwd: PathBuf::from("/tmp"),
        task: "toctou test".into(),
        state: super::super::record::ManagedSessionState::Active,
        created_at: chrono::Utc::now(),
        last_activity_at: None,
        workspace_path: Some(canonical_wt),
        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: None,
    };
    mgr.store
        .write()
        .await
        .upsert(record)
        .await
        .expect("upsert test record");

    // Phase 1 will see an empty initial set → worktree is a candidate.
    // #3649: the dir has no ownership sentinel, so it is classified
    // owner-unknown and skipped BEFORE the Phase 2 fresh-active check ever
    // runs — still never removed, now for the #3649 safe-default reason
    // rather than (only) the #1845 TOCTOU fresh-snapshot reason.
    let outcome = mgr
        .prune_orphaned_worktrees(repos_tmp.path(), &[], false)
        .await
        .expect("prune must not error");

    assert!(
        outcome.removed.is_empty(),
        "worktree backed by a live store record must NOT be removed; got: {:?}",
        outcome.removed
    );
    assert!(wt_path.exists(), "worktree dir must survive the prune");
}

// ── #3649: extended `.base/.worktrees` walk + ownership gating ──────────

/// `find_orphaned_worktrees` must ALSO discover the clone-based
/// `.base/.worktrees/<id>` shape, not just the in-project `.worktrees/<name>`
/// shape (#3649 item 4a — this walk previously covered ONLY the latter, so
/// the entire `provisioner::workspace` worktree store was invisible to the
/// orphan-GC, `tm doctor`, and `--dry-run`).
#[test]
fn find_orphaned_worktrees_covers_base_worktrees_shape() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let base_wt = root
        .join("owner")
        .join("repo")
        .join(".base")
        .join(".worktrees")
        .join("session-abc");
    std::fs::create_dir_all(&base_wt).unwrap();
    let empty_active: std::collections::HashSet<std::path::PathBuf> =
        std::collections::HashSet::new();
    let orphans = find_orphaned_worktrees(root, &empty_active);
    assert!(
        orphans.contains(&base_wt),
        "the .base/.worktrees shape must be discovered as a candidate; got {orphans:?}"
    );
}

/// A candidate whose ownership sentinel is absent (no `.trusty-mpm-worktree`
/// file at all — the pre-#3649/legacy shape) is NEVER auto-deleted, and is
/// counted in [`OrphanSweepOutcome::owner_unknown`] (#3649 item 4b).
#[tokio::test]
async fn prune_orphaned_worktrees_skips_owner_unknown() {
    let store_dir = tempfile::tempdir().unwrap();
    let repos = tempfile::tempdir().unwrap();
    let mgr = SessionManager::new(
        store_dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let wt = repos
        .path()
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("legacy-no-sentinel");
    std::fs::create_dir_all(&wt).unwrap();
    // Deliberately NO sentinel file written — simulates a legacy worktree.

    let outcome = mgr
        .prune_orphaned_worktrees(repos.path(), &[], false)
        .await
        .expect("prune must not error");

    assert!(
        outcome.removed.is_empty(),
        "an owner-unknown candidate must never be auto-deleted; got {:?}",
        outcome.removed
    );
    assert!(
        outcome.owner_unknown.iter().any(|p| p == &wt),
        "an owner-unknown candidate must be counted for doctor surfacing; got {:?}",
        outcome.owner_unknown
    );
    assert!(
        wt.exists(),
        "the untouched legacy worktree must survive on disk"
    );
}

/// Write a sentinel with an explicit `created_at`, bypassing
/// `sentinel_payload_bytes`'s "now" default — needed to simulate a sentinel
/// old enough to fall outside [`crate::session_manager::worktree_ownership::OWNERLESS_GRACE`]
/// (#3649 review fix regression coverage).
fn aged_sentinel_bytes(
    owner: ManagedSessionId,
    created_at: chrono::DateTime<chrono::Utc>,
) -> Vec<u8> {
    serde_json::to_vec(
        &crate::session_manager::worktree_ownership::WorktreeSentinel {
            owner_session_id: owner,
            created_at,
        },
    )
    .expect("serialize aged sentinel")
}

/// A candidate whose sentinel names an owner with NO resolvable session
/// record (deleted / never registered) AND whose sentinel is OLDER than the
/// creation-race grace window is provably ownerless and IS reclaimed (#3649
/// item 5 — "sentinel owner id does not resolve to any record"; #3649 review
/// fix — an aged timestamp is required here so this test exercises the
/// legitimate "owner was purged" cleanup path rather than the creation-race
/// bug a freshly-stamped absent owner would have masked).
#[tokio::test]
async fn prune_orphaned_worktrees_reclaims_terminal_owner() {
    let store_dir = tempfile::tempdir().unwrap();
    let repos = tempfile::tempdir().unwrap();
    let mgr = SessionManager::new(
        store_dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let wt = repos
        .path()
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("ownerless-gone");
    std::fs::create_dir_all(&wt).unwrap();
    let never_registered_owner = ManagedSessionId::new();
    let aged = chrono::Utc::now()
        - crate::session_manager::worktree_ownership::OWNERLESS_GRACE
        - chrono::Duration::minutes(1);
    std::fs::write(
        wt.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE),
        aged_sentinel_bytes(never_registered_owner, aged),
    )
    .expect("write sentinel");

    let outcome = mgr
        .prune_orphaned_worktrees(repos.path(), &[], false)
        .await
        .expect("prune must not error");

    assert!(
        outcome.removed.iter().any(|p| p == &wt),
        "a candidate whose owner has no resolvable record AND whose sentinel \
         is aged past the grace window must be reclaimed; got {:?}",
        outcome.removed
    );
    assert!(
        !wt.exists(),
        "the reclaimed worktree must be removed from disk"
    );
}

/// A candidate whose sentinel names an owner with NO resolvable session
/// record, but whose sentinel was stamped RECENTLY (within the creation-race
/// grace window), must NOT be reclaimed — this is the exact bug the #3649
/// review fix closes: the sentinel is written before the owning session's
/// `SessionRecord` is persisted, so a `get()`-not-found result during that
/// window is ambiguous between "deleted" and "not created yet", and a
/// freshly-stamped sentinel must resolve that ambiguity toward "not yet".
#[tokio::test]
async fn prune_orphaned_worktrees_spares_recent_unregistered_owner() {
    let store_dir = tempfile::tempdir().unwrap();
    let repos = tempfile::tempdir().unwrap();
    let mgr = SessionManager::new(
        store_dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let wt = repos
        .path()
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("mid-creation-race");
    std::fs::create_dir_all(&wt).unwrap();
    let not_yet_persisted_owner = ManagedSessionId::new();
    std::fs::write(
        wt.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE),
        crate::session_manager::worktree_ownership::sentinel_payload_bytes(not_yet_persisted_owner),
    )
    .expect("write sentinel");

    let outcome = mgr
        .prune_orphaned_worktrees(repos.path(), &[], false)
        .await
        .expect("prune must not error");

    assert!(
        !outcome.removed.iter().any(|p| p == &wt),
        "a candidate whose owner is absent but whose sentinel is fresh must \
         NEVER be reclaimed (mid-creation race, #3649); got {:?}",
        outcome.removed
    );
    assert!(
        wt.exists(),
        "the mid-creation-race worktree must survive on disk"
    );
}

/// A candidate whose sentinel names an owner with a LIVE (`Active`) record
/// is NEVER reclaimed, even though the directory itself is not in the
/// caller's active-path set (#3649 item 5 — "a live/Stopped/Errored owner's
/// worktree is NEVER ownerless").
#[tokio::test]
async fn prune_orphaned_worktrees_spares_live_owner() {
    let store_dir = tempfile::tempdir().unwrap();
    let repos = tempfile::tempdir().unwrap();
    let mgr = SessionManager::new(
        store_dir.path(),
        crate::session_manager::tests::FakeTmuxDriver::new(),
    )
    .await
    .expect("manager");

    let wt = repos
        .path()
        .join("owner")
        .join("repo")
        .join(".worktrees")
        .join("live-owner-elsewhere");
    std::fs::create_dir_all(&wt).unwrap();

    // Register the owner as a LIVE record whose workspace_path points
    // somewhere else entirely — so `wt` is NOT in `active_workspace_paths`
    // (it looks orphaned by the path-only check) but its sentinel's owner
    // is still genuinely alive.
    let owner_id = ManagedSessionId::new();
    let owner_record = mgr
        .create_with_id(
            owner_id,
            "task".into(),
            None,
            None,
            None,
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            false,
        )
        .await
        .expect("create owner record");
    assert_eq!(owner_record.state, ManagedSessionState::Provisioning);

    std::fs::write(
        wt.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE),
        crate::session_manager::worktree_ownership::sentinel_payload_bytes(owner_id),
    )
    .expect("write sentinel");

    let outcome = mgr
        .prune_orphaned_worktrees(repos.path(), &[], false)
        .await
        .expect("prune must not error");

    assert!(
        !outcome.removed.iter().any(|p| p == &wt),
        "a candidate whose owner is live must NEVER be reclaimed; got {:?}",
        outcome.removed
    );
    assert!(wt.exists(), "the live-owned worktree must survive on disk");
}

// ── git_worktree_list_agrees (#3649 item 5) ──────────────────────────────

/// `git_worktree_list_agrees` returns `true` for a path that is genuinely
/// registered as a git worktree.
#[test]
fn git_worktree_list_agrees_true_for_real_worktree() {
    let base_dir = tempfile::tempdir().unwrap();
    let base = base_dir.path();
    let init_ok = std::process::Command::new("git")
        .arg("init")
        .current_dir(base)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !init_ok {
        eprintln!("git_worktree_list_agrees_true_for_real_worktree: git unavailable, skipping");
        return;
    }
    let _ = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "config",
            "user.email",
            "ci@test.invalid",
        ])
        .status();
    let _ = std::process::Command::new("git")
        .args(["-C", base.to_str().unwrap(), "config", "user.name", "CI"])
        .status();
    let commit_ok = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "commit",
            "--allow-empty",
            "-m",
            "init",
        ])
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !commit_ok {
        eprintln!("git_worktree_list_agrees_true_for_real_worktree: commit failed, skipping");
        return;
    }
    let wt_dir = base.join(".worktrees");
    std::fs::create_dir_all(&wt_dir).unwrap();
    let wt_path = wt_dir.join("agree-test");
    let add_ok = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "worktree",
            "add",
            "-b",
            "session/agree-test",
        ])
        .arg(&wt_path)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(add_ok, "git worktree add must succeed in this test fixture");

    assert!(
        git_worktree_list_agrees(&wt_path),
        "a genuinely registered git worktree must agree"
    );
}

/// `git_worktree_list_agrees` returns `false` for a directory git has no
/// record of as a worktree (a plain dir sitting under `.worktrees/`).
#[test]
fn git_worktree_list_agrees_false_for_untracked_dir() {
    let base_dir = tempfile::tempdir().unwrap();
    let base = base_dir.path();
    let init_ok = std::process::Command::new("git")
        .arg("init")
        .current_dir(base)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !init_ok {
        eprintln!("git_worktree_list_agrees_false_for_untracked_dir: git unavailable, skipping");
        return;
    }
    let untracked = base.join(".worktrees").join("never-added-to-git");
    std::fs::create_dir_all(&untracked).unwrap();

    assert!(
        !git_worktree_list_agrees(&untracked),
        "a directory git never registered as a worktree must disagree"
    );
}

/// The #1845 F3 canonicalize-fallback streak must escalate to `error!` at
/// exactly [`CANONICALIZE_FAILURE_STREAK_THRESHOLD`] consecutive failures,
/// not before (#3715 item 3).
///
/// Why: this is pure counter logic — no daemon, no timing, no filesystem —
/// exercised directly against [`CanonicalizeFailureStreaks`] so the test is
/// deterministic and independent of the real ~60s sweep cadence.
/// What: calls `record_failure` repeatedly for the same path and asserts the
/// returned streak length increments 1..=N, crossing the threshold on the
/// Nth call.
#[test]
fn canonicalize_streak_escalates_at_threshold() {
    let mut streaks = CanonicalizeFailureStreaks::default();
    let path = std::path::Path::new("/some/vanished/workspace");

    for expected in 1..CANONICALIZE_FAILURE_STREAK_THRESHOLD {
        let streak = streaks.record_failure(path);
        assert_eq!(streak, expected, "streak must increment by 1 per failure");
        assert!(
            streak < CANONICALIZE_FAILURE_STREAK_THRESHOLD,
            "must not have reached the escalation threshold yet"
        );
    }

    let final_streak = streaks.record_failure(path);
    assert_eq!(final_streak, CANONICALIZE_FAILURE_STREAK_THRESHOLD);
    assert!(
        final_streak >= CANONICALIZE_FAILURE_STREAK_THRESHOLD,
        "the Nth consecutive failure must cross the escalation threshold"
    );
}

/// A single successful canonicalize must fully reset the streak for that
/// path — a subsequent failure starts back at 1, not N+1 (#3715 item 3).
///
/// What: drives a path partway toward the threshold, calls `record_success`,
/// then asserts the next `record_failure` call returns 1 again. Also checks
/// that a DIFFERENT path's streak is unaffected by another path's reset.
#[test]
fn canonicalize_streak_resets_on_success() {
    let mut streaks = CanonicalizeFailureStreaks::default();
    let path_a = std::path::Path::new("/workspace/a");
    let path_b = std::path::Path::new("/workspace/b");

    streaks.record_failure(path_a);
    streaks.record_failure(path_a);
    streaks.record_failure(path_a);
    streaks.record_failure(path_b);
    streaks.record_failure(path_b);

    streaks.record_success(path_a);
    assert_eq!(
        streaks.record_failure(path_a),
        1,
        "streak must restart at 1 after a success resets it"
    );
    assert_eq!(
        streaks.record_failure(path_b),
        3,
        "an unrelated path's streak must be untouched by another path's reset"
    );
}

/// A path whose session leaves the active set (decommissioned, deleted, or
/// `workspace_path` changed) must have its streak evicted, not linger forever
/// — otherwise `CanonicalizeFailureStreaks` grows unbounded across the
/// daemon's lifetime (#3715 finding 2).
///
/// What: builds up failure streaks for two paths, then calls `retain_active`
/// with an active set containing only ONE of them; asserts the evicted
/// path's streak restarts at 1 on its next failure (proving the old count
/// was actually removed, not just no longer escalating) while the retained
/// path's streak is untouched.
#[test]
fn canonicalize_streak_evicts_paths_no_longer_active() {
    let mut streaks = CanonicalizeFailureStreaks::default();
    let still_active = std::path::PathBuf::from("/workspace/still-active");
    let left_active = std::path::PathBuf::from("/workspace/decommissioned");

    streaks.record_failure(&still_active);
    streaks.record_failure(&still_active);
    streaks.record_failure(&left_active);
    streaks.record_failure(&left_active);
    streaks.record_failure(&left_active);

    let active: std::collections::HashSet<std::path::PathBuf> =
        [still_active.clone()].into_iter().collect();
    streaks.retain_active(&active);

    assert_eq!(
        streaks.record_failure(&left_active),
        1,
        "a path evicted by retain_active must restart its streak at 1, proving \
         the old count was removed rather than merely capped"
    );
    assert_eq!(
        streaks.record_failure(&still_active),
        3,
        "a path still present in the active set must be untouched by eviction"
    );
}