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
//! The production [`SessionControl`] impl over the daemon's managed surface.
//!
//! Why: the SM-STDIO adapter (DOC-14 §1A.1) and the SM-8 delegation loop both
//! drive sessions through the narrow [`SessionControl`] trait (now defined in
//! `core::sm::control` so the agent-side loop can depend on it too). This file
//! holds the PRODUCTION impl: an in-process bridge onto the daemon's existing
//! managed-session control surface (§2.6) — the SAME code `tm ticket`/the HTTP
//! `…/managed/*` routes use. Keeping it here (not in core) is deliberate: it
//! needs daemon-internal handles (`DaemonState`, `spawn_managed`, …) that core
//! must not depend on. The trait + input/error types are re-exported from this
//! module so `sm_stdio`'s public surface (`pub use control::{…}`) is unchanged.
//! What: [`DaemonSessionControl`] delegates each verb to the in-process
//! [`crate::session_manager::SessionManager`] + [`spawn_managed`]/[`resume_managed`]
//! (so the stdio path and the HTTP path cannot diverge). The trait, [`LaunchParams`],
//! and [`SessionControlError`] are re-exported from [`crate::core::sm::control`].
//! Test: `DaemonSessionControl` is exercised by the daemon's managed-session
//! integration tests (it forwards to the same helpers those cover); the
//! dispatcher's `sm.sessions.*` round-trips use the `tests.rs` mock.

use std::sync::Arc;

use async_trait::async_trait;

// Re-export the relocated trait + types so `sm_stdio`'s public surface
// (`pub use control::{DaemonSessionControl, LaunchParams, SessionControl,
// SessionControlError}`) is unchanged after the SM-8 move to core.
use crate::activity::cache::ActivityState;
pub use crate::core::sm::control::{
    LaunchParams, RawObservation, SessionControl, SessionControlError, Submit, Summary,
};

use crate::daemon::managed_routes::{SpawnParams, record_to_json, resume_managed, spawn_managed};
use crate::daemon::state::DaemonState;
use crate::session_manager::{ManagedError, ManagedSessionId};

/// Production [`SessionControl`] over the in-process managed-session surface.
///
/// Why: the SM runs IN the daemon, so the spec's "delegate to t-mpm's
/// session-manager" is a direct in-process call to the SAME helpers the HTTP
/// `…/managed/*` routes use ([`spawn_managed`]/[`resume_managed`] +
/// [`crate::session_manager::SessionManager`]). Calling them directly (rather
/// than looping back over HTTP) keeps one code path and means the stdio surface
/// inherits every fix to the managed surface for free.
/// What: holds the shared `Arc<DaemonState>` and forwards each verb to its
/// managed-surface counterpart, returning the same flat JSON the HTTP routes do
/// (via [`record_to_json`]).
/// Test: forwards to helpers covered by the managed integration tests; the
/// dispatcher round-trips use the `tests.rs` mock instead.
pub struct DaemonSessionControl {
    /// Shared daemon state holding the managed [`crate::session_manager::SessionManager`].
    state: Arc<DaemonState>,
}

impl DaemonSessionControl {
    /// Build the production control over the daemon's shared state.
    ///
    /// Why: the stdio entry point constructs this once from the daemon state so
    /// every `sm.sessions.*` call reuses the one live session manager.
    /// What: stores the `Arc<DaemonState>`.
    /// Test: construction is trivial; behaviour is covered by the managed tests.
    pub fn new(state: Arc<DaemonState>) -> Self {
        Self { state }
    }

    /// Parse + validate a managed session id string.
    ///
    /// Why: an invalid UUID must become a graceful `NotFound` (→ JSON-RPC error)
    /// rather than a panic, mirroring the HTTP route's 400/404 handling.
    /// What: parses the string into a [`ManagedSessionId`], mapping failure to
    /// [`SessionControlError::NotFound`].
    /// Test: covered by the managed integration tests.
    fn parse_id(session_id: &str) -> Result<ManagedSessionId, SessionControlError> {
        session_id
            .parse::<uuid::Uuid>()
            .map(ManagedSessionId::from)
            .map_err(|_| SessionControlError::NotFound(session_id.to_string()))
    }

    /// Map a manager [`ManagedError`] onto the control error class.
    ///
    /// Why: a post-parse operation failure must preserve the not-found-vs-backend
    /// distinction so the dispatcher picks the right JSON-RPC code. Reserving
    /// [`SessionControlError::NotFound`] for the genuinely-absent session (and the
    /// UUID-parse failure in [`Self::parse_id`]) — and routing every OTHER manager
    /// failure (tmux, store, invalid-state, I/O) to
    /// [`SessionControlError::Backend`] (`-32603`) — keeps `get`/`stop`/`kill`
    /// consistent with `send`/`resume`, which already surface backend failures as
    /// `Backend`.
    /// What: `ManagedError::SessionNotFound` → `NotFound`; every other variant →
    /// `Backend(e.to_string())`.
    /// Test: `tests.rs::stop_backend_failure_is_backend_not_found` and
    /// `kill_backend_failure_is_backend_not_found`.
    fn map_managed_err(e: ManagedError) -> SessionControlError {
        match e {
            ManagedError::SessionNotFound(msg) => SessionControlError::NotFound(msg),
            other => SessionControlError::Backend(other.to_string()),
        }
    }
}

#[async_trait]
impl SessionControl for DaemonSessionControl {
    /// Launch via the shared [`spawn_managed`] flow (provision → tmux → harness).
    ///
    /// Why: the spec maps `sm.sessions.launch` onto `POST /sessions`, whose
    /// engine is [`spawn_managed`]. Forwarding here keeps the adapter a thin
    /// mapping with zero provisioning logic of its own.
    /// What: maps `workdir → repo_url` (unless an explicit `repo_url` was
    /// supplied — WI-A #1585 — in which case that takes precedence), `prompt →
    /// task` (defaulting to a generic description when absent), `model →
    /// runtime`, and `ref_ → git_ref` (WI-A #1585; empty string when absent).
    /// A blank `git_ref` is explicitly handled by `RealGitBackend::clone_repo`
    /// which falls back to fetching `HEAD` (the remote's default branch
    /// pointer) instead of an empty, invalid ref name — see
    /// `provisioner::workspace::blank_git_ref_omits_branch_flag`
    /// for the contract test. Returns `{ session_id }`. The `goal_id` link is
    /// recorded by the caller via the goal store (§9.3), not here, so this
    /// stays purely session-control.
    /// Test: forwards to `spawn_managed` (managed integration tests); the
    /// repo_url/ref_ threading is covered by
    /// `chat_action_tests.rs::execute_launch_threads_repo_url_and_ref_`.
    async fn launch(&self, params: LaunchParams) -> Result<serde_json::Value, SessionControlError> {
        let task = params
            .prompt
            .filter(|p| !p.trim().is_empty())
            .unwrap_or_else(|| "session-manager launched task".to_string());
        // WI-A #1585: an explicit `repo_url` in LaunchParams takes precedence
        // over `workdir` for provisioning; `workdir` is used as the fallback
        // (local-path fast-path or legacy callers that encode the URL there).
        let repo_url = params.repo_url.unwrap_or(params.workdir);
        let git_ref = params.ref_.unwrap_or_default();
        let spawn = SpawnParams {
            repo_url,
            git_ref,
            task,
            name_hint: None,
            runtime: params.model,
            ephemeral: params.ephemeral,
            // `sm.sessions.launch` is the SM-STDIO adapter (a distinct DOC-14
            // subsystem, not the standard MCP tool surface) — never subject to
            // the MCP spawn gate (#1836/#1837), matching `tm launch`/`tm ticket`.
            mcp_initiated: false,
            // Turnkey by default (#1903/#1299): the SM driver launches to work.
            inject_task: None,
            // The SM-STDIO adapter does not expose Deliverable linkage
            // (#2379's CLI surface is `tm sessions new --deliverable`, HTTP-only).
            deliverable_id: None,
            // Keep the #1707 reconnect (#2450): the SM-STDIO adapter is a
            // programmatic driver; an idempotent reconnect for a project that
            // already has a live session is the safe default here too.
            force_new: false,
            // #5274: `sm.sessions.launch` carries no worktree request in its
            // `LaunchParams` wire shape, so the session runs in the project's
            // main checkout — the same default every programmatic caller gets.
            worktree: false,
        };
        let record = spawn_managed(
            &self.state,
            crate::session_manager::ManagedSessionId::new(),
            spawn,
        )
        .await
        .map_err(SessionControlError::Backend)?;
        Ok(serde_json::json!({ "session_id": record.id.to_string() }))
    }

    /// List all managed sessions as `{ sessions: [record, …] }`.
    async fn list(&self) -> Result<serde_json::Value, SessionControlError> {
        let mgr = self.state.session_manager().await;
        let sessions: Vec<serde_json::Value> =
            mgr.list().await.iter().map(record_to_json).collect();
        Ok(serde_json::json!({ "sessions": sessions }))
    }

    /// Get one session record (the flat managed JSON).
    async fn get(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        let record = mgr.get(&id).await.map_err(Self::map_managed_err)?;
        Ok(serde_json::json!({ "session": record_to_json(&record) }))
    }

    /// Inject text into the session pane; returns `{ ok: true }`.
    async fn send(
        &self,
        session_id: &str,
        text: &str,
    ) -> Result<serde_json::Value, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        mgr.send_input(&id, text)
            .await
            .map_err(|e| SessionControlError::Backend(e.to_string()))?;
        Ok(serde_json::json!({ "ok": true }))
    }

    /// Stop the runtime, keeping the workspace; returns `{ ok: true,
    /// runtime_left_running }`.
    ///
    /// #8935: `runtime_left_running` (additive) is the reason a live tmux
    /// session with the record's name was left running, or `null`.
    /// Test: `stop_says_an_unproven_runtime_was_left_running`.
    async fn stop(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        let report = mgr
            .stop_reporting(&id, crate::session_manager::StopCause::Deliberate)
            .await
            .map_err(Self::map_managed_err)?;
        Ok(serde_json::json!({
            "ok": true,
            "runtime_left_running": report.runtime.left_running(),
        }))
    }

    /// Resume a stopped session and re-spawn its runtime; returns `{ ok: true }`.
    async fn resume(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        resume_managed(&self.state, &id)
            .await
            .map_err(|e| SessionControlError::Backend(e.to_string()))?;
        Ok(serde_json::json!({ "ok": true }))
    }

    /// Force-stop / reap a session (decommission — terminal teardown).
    ///
    /// Why: `sm.sessions.kill` is the force-stop/reap verb (§1A.1 maps it to the
    /// `DELETE /sessions/{id}` + `/sessions/dead` reap path). The strongest
    /// in-process equivalent is `decommission` (kills runtime + tombstones the
    /// record); a plain `stop` would leave a resumable session, not a reaped one.
    /// What: forwards to `SessionManager::decommission_reporting` (no
    /// `--force`); returns `{ ok: true, workspace_removed, workspace_kept_reason,
    /// workspace_kept_by_design }` — the last three additive (#8663), the
    /// HTTP route's verdict fields, so a kept workspace is reported.
    /// Test: `kill_reports_the_workspace_verdict`.
    async fn kill(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        let report = mgr
            .decommission_reporting(
                &id,
                None,
                crate::session_manager::decommission_force::ProvisioningDirt::Refuse,
            )
            .await
            .map_err(Self::map_managed_err)?;
        Ok(serde_json::json!({
            "ok": true,
            "workspace_removed": report.workspace_removed,
            "workspace_kept_reason": report.workspace_kept_reason,
            // #8663 critic round 2: parity with the HTTP route.
            "workspace_kept_by_design": report.workspace_kept_by_design,
        }))
    }

    /// Inject text via the manager, dispatched per `Submit` (#1461).
    ///
    /// Why: the production wiring of the harness-agnostic write verb. It forwards
    /// to [`SessionManager::inject`](crate::session_manager::SessionManager::inject),
    /// which dispatches the keystroke intent onto
    /// the real tmux driver (literal+Enter / literal / C-c).
    /// What: parses the id, then calls `inject`, mapping a manager failure onto
    /// `Backend` (the pane/tmux failure class).
    /// Test: forwards to `SessionManager::inject` (covered by the inject-dispatch
    /// tests against a recording driver in tests/session_control_api.rs).
    async fn inject_text(
        &self,
        session_id: &str,
        text: &str,
        submit: Submit,
    ) -> Result<(), SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        mgr.inject(&id, text, submit)
            .await
            .map_err(|e| SessionControlError::Backend(e.to_string()))
    }

    /// Observe the session's raw surface — LLM-FREE (#1461).
    ///
    /// Why: the production wiring of the always-available read verb. It forwards
    /// to [`SessionManager::observe`](crate::daemon::services::delegation_tracker::observe), which never touches the LLM.
    /// What: parses the id and returns the [`RawObservation`] (pane + liveness +
    /// pending fields). `NotFound` is preserved for a genuinely-absent session.
    /// Test: `observe_returns_raw_pane_without_llm`, `observe_reports_runtime_active`.
    async fn observe(
        &self,
        session_id: &str,
        lines: usize,
    ) -> Result<RawObservation, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        mgr.observe(&id, lines).await.map_err(Self::map_managed_err)
    }

    /// Summarize the session via the activity monitor — LLM-OPTIONAL (#1461).
    ///
    /// Why: the production wiring of the optional inference verb. It reuses the
    /// SAME shared [`crate::activity::monitor::ActivityMonitor`] the `/activity`
    /// route uses, so the content-hash cache and the missing-key degrade are
    /// inherited verbatim — repeated polls on unchanged panes are cheap, and an
    /// unconfigured host returns `Unknown`/`None` instead of erroring.
    /// What: captures the pane (LLM-free), runs `ActivityMonitor::check` (which
    /// converts `MissingApiKey` to an `Unknown` verdict non-erroring), and maps
    /// the verdict onto a [`Summary`] — dropping the placeholder summary text to
    /// `None` whenever the state is `Unknown` (degraded), per the LLM-optional
    /// contract.
    /// Test: `summarize_degrades_to_unknown_without_key`, `summarize_returns_fake_verdict`,
    /// `summarize_serves_cached_verdict` (the last two via a fake-classifier monitor).
    async fn summarize(&self, session_id: &str) -> Result<Summary, SessionControlError> {
        let id = Self::parse_id(session_id)?;
        let mgr = self.state.session_manager().await;
        // Capture is LLM-free and tolerant of a gone pane (empty string).
        let pane_text = mgr.capture_pane(&id, 60).await.unwrap_or_default();
        let monitor = self.state.activity_monitor();
        // `check` already degrades MissingApiKey → Unknown verdict (no error).
        let result = monitor
            .check(session_id, &pane_text)
            .await
            .map_err(|e| SessionControlError::Backend(e.to_string()))?;
        Ok(verdict_to_summary(result.verdict))
    }

    /// Adopt an existing tmux session via the in-process manager (#1433).
    ///
    /// Why: the production wiring of the explicit-adopt verb. It forwards to
    /// [`crate::session_manager::SessionManager::adopt_existing`] so the stdio /
    /// action-loop path and the HTTP `…/managed/adopt` route share one code path.
    /// What: resolves an optional runtime selector (a bad value → `Backend`), then
    /// adopts the named pane with the supplied `cwd`/`task`/`runtime`. Maps the
    /// manager's `TmuxSessionMissing` to `NotFound` (no such pane) and every other
    /// failure (already-adopted, store) to `Backend`. Returns `{ session_id }`.
    /// Test: forwards to `adopt_existing` (covered by the manager unit tests); the
    /// action-loop dispatch is covered by `chat_action_tests.rs`.
    async fn adopt(
        &self,
        tmux_name: &str,
        cwd: &str,
        task: Option<&str>,
        runtime: Option<&str>,
    ) -> Result<serde_json::Value, SessionControlError> {
        let runtime_kind = match runtime {
            None => crate::runtime::RuntimeKind::default(),
            Some(raw) => raw
                .parse::<crate::runtime::RuntimeKind>()
                .map_err(|e| SessionControlError::Backend(e.to_string()))?,
        };
        let mgr = self.state.session_manager().await;
        let record = mgr
            .adopt_existing(
                tmux_name,
                std::path::PathBuf::from(cwd),
                task.unwrap_or_default().to_string(),
                runtime_kind,
                // Chat/action-loop adoptions are durable operator sessions, never
                // ephemeral — they must not be auto-reaped (#1508).
                false,
            )
            .await
            .map_err(|e| match e {
                ManagedError::TmuxSessionMissing(msg) => SessionControlError::NotFound(msg),
                other => SessionControlError::Backend(other.to_string()),
            })?;
        Ok(serde_json::json!({ "session_id": record.id.to_string() }))
    }
}

/// Map an [`crate::activity::cache::ActivityVerdict`] onto a [`Summary`] (#1461).
///
/// Why: the LLM-optional contract says a degraded (`Unknown`) verdict must carry
/// `summary = None` — the placeholder text ("OPENROUTER_API_KEY not configured")
/// is NOT a real summary. Centralising the mapping keeps that rule in one place
/// and mirrors the activity sidecar, which also never stores an `Unknown` summary.
/// What: copies `state`/`confidence`; sets `summary = Some(text)` only when the
/// state is anything other than `Unknown`.
/// Test: `summarize_degrades_to_unknown_without_key` (None) and
/// `summarize_returns_fake_verdict` (Some) in tests/session_control_api.rs.
fn verdict_to_summary(verdict: crate::activity::cache::ActivityVerdict) -> Summary {
    let summary = if verdict.state == ActivityState::Unknown {
        None
    } else {
        Some(verdict.summary)
    };
    Summary {
        state: verdict.state,
        summary,
        confidence: verdict.confidence,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::activity::cache::ActivityVerdict;

    #[test]
    fn verdict_to_summary_drops_unknown_summary_to_none() {
        // The degraded placeholder (Unknown + "OPENROUTER_API_KEY not configured")
        // must map to summary=None so the LLM-optional contract holds.
        let v = ActivityVerdict {
            state: ActivityState::Unknown,
            summary: "OPENROUTER_API_KEY not configured".into(),
            confidence: 0.0,
        };
        let s = verdict_to_summary(v);
        assert_eq!(s.state, ActivityState::Unknown);
        assert_eq!(s.summary, None);
        assert_eq!(s.confidence, 0.0);
    }

    #[test]
    fn verdict_to_summary_keeps_real_summary() {
        // A genuine (non-Unknown) verdict keeps its summary text and confidence.
        let v = ActivityVerdict {
            state: ActivityState::Working,
            summary: "writing code".into(),
            confidence: 0.87,
        };
        let s = verdict_to_summary(v);
        assert_eq!(s.state, ActivityState::Working);
        assert_eq!(s.summary.as_deref(), Some("writing code"));
        // Approx comparison: `confidence` is an f64 carried through the verdict,
        // so an exact `==` is fragile to float representation. 1e-6 is far
        // tighter than any meaningful confidence delta.
        assert!((s.confidence - 0.87).abs() < 1e-6);
    }

    /// A tmux where every created session stays live (#8935).
    #[derive(Default)]
    struct LiveTmux(std::sync::Mutex<Vec<String>>);

    impl crate::session_manager::ManagedTmuxDriver for LiveTmux {
        fn create_session(&self, name: &str, _: &str) -> Result<(), ManagedError> {
            self.0.lock().unwrap().push(name.to_owned());
            Ok(())
        }
        fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
            self.0.lock().unwrap().retain(|n| n != name);
            Ok(())
        }
        fn send_line(&self, _: &str, _: &str) -> Result<(), ManagedError> {
            Ok(())
        }
        fn capture(&self, _: &str, _: usize) -> Result<String, ManagedError> {
            Ok(String::new())
        }
        fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
            Ok(self.0.lock().unwrap().clone())
        }
    }

    /// #8935 critic round: `sm.sessions.stop` says when it moved the record
    /// only. The record has no pane id, so the live session's ownership is
    /// unproven and it is left running. Red on a73e5ac7f3, which answered
    /// `{ ok: true }` alone.
    #[tokio::test]
    async fn stop_says_an_unproven_runtime_was_left_running() {
        let tmp = tempfile::tempdir().expect("daemon root");
        let ws = tempfile::tempdir().expect("user directory");
        let tmux = Arc::new(LiveTmux::default());
        let state = Arc::new(
            DaemonState::with_root_isolated_managed_and_driver(
                tmp.path().to_path_buf(),
                tmux.clone(),
            )
            .await,
        );
        let id = ManagedSessionId::new();
        state
            .session_manager()
            .await
            .create_with_id(
                id,
                "regression: #8935 sm stop".to_string(),
                Some(ws.path().to_path_buf()),
                None,
                Some(ws.path().to_path_buf()),
                None,
                None,
                crate::runtime::RuntimeKind::default(),
                false,
                false,
            )
            .await
            .expect("seed session");

        let json = DaemonSessionControl::new(state)
            .stop(&id.to_string())
            .await
            .expect("stop");

        assert_eq!(json["ok"], serde_json::Value::Bool(true), "{json}");
        let why = json["runtime_left_running"]
            .as_str()
            .unwrap_or_else(|| panic!("the stop names the live session: {json}"));
        assert!(why.contains("no pane id"), "{why}");
        assert_eq!(tmux.0.lock().unwrap().len(), 1, "the session was killed");
    }

    /// #8663 critic round 1: `sm.sessions.kill` carries `workspace_removed`,
    /// `workspace_kept_reason` and (critic round 2) `workspace_kept_by_design`
    /// beside `ok`, as the HTTP route does. The kept-reason string itself is
    /// covered by `session_decommission_reports_a_kept_workspace`.
    #[tokio::test]
    async fn kill_reports_the_workspace_verdict() {
        let tmp = tempfile::tempdir().expect("daemon root");
        let ws = tempfile::tempdir().expect("user directory");
        let state =
            Arc::new(DaemonState::with_root_isolated_managed(tmp.path().to_path_buf()).await);
        let id = ManagedSessionId::new();
        state
            .session_manager()
            .await
            .create_with_id(
                id,
                "regression: #8663 sm kill".to_string(),
                Some(ws.path().to_path_buf()),
                None,
                Some(ws.path().to_path_buf()),
                None,
                None,
                crate::runtime::RuntimeKind::default(),
                false,
                false,
            )
            .await
            .expect("seed session");

        let json = DaemonSessionControl::new(state)
            .kill(&id.to_string())
            .await
            .expect("kill");
        assert_eq!(json["ok"], serde_json::Value::Bool(true), "{json}");
        assert_eq!(
            json.get("workspace_removed"),
            Some(&serde_json::Value::Bool(false)),
            "{json}"
        );
        assert_eq!(
            json.get("workspace_kept_reason"),
            Some(&serde_json::Value::Null),
            "{json}"
        );
        let by_design = json["workspace_kept_by_design"]
            .as_str()
            .unwrap_or_else(|| panic!("the by-design reason is a string: {json}"));
        assert!(by_design.contains("kept by design"), "{by_design}");
    }
}