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
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
//! Daemon-side auto-nudge for idle-parked managed sessions (#2621).
//!
//! Why: `core::idle_parking` (shipped in #2610/PR #2620) already flags a
//! `Stop`/`SubagentStop` whose final assistant message parks the task on a
//! background monitor/notification, and forwards `payload.idle_parking` to the
//! daemon. Until now the daemon only recorded the flag; a human still had to
//! notice the stall and send a manual nudge. This module closes that loop: when
//! a parking hook arrives it correlates the Claude session to a managed session,
//! checks the conservative safety rails, and injects a short foreground-block
//! instruction into the parked pane — the same message a human PM was typing by
//! hand. It is opt-in (`[idle_nudge].enabled = false` by default) and bounded by
//! a per-session cap and cooldown so a stuck session is never spammed.
//! What: [`has_live_children`] is the pure "is the session still working?"
//! predicate over its delegation list; [`run_nudge`] is the injectable core —
//! correlate → gate ([`crate::core::idle_nudge::decide_nudge`]) → inject →
//! record — returning a [`NudgeOutcome`] for logging and tests;
//! [`maybe_nudge_parked_session`] is the thin `DaemonState` entry point the
//! `ingest_hook` handler spawns off the hot path. All decision logic lives in
//! the pure `core::idle_nudge`; this module owns only the effects (config load,
//! session-manager correlation, tmux inject) and the structured surfacing logs.
//! Test: `has_live_children_*`, `run_nudge_*` in the inline `tests` module.

use std::sync::Arc;

use chrono::{DateTime, Utc};
use tracing::{info, warn};

use crate::core::agent::Delegation;
use crate::core::config::MpmConfig;
use crate::core::hook::HookEvent;
use crate::core::idle_nudge::{IdleNudgeConfig, NudgeDecision, NudgeLedger, decide_nudge};
use crate::core::session::SessionId;
use crate::core::sm::control::Submit;
use crate::daemon::state::DaemonState;
use crate::session_manager::{ManagedSessionState, SessionManager};

/// Does this session still have live tracked children? (pure)
///
/// Why: the #2621 design note requires the "no live tracked children"
/// precondition so a session that is genuinely still working — one whose
/// delegated subagents are mid-flight — is never nudged as if it were stalled.
/// A leaf engineer agent (the common dogfooding park) has no live children, so
/// this returns `false` and the nudge proceeds; a PM with a running delegation
/// returns `true` and is left alone.
/// What: `true` iff any delegation's status
/// `is_live` — i.e. `Queued` or `Running`.
/// `Completed`/`Failed`/`Cancelled` are terminal and do not count, and neither
/// does `Stale`: a delegation the staleness sweep gave up on is no longer
/// trustworthy evidence of work in flight, and treating it as live is what let a
/// single lost `SubagentStop` suppress this session's nudge forever (#2864
/// review). An empty list (no tracked delegations at all) is `false` — the
/// conservative default that lets a leaf agent be nudged.
/// Test: `has_live_children_true_for_running`, `has_live_children_false_when_terminal`,
/// `has_live_children_false_when_empty`, `stale_running_delegation_stops_suppressing_the_nudge`.
pub fn has_live_children(delegations: &[Delegation]) -> bool {
    delegations.iter().any(|d| d.status.is_live())
}

/// The result of one nudge attempt, for structured logging and tests.
///
/// Why: `run_nudge` both acts and must explain what it did (or why it did not);
/// a single enum carries the outcome without a side channel and gives the unit
/// tests a precise assertion target.
/// What: correlation miss, a skip with its [`crate::core::idle_nudge::SkipReason`],
/// a successful nudge, or an inject failure carrying the error text.
/// Test: every `run_nudge_*` test asserts on this.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NudgeOutcome {
    /// No Active managed session correlates to the hook's Claude session id —
    /// the parked runtime is unmanaged, so there is no pane to nudge.
    NoManagedSession,
    /// A safety rail suppressed the nudge; `.0` names the first rail that failed.
    Skipped(crate::core::idle_nudge::SkipReason),
    /// The nudge was injected into the pane.
    Nudged,
    /// The nudge decision passed but the tmux inject failed; `.0` is the error.
    InjectFailed(String),
}

/// Correlate → prune → gate → inject → record for one parking hook (injectable
/// core).
///
/// Why: keeping the effectful sequence in one function that takes its
/// collaborators explicitly (manager, ledger, delegations, config) — rather than
/// reaching into `DaemonState` — makes it unit-testable against a
/// `FakeNoopTmuxDriver` exactly like `idle_reaper::run_one_sweep`, with no real
/// tmux and no on-disk config.
/// What: (1) lists all managed sessions and immediately
/// [`NudgeLedger::prune`]s the ledger down to that live set — this is the
/// ONLY production call site that prunes the ledger (#2621 code-critic HIGH
/// 2), so it must run on every attempt regardless of the outcome below, not
/// just on a successful correlation; (2) finds the single Active managed
/// session whose `claude_session_id` matches `claude_session_id` (→
/// [`NudgeOutcome::NoManagedSession`] when none); (3) computes
/// [`has_live_children`] from `delegations`; (4) under the ledger lock,
/// snapshots the session's [`crate::core::idle_nudge::NudgeRecord`] and runs
/// [`decide_nudge`] with `phrase` (the caller only invokes this on a real
/// park, so `phrase` is normally `Some`; `decide_nudge` itself rejects a
/// human-addressed phrase via [`crate::core::idle_parking::is_nudge_eligible`]);
/// (5) on [`NudgeDecision::Nudge`] records the delivery in the ledger *before*
/// releasing the lock (so a concurrent second parking hook cannot
/// double-nudge) and then injects `cfg.message` with [`Submit::Enter`]; (6)
/// returns the [`NudgeOutcome`]. The record-before-inject ordering means a
/// failed inject still consumes one nudge budget slot — a deliberately
/// conservative bias toward fewer nudges. Bumps `last_activity_at` via the
/// manager's normal `inject` path.
/// #8942: a live protected-kind record never correlates.
/// Test: `run_nudge_injects_when_idle_and_enabled`,
/// `idle_nudge_never_types_into_a_supervisor_pane`,
/// `run_nudge_skips_when_disabled`, `run_nudge_skips_when_live_children`,
/// `run_nudge_skips_human_wait_phrase`, `run_nudge_no_managed_session`,
/// `run_nudge_respects_cap`, `run_nudge_prunes_stale_ledger_entries`.
pub async fn run_nudge(
    mgr: &SessionManager,
    ledger: &parking_lot::Mutex<NudgeLedger>,
    delegations: &[Delegation],
    cfg: &IdleNudgeConfig,
    claude_session_id: &str,
    phrase: Option<&str>,
    now: DateTime<Utc>,
) -> NudgeOutcome {
    // 1. Correlate the Claude session id to an Active managed session, and — on
    //    the SAME `mgr.list()` call — prune the ledger to the current live set
    //    (#2621 code-critic HIGH 2: this is the only place `prune` is called in
    //    production; it must run unconditionally, before the correlation
    //    outcome is known, so the ledger never grows unbounded even for
    //    sessions that stop correlating).
    let records = mgr.list().await;
    let live_ids: std::collections::HashSet<uuid::Uuid> = records.iter().map(|r| r.id.0).collect();
    ledger.lock().prune(&live_ids);

    // #8942 ruling 4: the Architect's pane never correlates, so it is never nudged.
    let Some(record) = records.iter().find(|r| {
        matches!(r.state, ManagedSessionState::Active)
            && r.claude_session_id.as_deref() == Some(claude_session_id)
            && !crate::session_manager::supervisor::skip_protected(r, "idle nudge")
    }) else {
        return NudgeOutcome::NoManagedSession;
    };
    let managed_id = record.id;

    // 2. Precondition: is the session still working?
    let live_children = has_live_children(delegations);

    // 3. Gate under the ledger lock; record the delivery before releasing it so a
    //    concurrent parking hook for the same session cannot double-nudge.
    let decision = {
        let mut guard = ledger.lock();
        let snapshot = guard.snapshot(managed_id.0);
        let decision = decide_nudge(cfg, phrase, live_children, &snapshot, now);
        if decision == NudgeDecision::Nudge {
            guard.record_nudge(managed_id.0, now);
        }
        decision
    };

    match decision {
        NudgeDecision::Skip(reason) => NudgeOutcome::Skipped(reason),
        NudgeDecision::Nudge => match mgr.inject(&managed_id, &cfg.message, Submit::Enter).await {
            Ok(()) => NudgeOutcome::Nudged,
            Err(e) => NudgeOutcome::InjectFailed(e.to_string()),
        },
    }
}

/// Spawn the surface-and-nudge task iff this hook is a flagged park (#2621).
///
/// Why: the `ingest_hook` handler must trigger the nudge without adding latency
/// to (or a failure path onto) the hook response, and without bloating the
/// already-oversized `api.rs`. Concentrating the "is this a park?" gate and the
/// `tokio::spawn` here keeps the handler's footprint to a single call.
/// What: returns immediately unless `event` is `Stop`/`SubagentStop` AND
/// `payload["idle_parking"] == true` (the flag `tm hook` sets via
/// `core::idle_parking`). When it matches, it clones the owned data the detached
/// task needs (`session_id`, optional `idle_parking_phrase`) and spawns
/// [`maybe_nudge_parked_session`]. Fire-and-forget — the task's own best-effort
/// logging is the only observation of its result.
/// Test: the gate is trivial routing; the spawned work is covered by the
/// `run_nudge_*` tests. Exercised end to end by any daemon integration test that
/// posts a parking `Stop` hook.
pub fn spawn_if_parked(
    state: &Arc<DaemonState>,
    event: HookEvent,
    session_id: &str,
    payload: &serde_json::Value,
) {
    if !matches!(event, HookEvent::Stop | HookEvent::SubagentStop) {
        return;
    }
    if payload
        .get("idle_parking")
        .and_then(serde_json::Value::as_bool)
        != Some(true)
    {
        return;
    }
    let state = Arc::clone(state);
    let session_id = session_id.to_string();
    let phrase = payload
        .get("idle_parking_phrase")
        .and_then(serde_json::Value::as_str)
        .map(str::to_string);
    tokio::spawn(async move {
        maybe_nudge_parked_session(&state, &session_id, phrase.as_deref()).await;
    });
}

/// `DaemonState` entry point: surface a park and auto-nudge if enabled (#2621).
///
/// Why: the `ingest_hook` handler spawns this off the request hot path when a
/// `Stop`/`SubagentStop` arrives with `payload.idle_parking = true`. It performs
/// the daemon-side surfacing (a loud structured `warn!` so the stall is visible
/// in the daemon log / event stream even when nudging is off) and, when the
/// feature is enabled, drives [`run_nudge`] against the live session manager.
/// What: emits the surfacing log; loads `[idle_nudge]` via
/// [`MpmConfig::load_default`]; resolves the session manager and this session's
/// tracked delegations (keyed by the Claude session UUID); calls [`run_nudge`];
/// and logs the [`NudgeOutcome`]. Entirely best-effort — every failure path is
/// logged and swallowed so it can never affect the hook response. A
/// non-UUID `claude_session_id` (or one with no tracked delegations) simply
/// yields an empty delegation list, which is the safe default.
/// Test: exercised indirectly by `run_nudge_*`; the config-load + surfacing shell
/// is side-effect-only (a `tracing` log + `DaemonState` reads).
pub async fn maybe_nudge_parked_session(
    state: &Arc<DaemonState>,
    claude_session_id: &str,
    phrase: Option<&str>,
) {
    // Daemon-side surfacing (#2621 part 1): make the park a first-class, greppable
    // signal in the daemon log regardless of whether nudging is enabled.
    warn!(
        target: "trusty_mpm::idle_parking",
        session = %claude_session_id,
        phrase = phrase.unwrap_or("<unknown>"),
        "idle-parking detected on a managed session turn-end (#2621); a stopped agent cannot be re-woken by a self-spawned monitor"
    );

    let cfg = MpmConfig::load_default().idle_nudge;
    if !cfg.enabled {
        info!(
            target: "trusty_mpm::idle_parking",
            session = %claude_session_id,
            "auto-nudge disabled ([idle_nudge].enabled = false); surfaced only"
        );
        return;
    }

    let delegations = uuid::Uuid::parse_str(claude_session_id)
        .map(|u| state.delegations_for(SessionId(u)))
        .unwrap_or_default();

    let mgr = state.session_manager().await;
    let outcome = run_nudge(
        &mgr,
        state.nudge_ledger(),
        &delegations,
        &cfg,
        claude_session_id,
        phrase,
        Utc::now(),
    )
    .await;

    match &outcome {
        NudgeOutcome::Nudged => info!(
            target: "trusty_mpm::idle_parking",
            session = %claude_session_id,
            "auto-nudged idle-parked managed session (#2621)"
        ),
        NudgeOutcome::Skipped(reason) => info!(
            target: "trusty_mpm::idle_parking",
            session = %claude_session_id,
            reason = reason.tag(),
            "auto-nudge skipped (#2621)"
        ),
        NudgeOutcome::NoManagedSession => info!(
            target: "trusty_mpm::idle_parking",
            session = %claude_session_id,
            "auto-nudge skipped: no Active managed session correlates to this Claude session (#2621)"
        ),
        NudgeOutcome::InjectFailed(e) => warn!(
            target: "trusty_mpm::idle_parking",
            session = %claude_session_id,
            error = %e,
            "auto-nudge inject failed (#2621)"
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::agent::{Delegation, DelegationStatus, ModelTier};
    use crate::core::idle_nudge::SkipReason;
    use crate::core::session::SessionId;
    use crate::session_manager::{FakeNoopTmuxDriver, ManagedSessionId};
    use tempfile::TempDir;

    /// A machine-wait phrase (nudge-eligible), the standard "genuinely parked"
    /// input for these tests. Must match a `core::idle_parking::PARKING_PHRASES`
    /// entry that is NOT in `HUMAN_ADDRESSED_PHRASES`.
    const MACHINE_PHRASE: &str = "i'll wait for the";
    /// A human-addressed phrase (NOT nudge-eligible) for `run_nudge_skips_human_wait_phrase`.
    const HUMAN_PHRASE: &str = "let me know once";

    fn enabled_cfg() -> IdleNudgeConfig {
        IdleNudgeConfig {
            enabled: true,
            max_nudges_per_session: 2,
            cooldown_secs: 300,
            message: "resume in the foreground now".into(),
        }
    }

    fn delegation_with(status: DelegationStatus) -> Delegation {
        let mut d = Delegation::new(
            SessionId::new(),
            None,
            "rust-engineer",
            ModelTier::Sonnet,
            "x",
        );
        d.status = status;
        d
    }

    #[test]
    fn has_live_children_true_for_running() {
        assert!(has_live_children(&[delegation_with(
            DelegationStatus::Running
        )]));
        assert!(has_live_children(&[delegation_with(
            DelegationStatus::Queued
        )]));
    }

    #[test]
    fn has_live_children_false_when_terminal() {
        assert!(!has_live_children(&[
            delegation_with(DelegationStatus::Completed),
            delegation_with(DelegationStatus::Failed),
            delegation_with(DelegationStatus::Cancelled),
        ]));
    }

    #[test]
    fn has_live_children_false_when_empty() {
        assert!(!has_live_children(&[]));
    }

    /// Build a manager with one Active managed session correlated to a Claude
    /// session id. Mirrors the `idle_reaper` sweep-test scaffold: create →
    /// stop → resume drives the record to Active under the no-op tmux driver.
    async fn manager_with_active_correlated(
        dir: &TempDir,
        claude_id: &str,
    ) -> (Arc<SessionManager>, ManagedSessionId) {
        let mgr = SessionManager::new(dir.path(), Arc::new(FakeNoopTmuxDriver))
            .await
            .expect("manager");
        let rec = mgr
            .create(
                "parked task".into(),
                Some(dir.path().to_path_buf()),
                None,
                None,
                None,
                None,
            )
            .await
            .expect("create");
        mgr.stop(&rec.id).await.expect("stop");
        mgr.resume(&rec.id).await.expect("resume");
        mgr.set_claude_session_id(&rec.id, claude_id)
            .await
            .expect("set claude id");
        (Arc::new(mgr), rec.id)
    }

    #[tokio::test]
    async fn run_nudge_injects_when_idle_and_enabled() {
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let cfg = enabled_cfg();
        let now = Utc::now();

        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &cfg,
            &claude_id,
            Some(MACHINE_PHRASE),
            now,
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::Nudged);
        // The ledger must record exactly one delivery for the managed session.
        assert_eq!(ledger.lock().snapshot(id.0).count, 1);
    }

    /// #8942 ruling 4: the same parked, correlated, idle session is never
    /// nudged once its record is the Architect's.
    #[tokio::test]
    async fn idle_nudge_never_types_into_a_supervisor_pane() {
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
        let mut record = mgr.get(&id).await.expect("record");
        record.kind = crate::session_manager::SessionKind::Supervisor;
        mgr.store.write().await.upsert(record).await.expect("seed");
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &enabled_cfg(),
            &claude_id,
            Some(MACHINE_PHRASE),
            Utc::now(),
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::NoManagedSession);
        assert_eq!(ledger.lock().snapshot(id.0).count, 0);
    }

    #[tokio::test]
    async fn run_nudge_skips_when_disabled() {
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let mut cfg = enabled_cfg();
        cfg.enabled = false;

        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &cfg,
            &claude_id,
            Some(MACHINE_PHRASE),
            Utc::now(),
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::Disabled));
        assert_eq!(
            ledger.lock().snapshot(id.0).count,
            0,
            "no delivery recorded"
        );
    }

    #[tokio::test]
    async fn run_nudge_skips_when_live_children() {
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, _id) = manager_with_active_correlated(&dir, &claude_id).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let cfg = enabled_cfg();
        let children = [delegation_with(DelegationStatus::Running)];

        let outcome = run_nudge(
            &mgr,
            &ledger,
            &children,
            &cfg,
            &claude_id,
            Some(MACHINE_PHRASE),
            Utc::now(),
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::HasLiveChildren));
    }

    #[tokio::test]
    async fn run_nudge_skips_human_wait_phrase() {
        // #2621 code-critic HIGH 1: a session parked on a HUMAN-addressed wait
        // ("let me know once…") must never be nudged end to end, even though it
        // correlates cleanly and has no live children.
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let cfg = enabled_cfg();

        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &cfg,
            &claude_id,
            Some(HUMAN_PHRASE),
            Utc::now(),
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::HumanWait));
        assert_eq!(
            ledger.lock().snapshot(id.0).count,
            0,
            "a human-wait skip must not consume a nudge budget slot"
        );
    }

    #[tokio::test]
    async fn run_nudge_no_managed_session() {
        let dir = TempDir::new().unwrap();
        // Correlate the manager to a DIFFERENT claude id than we nudge with.
        let (mgr, _id) =
            manager_with_active_correlated(&dir, &uuid::Uuid::new_v4().to_string()).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let cfg = enabled_cfg();

        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &cfg,
            &uuid::Uuid::new_v4().to_string(),
            Some(MACHINE_PHRASE),
            Utc::now(),
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::NoManagedSession);
    }

    #[tokio::test]
    async fn run_nudge_respects_cap() {
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let mut cfg = enabled_cfg();
        cfg.max_nudges_per_session = 1;
        cfg.cooldown_secs = 0; // isolate the cap from the cooldown

        // First nudge lands.
        let now = Utc::now();
        assert_eq!(
            run_nudge(
                &mgr,
                &ledger,
                &[],
                &cfg,
                &claude_id,
                Some(MACHINE_PHRASE),
                now
            )
            .await,
            NudgeOutcome::Nudged
        );
        // Second (cooldown elapsed) is blocked by the per-session cap.
        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &cfg,
            &claude_id,
            Some(MACHINE_PHRASE),
            now + chrono::Duration::seconds(1),
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::CapExhausted));
        assert_eq!(ledger.lock().snapshot(id.0).count, 1, "cap holds at 1");
    }

    #[tokio::test]
    async fn run_nudge_prunes_stale_ledger_entries() {
        // #2621 code-critic HIGH 2: `run_nudge` must prune the ledger against the
        // CURRENT managed-session set on every call, not just accumulate entries
        // for the lifetime of the daemon. Build a ledger with a stale entry (a
        // UUID no managed session will ever correlate to) alongside a real,
        // correlated session; after one `run_nudge` call the stale entry must be
        // gone while the real session's own record survives.
        let dir = TempDir::new().unwrap();
        let claude_id = uuid::Uuid::new_v4().to_string();
        let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
        let ledger = parking_lot::Mutex::new(NudgeLedger::new());
        let stale_id = uuid::Uuid::new_v4();
        let now = Utc::now();
        ledger.lock().record_nudge(stale_id, now);
        ledger
            .lock()
            .record_nudge(id.0, now - chrono::Duration::seconds(10_000));

        let cfg = enabled_cfg();
        let outcome = run_nudge(
            &mgr,
            &ledger,
            &[],
            &cfg,
            &claude_id,
            Some(MACHINE_PHRASE),
            now,
        )
        .await;
        assert_eq!(outcome, NudgeOutcome::Nudged);

        let guard = ledger.lock();
        assert_eq!(
            guard.snapshot(stale_id),
            crate::core::idle_nudge::NudgeRecord::default(),
            "stale ledger entry for a non-existent session must be pruned"
        );
        assert_eq!(
            guard.snapshot(id.0).count,
            2,
            "the real session's record survives pruning and reflects this nudge"
        );
    }
}