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
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
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
//! POST /api/v1/sessions/managed/{id}/reactivate route handler (#2023 C, #2453).
//!
//! Why: `managed_routes/mod.rs` was at the 500-SLOC production cap; this
//! route (and its doc comment) is extracted here, mirroring how `lifecycle.rs`
//! and `activity.rs` already keep `mod.rs` under budget.
//! What: one axum handler, `reactivate_managed_session`, that delegates to
//! [`crate::session_manager::SessionManager::mark_reactivated`], falling back
//! to [`reconcile_stale_active_then_reactivate`] (#2453) when the record is
//! `Active` but the daemon's own periodic reap tick has not yet caught up
//! with the pane's actual runtime-exited state.
//! Test: `mark_reactivated_flips_stopped_to_active`,
//! `mark_reactivated_rejects_non_stopped` in `session_manager::reactivate_tests`;
//! `should_reconcile_stale_active_*` below.

use std::sync::Arc;

use axum::{
    extract::{Path as AxumPath, Query, State},
    response::IntoResponse,
};
use serde::Deserialize;

use crate::core::own_binary_names::OWN_BINARY_NAMES;
use crate::daemon::orphan_gc::{ChildLivenessProbe, PaneInfo, ProcessTreeProbe, is_idle_shell};
use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::runtime_reap::{find_runtime_exited, session_has_live_pane};
use crate::daemon::state::DaemonState;
use crate::session_manager::{
    ManagedError, ManagedSessionId, ManagedSessionState, SessionManager, SessionRecord,
};

use super::{parse_id, record_to_summary};

/// Query parameters for the reactivate route.
///
/// Why: an in-place-relaunch client (`bin/tm`'s `guided_inplace`) runs `tm`
/// as the foreground process of the very pane it is relaunching. At the exact
/// moment it POSTs here, `tmux list-panes` reports THAT pane's
/// `pane_current_command` as `tm` (and its shell PID has `tm` as a live child)
/// — so the daemon's own idleness classification would see the pane as "still
/// running a runtime" and refuse the stale-`Active` reconcile with a 409, even
/// though the agent has genuinely exited (#2789, the reported dead-end). The
/// client therefore tells the daemon which pane is ITS OWN via the stable tmux
/// `pane_id`, so [`reconcile_stale_active_then_reactivate`] can exclude that
/// one self-referential pane from the liveness check.
/// What: an optional `caller_pane_id` (tmux's `%N`). Absent for non-tmux or
/// pre-#2789 clients, in which case reconciliation behaves exactly as before.
/// The `pane_confirmed_dead` flag (#2790-follow-up) carries the CLI's
/// self-asserted proof-of-death claim — see its field doc for the trust-model
/// discussion (accepted, not independently verified) — and
/// [`should_reconcile_stale_active`].
/// Test: `should_reconcile_stale_active_true_when_only_caller_pane_busy`,
/// `should_reconcile_stale_active_true_when_confirmed_dead_and_pane_missing`.
#[derive(Debug, Default, Deserialize)]
pub struct ReactivateQuery {
    /// The stable tmux `pane_id` of the pane the reactivate request originates
    /// from (the pane about to `exec` `claude` in place).
    #[serde(default)]
    pub caller_pane_id: Option<String>,
    /// Set by an in-place-relaunch client that has matched tmux's stable
    /// `pane_id` (`bin/tm`'s `guided::pane_identity_confirmed`) against the
    /// record and is asserting — a CLIENT SELF-ASSERTION, not something the
    /// daemon independently verifies (no process/ancestry check) — that it is
    /// running inside THIS record's OWN pane and observed the agent exit. Like
    /// every other managed-session state transition this daemon accepts from a
    /// caller (resume/stop/decommission included), the assertion is trusted
    /// under the localhost/single-operator threat model this daemon runs
    /// under, not treated as a cryptographically-verified fact. It is still
    /// bounded, not a free pass: the pane match can be wrong in ways the
    /// daemon's own tmux-pane liveness probe cannot see (the requesting `tm` is
    /// that pane's foreground command; a just-exited agent may leave a
    /// not-yet-reaped child; a racing `list-panes` may momentarily omit the
    /// pane), so when `true`, [`should_reconcile_stale_active`] trusts it OVER
    /// the probe only for the caller's OWN pane, while an INDEPENDENT
    /// sibling-pane liveness check still refuses if a genuinely live SIBLING
    /// pane keeps the tmux session busy (#2157). Absent/false for every
    /// pre-existing client, preserving the prior probe-only behavior.
    #[serde(default)]
    pub pane_confirmed_dead: bool,
}

/// POST /api/v1/sessions/managed/{id}/reactivate — flip Stopped -> Active IN
/// PLACE, with NO tmux mutation (#2023 component C).
///
/// Why: `resume` (in `lifecycle.rs`) always kills any surviving tmux session
/// and creates a fresh one — correct for the daemon-driven restart path, but
/// WRONG for the bare-`tm` in-pane relaunch: the operator is running `tm` from
/// inside the very pane `SessionManager::mark_runtime_exited_stopped` (#2023
/// A) left alive, and is about to `exec` `claude` directly back into that SAME
/// pane. This route gives that path a dedicated, non-destructive transition —
/// [`crate::session_manager::SessionManager::mark_reactivated`] only flips the
/// record's state.
/// What: 404 when the id is unknown; `mark_reactivated` accepts `Stopped` or
/// `Decommissioned` (#2777 — reviving a decommissioned session's lingering pane
/// in place, see `mark_reactivated`'s doc); on any OTHER non-reactivatable
/// state, tries
/// [`reconcile_stale_active_then_reactivate`] (#2453) before giving up — this
/// closes the up-to-60s window where a pane's `claude` process has already
/// exited but the periodic reap tick (or a racing `SessionEnd` hook) has not
/// yet flipped the record's `state` to `Stopped`; still 409 when the record is
/// genuinely not reconcilable (a live runtime, a different lifecycle state, or
/// a sibling pane in the same tmux session that is still active). The optional
/// `caller_pane_id` query param (#2789) lets an in-place-relaunch client name
/// its OWN pane so the reconcile does not count the requesting `tm` process
/// itself as a live runtime. 200 with the updated summary on success either way.
/// Test: `mark_reactivated_flips_stopped_to_active`,
/// `mark_reactivated_rejects_non_stopped` in `session_manager::reactivate_tests`;
/// `should_reconcile_stale_active_*` below.
pub async fn reactivate_managed_session(
    State(state): State<Arc<DaemonState>>,
    AxumPath(id_str): AxumPath<String>,
    Query(params): Query<ReactivateQuery>,
) -> impl IntoResponse {
    reactivate_core(&state, &id_str, &params).await // #6288
}

/// The transport-neutral body of `POST .../{id}/reactivate` (#6288), served
/// over the socket as `mpm.managed.reactivate`.
///
/// #9101: [`caller_pane_refusal`] answers 409 first, so a caller in a pane
/// that only looks like the record's never gets the record reactivated.
/// Test: `managed_reactivate_parity` in `daemon::rpc::managed_tests`.
pub(crate) async fn reactivate_core(
    state: &Arc<DaemonState>,
    id_str: &str,
    params: &ReactivateQuery,
) -> RouteOutcome {
    let id = match parse_id(id_str) {
        Ok(id) => id,
        Err((code, msg)) => return RouteOutcome::text(code.as_u16(), msg),
    };
    let mgr = state.session_manager().await;
    if let Some(why) = caller_pane_refusal(&mgr, &id, params.caller_pane_id.as_deref()).await {
        return RouteOutcome::text(409, why);
    }
    match mgr.mark_reactivated(&id).await {
        Ok(record) => RouteOutcome::ok(&record_to_summary(&record)),
        Err(ManagedError::SessionNotFound(_)) => {
            RouteOutcome::text(404, format!("session {id_str} not found"))
        }
        Err(ManagedError::InvalidState(_, reason)) => match reconcile_stale_active_then_reactivate(
            &mgr,
            &id,
            params.caller_pane_id.as_deref(),
            params.pane_confirmed_dead,
        )
        .await
        {
            Some(record) => RouteOutcome::ok(&record_to_summary(&record)),
            None => RouteOutcome::text(409, reason),
        },
        Err(e) => RouteOutcome::text(500, e.to_string()),
    }
}

/// Why a caller claiming `id`'s own pane may not reactivate it, or `None`
/// (#9101).
///
/// Why: `bin/tm`'s in-place relaunch proves it runs in the record's pane by
/// comparing `%N` ids only, and it then execs `claude` into that pane. A
/// restarted tmux server reuses `%N` ids, so a stale record's id can match
/// the caller's pane in an unrelated session. The client summary carries no
/// server identity, so the daemon, which holds the record, decides.
/// #9566: a bare `tm` run by a live agent's Bash tool also sits in the
/// record's pane, so the pane id alone cannot tell a dead agent from a live
/// one. A Stopped record is reactivated with no liveness check, so this gate
/// also reads the pane's foreground command.
/// What: `None` when no `caller_pane_id` was sent, the record cannot be read
/// (`mark_reactivated` answers that), or the caller names another pane than
/// `record.pane_id`. Otherwise `SessionManager::owned_pane`'s refusal text;
/// then a refusal unless the owned pane's foreground command reads idle per
/// [`caller_pane_is_idle`], an unreadable command included.
/// Test: `a_stale_record_after_a_server_restart_is_never_reactivated_in_place`,
/// `an_unreadable_pane_identity_refuses_the_in_place_reactivate`,
/// `a_stopped_record_whose_pane_runs_a_live_agent_is_never_reactivated_in_place`,
/// `a_stopped_record_whose_pane_runs_tm_is_still_reactivated_in_place`.
async fn caller_pane_refusal(
    mgr: &SessionManager,
    id: &ManagedSessionId,
    caller_pane_id: Option<&str>,
) -> Option<String> {
    let caller = caller_pane_id.filter(|p| !p.is_empty())?;
    let record = mgr.get(id).await.ok()?;
    if record.pane_id.as_deref() != Some(caller) {
        return None;
    }
    let pane = match mgr.owned_pane(id, &record) {
        Ok(pane) => pane,
        Err(e) => return Some(e.to_string()),
    };
    // #9566: a live agent in the foreground means this caller is its child.
    match mgr.pane_foreground_command(&pane) {
        Ok(command) if caller_pane_is_idle(&command) => None,
        Ok(command) => Some(format!(
            "refusing to reactivate session {id} in place: pane {pane} runs `{command}` \
             in the foreground, so its agent is still live"
        )),
        Err(e) => Some(format!(
            "refusing to reactivate session {id} in place: the foreground command of \
             pane {pane} could not be read: {e}"
        )),
    }
}

/// True when a caller pane whose foreground is `command` holds no live agent
/// (#9566).
///
/// Why: the in-place relaunch client runs `tm` as its pane's foreground once
/// the agent exited, but a live agent's Bash tool runs `tm` as a background
/// child, leaving the agent in the foreground. Only the first may be read as
/// idle.
/// What: `true` for one of this crate's own binary names or an idle shell
/// ([`is_idle_shell`]); `false` for anything else — `claude`, `node`, the
/// version string Claude Code sets as its process title, or any other program.
/// Test: `should_reconcile_stale_active_false_when_caller_pane_runs_a_live_agent`,
/// `should_reconcile_stale_active_true_when_only_caller_pane_busy`.
fn caller_pane_is_idle(command: &str) -> bool {
    let command = command.trim();
    OWN_BINARY_NAMES.contains(&command) || is_idle_shell(command)
}

/// #2453: reconcile a stale-`Active` record before refusing a reactivate.
///
/// Why: `mark_reactivated` above is Stopped-only by design (#2023 C) — but a
/// pane whose `claude` process just exited reads `Active` on the record for up
/// to 60 seconds (the daemon's `REAP_INTERVAL_SECS`, `daemon/mod.rs`) of lag
/// before the periodic `runtime_reap` tick (or a racing `SessionEnd` hook)
/// catches up.
/// Bare `tm`, run in that exact pane within that window, would otherwise be
/// told "not Stopped" and fall through to the destructive guided-picker
/// resume/attach path (#2453's root cause). This closes that gap by
/// independently re-checking the record's OWN pane with the SAME conservative
/// classification [`crate::daemon::runtime_reap::stop_runtime_exited`] uses on
/// its periodic tick, and — only when that confirms the runtime has genuinely
/// exited — folding the `mark_runtime_exited_stopped` transition into this
/// call before reactivating, instead of leaving the operator to wait out the
/// reap tick.
/// What: re-fetches the record; bails (`None`) unless it is `Active` and
/// [`should_reconcile_stale_active`] confirms every pane belonging to its tmux
/// session — EXCEPT the caller's own pane (`caller_pane_id`, #2789) — is idle
/// (a still-live pane — including a genuinely different, still-active sibling
/// window in the SAME tmux session, #2157 — keeps the existing 409 refusal,
/// preserving that safety boundary). `pane_confirmed_dead` forwards the CLI's
/// self-asserted claim (see [`ReactivateQuery::pane_confirmed_dead`] for the
/// trust-model discussion — it is accepted, not independently re-verified,
/// same as this daemon accepts any other client-originated state-transition
/// request) that it is inside the record's OWN pane and observed the agent
/// exit, so a caller matching that description reconciles even when the
/// tmux-pane probe would false-positive on that pane — bounded by the SAME
/// independent sibling-pane liveness check every other path here uses. On
/// confirmation, calls
/// [`SessionManager::mark_runtime_exited_stopped`] then
/// [`SessionManager::mark_reactivated`]; any failure at either step (tmux
/// discovery, pane listing, or either state transition) also yields `None` so
/// the caller falls back to the ordinary 409.
/// Test: I/O path (shells out to a real `tmux`); not unit-tested — mirrors the
/// rest of this binary's daemon-tmux-discovery call sites. The pure
/// classification it delegates to is `should_reconcile_stale_active_*` below.
async fn reconcile_stale_active_then_reactivate(
    mgr: &SessionManager,
    id: &ManagedSessionId,
    caller_pane_id: Option<&str>,
    pane_confirmed_dead: bool,
) -> Option<SessionRecord> {
    let record = mgr.get(id).await.ok()?;
    let panes = crate::daemon::tmux::TmuxDriver::discover()
        .ok()?
        .list_managed_panes()
        .ok()?;
    if !should_reconcile_stale_active(
        &record,
        &panes,
        caller_pane_id,
        pane_confirmed_dead,
        &ProcessTreeProbe,
    ) {
        return None;
    }
    mgr.mark_runtime_exited_stopped(id).await.ok()?;
    mgr.mark_reactivated(id).await.ok()
}

/// Pure predicate (#2453): should a non-`Stopped` reactivate request be
/// reconciled rather than refused?
///
/// Why: isolating the decision from the tmux round-trip
/// ([`reconcile_stale_active_then_reactivate`]) makes it exhaustively
/// unit-testable without a live daemon or tmux server. Reuses
/// [`find_runtime_exited`] — the SAME session-level classification the
/// periodic `runtime_reap` tick uses — rather than reimplementing pane
/// idleness, so a genuinely live sibling pane in the same tmux session (a
/// different window, #2157) keeps the WHOLE session "live" and this predicate
/// correctly returns `false`, preserving the existing refuse+switch-client
/// contract for that case.
///
/// #2789: the caller's OWN pane is first neutralized to an idle shell via
/// [`neutralize_caller_pane`]. Without this, an in-place-relaunch request is
/// self-defeating: the requesting `tm` process is the foreground command of
/// the pane being relaunched, so tmux reports that pane as running `tm` (a
/// non-shell command, and its shell PID has `tm` as a live child) — which the
/// unmodified liveness check reads as "runtime still alive", refusing the
/// reconcile with a 409 and stranding the operator in a bare shell. A pane
/// whose foreground is `tm` holds no live interactive `claude`, so treating it
/// as idle is sound. #9566: the caller's pane can still run a live agent — a
/// live claude's Bash tool runs `tm` as a background child, leaving claude in
/// the foreground — so only a pane whose foreground reads idle per
/// [`caller_pane_is_idle`] is neutralized. Every OTHER (sibling) pane is
/// checked unchanged, so a genuinely live sibling window still blocks the
/// reconcile.
/// #2794: `caller_pane_confirmed_dead` (from
/// [`ReactivateQuery::pane_confirmed_dead`]) is the CLI's self-asserted claim
/// — NOT independently verified by the daemon (no process/ancestry check) —
/// that it is this record's OWN pane and the agent has exited, accepted under
/// the same localhost/single-operator trust model this daemon already extends
/// to every other client-originated state transition (resume/stop/
/// decommission), and bounded by an INDEPENDENT sibling-pane liveness check
/// (below), not taken as a bare capability grant. `find_runtime_exited`
/// requires the record's tmux session to be PRESENT in `panes` AND every one
/// of its panes to read idle — a conservative rule that is correct for the
/// periodic reaper but leaves a residual gap for the in-place-relaunch caller:
/// its OWN pane can read busy in ways [`neutralize_caller_pane`] cannot always
/// repair (a racing `list-panes` omitting the pane entirely, or a not-yet-reaped
/// agent child the probe still counts). Bob's #2794 repro (a zombie-`Active`
/// record that only reconciled minutes later once the reaper independently
/// caught up) is exactly that gap. When the CLI asserts, via stable `pane_id`
/// (`bin/tm`'s `guided::pane_identity_confirmed`), that it is this record's own
/// pane and observed the agent exit, that assertion is trusted OVER the probe
/// for the caller's OWN pane: reconcile as long as no genuinely different
/// SIBLING pane keeps the session live (the #2157 boundary, independently
/// re-checked here regardless of the assertion). This never requires the
/// session to be "present", so the missing-pane race no longer strands the
/// operator, while the sibling check keeps a stale or mistaken assertion from
/// silently overriding a genuinely live different window.
///
/// What: `true` when `record.state == Active` AND either — (a)
/// `caller_pane_confirmed_dead` is set with a non-blank `caller_pane_id` and no
/// SIBLING pane in the record's tmux session is still live (over the
/// caller-neutralized list); or (b) the ordinary `find_runtime_exited` (scoped
/// to just this record, over the caller-neutralized pane list) selects it — its
/// tmux session has at least one present pane and NONE (other than the caller's
/// own) are still running an agent. `false` for every other state
/// (Provisioning/Errored/Decommissioned — those fall through to the ordinary
/// 409, matching `mark_reactivated`'s Stopped-only contract) and, on the
/// probe-only path (b), for a missing/still-live-sibling pane.
/// Test: `should_reconcile_stale_active_true_when_active_and_idle`,
/// `should_reconcile_stale_active_false_when_pane_still_live`,
/// `should_reconcile_stale_active_false_when_pane_missing`,
/// `should_reconcile_stale_active_false_when_not_active`,
/// `should_reconcile_stale_active_true_when_only_caller_pane_busy`,
/// `should_reconcile_stale_active_false_when_sibling_pane_live_despite_caller`,
/// `should_reconcile_stale_active_true_when_confirmed_dead_and_pane_missing`,
/// `should_reconcile_stale_active_true_when_confirmed_dead_and_caller_pane_busy`,
/// `should_reconcile_stale_active_false_when_confirmed_dead_but_sibling_live`,
/// `should_reconcile_stale_active_ignores_confirmed_dead_without_caller_pane`.
pub(crate) fn should_reconcile_stale_active(
    record: &SessionRecord,
    panes: &[PaneInfo],
    caller_pane_id: Option<&str>,
    caller_pane_confirmed_dead: bool,
    probe: &dyn ChildLivenessProbe,
) -> bool {
    if record.state != ManagedSessionState::Active {
        return false;
    }
    let neutralized = neutralize_caller_pane(panes, caller_pane_id);
    if caller_pane_confirmed_dead && caller_pane_id.is_some_and(|s| !s.is_empty()) {
        // Self-asserted proof-of-death path: the caller's own pane is trusted
        // as idle (already neutralized above) on the caller's say-so, accepted
        // under this daemon's localhost/single-operator trust model; reconcile
        // unless an INDEPENDENTLY checked SIBLING pane keeps the session live.
        // Deliberately does NOT gate on session presence, so a pane momentarily
        // missing from `list-panes` cannot block a caller asserting it is
        // inside the record's pane.
        return !session_has_live_pane(&record.tmux_name, &neutralized, probe);
    }
    !find_runtime_exited(std::slice::from_ref(record), &neutralized, probe).is_empty()
}

/// #2789: return `panes` with the caller's OWN pane rewritten to look like an
/// idle shell, so the liveness aggregation does not count the requesting `tm`
/// process against its own session.
///
/// Why: see [`should_reconcile_stale_active`]. The caller's pane is kept in the
/// list (its session must still count as "present" for `find_runtime_exited` to
/// consider it) but its command/PID are replaced so it reads as idle — as if
/// the pane had already dropped back to the bare shell it will become the
/// instant `tm` `exec`s `claude`. Matching is by the stable tmux `pane_id`
/// (never reused across panes), so exactly one pane can match.
/// What: when `caller_pane_id` is `None`/blank, or no pane matches it, returns
/// the panes unchanged (behaviorally identical to the pre-#2789 path). When a
/// pane matches and its foreground reads idle per [`caller_pane_is_idle`]
/// (#9566), that entry's `pane_current_command` becomes a bare shell and its
/// `pane_pid` becomes `None` (so [`ChildLivenessProbe`] reports no live
/// child); a matching pane running a live agent, and all other panes, are
/// copied verbatim.
/// Test: exercised via `should_reconcile_stale_active_true_when_only_caller_pane_busy`,
/// `should_reconcile_stale_active_false_when_sibling_pane_live_despite_caller`
/// and `should_reconcile_stale_active_false_when_caller_pane_runs_a_live_agent`.
fn neutralize_caller_pane(panes: &[PaneInfo], caller_pane_id: Option<&str>) -> Vec<PaneInfo> {
    let Some(caller) = caller_pane_id.filter(|s| !s.is_empty()) else {
        return panes.to_vec();
    };
    panes
        .iter()
        .map(|p| {
            // #9566: a live agent in the caller's pane is never rewritten idle.
            if p.pane_id.as_deref() == Some(caller) && caller_pane_is_idle(&p.pane_current_command)
            {
                PaneInfo {
                    pane_current_command: "zsh".to_string(),
                    pane_pid: None,
                    ..p.clone()
                }
            } else {
                p.clone()
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::daemon::orphan_gc::AlwaysIdleProbe;
    use chrono::Utc;
    use std::path::PathBuf;

    /// Build a minimal `Active` record for a given tmux name — every other
    /// field is a placeholder; only `state` and `tmux_name` drive the
    /// predicate under test.
    fn active_record(tmux_name: &str) -> SessionRecord {
        SessionRecord {
            id: ManagedSessionId::new(),
            tmux_name: tmux_name.to_string(),
            cwd: PathBuf::from("/tmp/test"),
            task: "test".into(),
            state: ManagedSessionState::Active,
            created_at: 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,
            tmux_server: None,
            injection_status: Default::default(),
            worktree_owner: None,
            terminal_at: None,
            stop_cause: None,
            kind: Default::default(),
        }
    }

    fn pane(name: &str, cmd: &str) -> PaneInfo {
        PaneInfo {
            session_name: name.to_string(),
            pane_current_command: cmd.to_string(),
            pane_pid: Some(4242),
            pane_id: None,
            pane_current_path: None,
        }
    }

    /// A pane carrying a stable tmux `pane_id` — needed to exercise the #2789
    /// caller-pane neutralization.
    fn pane_with_id(name: &str, cmd: &str, pane_id: &str) -> PaneInfo {
        PaneInfo {
            session_name: name.to_string(),
            pane_current_command: cmd.to_string(),
            pane_pid: Some(4242),
            pane_id: Some(pane_id.to_string()),
            pane_current_path: None,
        }
    }

    #[test]
    fn should_reconcile_stale_active_true_when_active_and_idle() {
        // The #2453 repro case: the record still reads Active, but the pane
        // has genuinely dropped back to a bare shell — reconcile, don't 409.
        let record = active_record("tm-demo");
        let panes = vec![pane("tm-demo", "zsh")];
        assert!(should_reconcile_stale_active(
            &record,
            &panes,
            None,
            false,
            &AlwaysIdleProbe
        ));
    }

    #[test]
    fn should_reconcile_stale_active_false_when_pane_still_live() {
        // A genuinely running claude in the record's OWN pane must not be
        // reconciled — this is the ordinary "record is correctly Active" case.
        let record = active_record("tm-demo");
        let panes = vec![pane("tm-demo", "claude")];
        assert!(!should_reconcile_stale_active(
            &record,
            &panes,
            None,
            false,
            &AlwaysIdleProbe
        ));
    }

    #[test]
    fn should_reconcile_stale_active_false_when_sibling_pane_live() {
        // #2157 safety boundary: a DIFFERENT window in the SAME tmux session
        // still running claude must keep the whole session "live" — the
        // existing refuse+switch-client behavior for a genuine sibling window
        // must be preserved, not silently reconciled out from under it.
        let record = active_record("tm-demo");
        let panes = vec![pane("tm-demo", "zsh"), pane("tm-demo", "claude")];
        assert!(!should_reconcile_stale_active(
            &record,
            &panes,
            None,
            false,
            &AlwaysIdleProbe
        ));
    }

    #[test]
    fn should_reconcile_stale_active_false_when_pane_missing() {
        // No pane at all for this tmux session (session fully gone) is the
        // #1744 reaper's job, not this reconcile path — must not reconcile.
        let record = active_record("tm-demo");
        assert!(!should_reconcile_stale_active(
            &record,
            &[],
            None,
            false,
            &AlwaysIdleProbe
        ));
    }

    #[test]
    fn should_reconcile_stale_active_false_when_not_active() {
        // Every other lifecycle state must fall through to the ordinary 409 —
        // `mark_reactivated`'s Stopped-only contract is unchanged for these.
        for state in [
            ManagedSessionState::Provisioning,
            ManagedSessionState::Errored,
            ManagedSessionState::Decommissioned,
        ] {
            let mut record = active_record("tm-demo");
            record.state = state.clone();
            let panes = vec![pane("tm-demo", "zsh")];
            assert!(
                !should_reconcile_stale_active(&record, &panes, None, false, &AlwaysIdleProbe),
                "state {state} must not be reconciled"
            );
        }
    }

    #[test]
    fn should_reconcile_stale_active_true_when_only_caller_pane_busy() {
        // #2789 core repro: bare `tm` typed in the just-vacated pane runs as
        // that pane's foreground command, so tmux reports it as running `tm`
        // (NOT an idle shell). Without the caller-pane exclusion this reads as
        // "runtime still alive" and 409s — the reported dead-end. Naming the
        // caller's own pane_id neutralizes exactly that one pane, so the
        // session reconciles.
        let record = active_record("tm-cto-01");
        let panes = vec![pane_with_id("tm-cto-01", "tm", "%3")];
        assert!(
            !should_reconcile_stale_active(&record, &panes, None, false, &AlwaysIdleProbe),
            "pre-#2789 behavior (no caller_pane_id): the caller's own `tm` pane \
             is still (wrongly) seen as live"
        );
        assert!(
            should_reconcile_stale_active(&record, &panes, Some("%3"), false, &AlwaysIdleProbe),
            "#2789: naming the caller's own pane must let its session reconcile"
        );
    }

    #[test]
    fn should_reconcile_stale_active_false_when_sibling_pane_live_despite_caller() {
        // #2789 genuine-conflict guard: neutralizing the caller's OWN pane must
        // NOT hide a genuinely live claude in a DIFFERENT pane of the same tmux
        // session (#2157). The sibling keeps the session live → 409 stands.
        let record = active_record("tm-cto-01");
        let panes = vec![
            pane_with_id("tm-cto-01", "tm", "%3"),     // caller's own pane
            pane_with_id("tm-cto-01", "claude", "%4"), // genuinely live sibling
        ];
        assert!(
            !should_reconcile_stale_active(&record, &panes, Some("%3"), false, &AlwaysIdleProbe),
            "a live sibling pane must still block reconcile even when the \
             caller's own pane is excluded"
        );
    }

    #[test]
    fn should_reconcile_stale_active_ignores_unmatched_caller_pane_id() {
        // A caller_pane_id that matches no pane in the record's session must
        // leave the liveness check unchanged — no accidental neutralization of
        // an unrelated pane.
        let record = active_record("tm-demo");
        let panes = vec![pane_with_id("tm-demo", "claude", "%1")];
        assert!(
            !should_reconcile_stale_active(&record, &panes, Some("%99"), false, &AlwaysIdleProbe),
            "an unmatched caller_pane_id must not force any pane idle"
        );
    }

    #[test]
    fn should_reconcile_stale_active_true_when_confirmed_dead_and_pane_missing() {
        // #2794 core repro: the caller has PROVEN (CLI-side pane_identity_
        // confirmed) it is inside this record's OWN pane and the agent exited,
        // but `list-panes` momentarily reports NO pane for the session (a race
        // the probe-only path treats as "session gone" → 409, the reported
        // dead-end). Proof-of-death must reconcile anyway — there is no sibling
        // to protect.
        let record = active_record("tm-mcp-services-01");
        assert!(
            !should_reconcile_stale_active(&record, &[], Some("%5"), false, &AlwaysIdleProbe),
            "without proof-of-death, a missing pane keeps the ordinary 409"
        );
        assert!(
            should_reconcile_stale_active(&record, &[], Some("%5"), true, &AlwaysIdleProbe),
            "#2794: a pane-confirmed-dead caller reconciles even when \
             list-panes omits its pane"
        );
    }

    #[test]
    fn should_reconcile_stale_active_true_when_confirmed_dead_and_caller_pane_busy() {
        // The exact zombie-Active shape: the record still reads Active and the
        // caller's own pane reports `tm` (its foreground command), which the
        // probe reads as live. Proof-of-death overrides the probe for the
        // caller's OWN pane.
        let record = active_record("tm-mcp-services-01");
        let panes = vec![pane_with_id("tm-mcp-services-01", "tm", "%5")];
        assert!(
            should_reconcile_stale_active(&record, &panes, Some("%5"), true, &AlwaysIdleProbe),
            "#2794: pane-confirmed-dead reconciles its own busy `tm` pane"
        );
    }

    #[test]
    fn should_reconcile_stale_active_false_when_confirmed_dead_but_sibling_live() {
        // Proof-of-death for the caller's OWN pane must NOT override a genuinely
        // live claude in a DIFFERENT window of the same tmux session (#2157).
        let record = active_record("tm-mcp-services-01");
        let panes = vec![
            pane_with_id("tm-mcp-services-01", "tm", "%5"), // caller's own pane
            pane_with_id("tm-mcp-services-01", "claude", "%6"), // live sibling
        ];
        assert!(
            !should_reconcile_stale_active(&record, &panes, Some("%5"), true, &AlwaysIdleProbe),
            "a live sibling window must still block reconcile despite proof-of-death"
        );
    }

    #[test]
    fn should_reconcile_stale_active_ignores_confirmed_dead_without_caller_pane() {
        // The proof-of-death shortcut requires a concrete caller_pane_id — the
        // flag alone (no pane id, or a blank one) must fall back to the ordinary
        // probe-based path, never a blanket reconcile.
        let record = active_record("tm-demo");
        let panes = vec![pane("tm-demo", "claude")];
        assert!(
            !should_reconcile_stale_active(&record, &panes, None, true, &AlwaysIdleProbe),
            "pane_confirmed_dead without a caller_pane_id must not reconcile a live pane"
        );
        assert!(
            !should_reconcile_stale_active(&record, &panes, Some(""), true, &AlwaysIdleProbe),
            "a blank caller_pane_id must not trigger the proof-of-death shortcut"
        );
    }

    #[test]
    fn should_reconcile_stale_active_false_when_caller_pane_runs_a_live_agent() {
        // #9566: a bare `tm` run by a live claude's Bash tool sits in that
        // claude's pane, whose foreground stays the agent. Proof-of-death from
        // that pane must not reconcile the Active record.
        let record = active_record("tm-demo");
        for cmd in ["claude", "node", "2.1.3"] {
            let panes = vec![pane_with_id("tm-demo", cmd, "%5")];
            assert!(
                !should_reconcile_stale_active(&record, &panes, Some("%5"), true, &AlwaysIdleProbe),
                "a caller pane running `{cmd}` was reconciled"
            );
        }
    }

    #[test]
    fn should_reconcile_stale_active_false_when_unconfirmed_caller_pane_runs_a_live_agent() {
        // #9566 twin without proof-of-death: the probe path must not read the
        // live agent in the caller's pane as idle either.
        let record = active_record("tm-demo");
        for cmd in ["claude", "node", "2.1.3"] {
            let panes = vec![pane_with_id("tm-demo", cmd, "%5")];
            assert!(
                !should_reconcile_stale_active(
                    &record,
                    &panes,
                    Some("%5"),
                    false,
                    &AlwaysIdleProbe
                ),
                "a caller pane running `{cmd}` was reconciled"
            );
        }
    }
}