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
//! Managed session-manager command methods (`/api/v1/sessions/managed/*`).
//!
//! Why: the managed verb set is the convergence target for every managed-session
//! UI (the `tm` CLI today; STUI #1272, TELUI #1433, Web/Slack next). Each verb
//! maps one-to-one onto a typed [`crate::client::DaemonClient`] managed method,
//! so wiring them here once gives every adapter the full surface through the
//! shared [`CommandResult`]. Splitting these out of the dispatch facade keeps
//! every executor file under the 500-SLOC cap.
//! What: inherent `CommandExecutor` methods, one per managed verb. Verbs that
//! take a target resolve the fuzzy id-or-name against the live managed list via
//! the canonical [`resolve_target`] resolver before calling the endpoint, so a
//! friendly name or prefix works everywhere. Transport/HTTP failures become a
//! renderable [`CommandResult::Error`], never a panic.
//! Test: the `execute_managed_*` tests in `tests.rs` (wire-shape + dispatch).

use super::CommandExecutor;
use crate::client::http_client::{
    ManagedAdoptRequest, ManagedDecommissionOutcome, ManagedSessionSummary, ManagedSpawnRequest,
};
use crate::client::resolver::resolve_target;
use crate::client::result::{CommandResult, ManagedSessionView, ProjectFleetView};

impl CommandExecutor {
    /// Resolve a fuzzy managed target to its canonical id.
    ///
    /// Why: every target-taking managed verb accepts an id, a friendly name, or
    /// an unambiguous prefix; resolving once against the live list keeps the
    /// endpoint call unambiguous and the precedence identical across verbs.
    /// What: fetches the managed list, applies [`resolve_target`], and returns
    /// the matched session's canonical `id`. Returns `Err(CommandResult::Error)`
    /// when the daemon is unreachable or nothing matches, so callers can `?`-style
    /// short-circuit with a renderable error.
    /// Test: covered by `execute_managed_get_unknown_errors` and the happy-path
    /// managed tests.
    async fn resolve_managed(&self, target: &str) -> Result<ManagedSessionSummary, CommandResult> {
        let sessions = self
            .client()
            .list_managed_sessions()
            .await
            .map_err(|e| CommandResult::Error(format!("daemon unreachable: {e}")))?;
        match resolve_target(&sessions, target) {
            Some(found) => Ok(found.clone()),
            None => Err(CommandResult::Error(format!(
                "managed session {target} not found"
            ))),
        }
    }

    /// `managed-new` — spawn a managed session in an existing local checkout.
    ///
    /// Why: since ADR-0055 (#6000) the daemon provisions no workspace of its
    /// own, so a `repo_url` that is not already a directory on this host has
    /// nowhere to run. Refusing here means `tm session new <url>` reports the
    /// remedy immediately instead of after a round trip — and reports it even
    /// when no daemon is reachable to answer.
    /// What: applies [`crate::core::local_repo_url::require_local_repo_url`],
    /// then POSTs the spawn request as before.
    /// Test: `execute_managed_new_refuses_a_remote_repo_url` in `tests.rs`.
    #[allow(clippy::too_many_arguments)]
    pub(super) async fn managed_new(
        &self,
        repo_url: String,
        git_ref: String,
        task: String,
        name_hint: Option<String>,
        runtime: Option<String>,
        inject_task: Option<bool>,
        deliverable_id: Option<String>,
        force_new: bool,
    ) -> CommandResult {
        // #6000 / ADR-0055: the same rule the daemon applies, applied one hop
        // earlier so the refusal does not depend on a reachable daemon.
        if let Err(e) = crate::core::local_repo_url::require_local_repo_url(&repo_url) {
            return CommandResult::Error(format!("spawn failed: {e}"));
        }
        let req = ManagedSpawnRequest {
            repo_url,
            git_ref,
            task,
            name_hint,
            runtime,
            inject_task,
            deliverable_id,
            force_new,
        };
        match self.client().spawn_managed_session(&req).await {
            Ok(resp) => CommandResult::ManagedSpawned {
                id: resp.id,
                name: resp.name,
                state: resp.state,
                runtime: resp.runtime,
                attach_cmd: resp.attach_cmd,
            },
            Err(e) => CommandResult::Error(format!("spawn failed: {e}")),
        }
    }

    /// `managed-adopt` — adopt an EXISTING tmux session into the managed store
    /// (#1433).
    ///
    /// Why: connect the managed surface to a pane the operator already has so it
    /// can be driven through observe/send/stop/resume. `cwd` is required because
    /// the pane's provenance is unknown to the daemon.
    /// What: validates `tmux_name`/`cwd` are non-empty, POSTs the adopt request,
    /// and returns a [`CommandResult::ManagedAdopted`]; a transport/HTTP failure
    /// (including the daemon's typed 404/409) becomes a renderable error.
    /// Test: `execute_managed_adopt_*` in `tests.rs`.
    pub(super) async fn managed_adopt(
        &self,
        tmux_name: String,
        cwd: String,
        task: Option<String>,
        runtime: Option<String>,
    ) -> CommandResult {
        if tmux_name.trim().is_empty() {
            return CommandResult::Error("adopt: tmux_name is required".to_string());
        }
        if cwd.trim().is_empty() {
            return CommandResult::Error("adopt: cwd is required".to_string());
        }
        let req = ManagedAdoptRequest {
            tmux_name,
            cwd,
            task,
            runtime,
        };
        match self.client().adopt_managed_session(&req).await {
            Ok(resp) => CommandResult::ManagedAdopted {
                id: resp.id,
                name: resp.name,
                state: resp.state,
                cwd: resp.cwd,
                runtime: resp.runtime,
                attach_cmd: resp.attach_cmd,
            },
            Err(e) => CommandResult::Error(format!("adopt failed: {e}")),
        }
    }

    /// `managed-list` — list every managed session.
    pub(super) async fn managed_list(&self) -> CommandResult {
        match self.client().list_managed_sessions().await {
            Ok(sessions) => CommandResult::ManagedSessions(
                sessions.into_iter().map(ManagedSessionView::from).collect(),
            ),
            Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
        }
    }

    /// `managed-fleet` — list managed sessions grouped by registered project.
    ///
    /// Why: the Telegram `/fleet` command (and any future TUI surface) needs a
    /// per-project breakdown; wiring the HTTP call here keeps the Telegram bot
    /// and the TUI off the raw wire type and off the grouping logic.
    /// What: calls `GET /api/v1/sessions/managed/fleet`, maps each wire group to
    /// a [`ProjectFleetView`], and wraps the result in
    /// [`CommandResult::ManagedFleet`].
    /// Test: `execute_managed_fleet_against_test_daemon` in `tests.rs`.
    pub(super) async fn managed_fleet(&self) -> CommandResult {
        match self.client().fleet_managed_sessions().await {
            Ok(groups) => {
                let fleet: Vec<ProjectFleetView> = groups
                    .into_iter()
                    .map(|g| ProjectFleetView {
                        project_name: g.project_name,
                        repo_url: g.repo_url,
                        sessions: g
                            .sessions
                            .into_iter()
                            .map(ManagedSessionView::from)
                            .collect(),
                    })
                    .collect();
                CommandResult::ManagedFleet(fleet)
            }
            Err(e) => CommandResult::Error(format!("daemon unreachable: {e}")),
        }
    }

    /// `managed-get` — fetch one managed session's record.
    ///
    /// Why: resolves a fuzzy target then fetches the authoritative per-session
    /// record (rather than reusing the list row) so the detail view reflects the
    /// freshest state.
    /// What: resolves `target`, GETs the per-session endpoint, and returns a
    /// [`ManagedSessionView`]. When `target` already parses as a UUID it is an
    /// id, so the list fetch in [`resolve_managed`] is skipped and the
    /// per-session endpoint is hit directly; friendly names/prefixes still go
    /// through the list-based resolver. Note the resulting asymmetry: the UUID
    /// fast-path bypasses the list and therefore relies on the per-session
    /// endpoint enforcing the same access control the list endpoint applies —
    /// fine for this local/single-user daemon, but a constraint to revisit
    /// before any multi-tenant deployment.
    /// Test: `execute_managed_get_unknown_errors`.
    pub(super) async fn managed_get(&self, target: &str) -> CommandResult {
        // Skip the redundant list round-trip when the caller already gave us an
        // id: a UUID can only be a canonical id, so resolving it against the
        // list would fetch the whole catalog only to look up a value we hold.
        let id = if is_session_id(target) {
            target.to_string()
        } else {
            match self.resolve_managed(target).await {
                Ok(s) => s.id,
                Err(err) => return err,
            }
        };
        match self.client().get_managed_session(&id).await {
            Ok(summary) => CommandResult::ManagedSession(ManagedSessionView::from(summary)),
            Err(e) => CommandResult::Error(format!("get failed: {e}")),
        }
    }

    /// `managed-send` — inject text into a managed session's pane.
    pub(super) async fn managed_send(&self, target: &str, text: &str) -> CommandResult {
        if text.trim().is_empty() {
            return CommandResult::Error("send: text is required".to_string());
        }
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.client().send_to_managed_session(&id, text).await {
            Ok(resp) => CommandResult::ManagedSent {
                id,
                tmux_name: resp.tmux_name,
            },
            Err(e) => CommandResult::Error(format!("send failed: {e}")),
        }
    }

    /// `managed-answer` — answer a managed session's pending decision.
    ///
    /// Why: the direct decision-answer verb. It reaches the REAL
    /// `POST .../{id}/answer` endpoint so the harness actually unblocks (the
    /// `/approve`/`/deny` family routes here too via the sessions module).
    /// What: resolves `target`, POSTs `{ answer }` to the answer endpoint, and
    /// returns a [`CommandResult::ManagedAnswered`].
    /// Test: `execute_managed_answer_unknown_errors`,
    /// `execute_managed_answer_routes_to_answer_endpoint`.
    pub(super) async fn managed_answer(&self, target: &str, answer: &str) -> CommandResult {
        if answer.trim().is_empty() {
            return CommandResult::Error("answer: text is required".to_string());
        }
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.client().answer_managed_session(&id, answer).await {
            Ok(resp) => CommandResult::ManagedAnswered {
                id,
                answer: answer.to_string(),
                tmux_name: resp.tmux_name,
            },
            Err(e) => CommandResult::Error(format!("answer failed: {e}")),
        }
    }

    /// `managed-activity` — inspect a managed session's activity.
    pub(super) async fn managed_activity(&self, target: &str) -> CommandResult {
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.client().managed_session_activity(&id).await {
            Ok(act) => CommandResult::ManagedActivity {
                id,
                state: act.state,
                summary: act.summary,
                raw_pane: act.raw_pane,
                runtime_active: act.runtime_active,
                pending_decision: act.pending_decision,
            },
            Err(e) => CommandResult::Error(format!("activity failed: {e}")),
        }
    }

    /// `managed-attach` — show a managed session's tmux attach command.
    pub(super) async fn managed_attach_cmd(&self, target: &str) -> CommandResult {
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.client().managed_session_attach_cmd(&id).await {
            Ok(resp) => CommandResult::ManagedAttachCmd {
                id,
                attach_cmd: resp.attach_cmd,
            },
            Err(e) => CommandResult::Error(format!("attach-cmd failed: {e}")),
        }
    }

    /// `managed-stop` — stop a managed session's runtime (keeps the workspace).
    pub(super) async fn managed_runtime_stop(&self, target: &str) -> CommandResult {
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.client().runtime_stop_managed_session(&id).await {
            Ok(summary) => lifecycle(summary, "stopped", None),
            Err(e) => CommandResult::Error(format!("runtime-stop failed: {e}")),
        }
    }

    /// `managed-resume` — resume a stopped managed session.
    pub(super) async fn managed_resume(&self, target: &str) -> CommandResult {
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.client().resume_managed_session(&id).await {
            Ok(summary) => lifecycle(summary, "resumed", None),
            Err(e) => CommandResult::Error(format!("resume failed: {e}")),
        }
    }

    /// Decommission one session BY CANONICAL ID — the single implementation every
    /// decommission entry point routes through (#5913).
    ///
    /// Why: three independent paths used to reach the decommission endpoint, and
    /// they drifted — one printed a hardcoded "workspace removed" (#5899) and only
    /// one repaired the base repo's worktree bookkeeping. One implementation is
    /// the repo's common-entry-point rule, and it is what stops the next behaviour
    /// added here from diverging again. Every caller now routes through here:
    ///
    /// | Caller | Reaches this via |
    /// |---|---|
    /// | `tm session decommission <id>` | [`Self::managed_decommission`] |
    /// | Telegram `/decommission`, Slack `/decommission` | [`Self::managed_decommission`] |
    /// | `tui::coordinator` `/kill`, `/decommission`, `/managed-decommission` | [`Self::managed_decommission`] |
    /// | `tui::project_ctl`'s `[d]` hotkey | `project_ctl::actions::dispatch` |
    /// | `tm session prune-idle`'s bulk sweep | `commands::prune::prune_idle` → `commands::managed::session_decommission_line` |
    ///
    /// One caller of the endpoint deliberately stays out:
    /// `commands::session_picker_prune::decommission_dead_record` POSTs
    /// `?record_only=true`, a different operation that must never remove a
    /// workspace and so must never prune. It also reads the raw body to detect a
    /// daemon too old to honour `record_only`, which a typed response hides.
    ///
    /// This covers every client-side path. The daemon's own in-process callers sit
    /// below this seam and call `SessionManager::decommission` directly, so none of
    /// them gets this repair (#5949):
    ///
    /// - `daemon::mcp_session::session_decommission`
    /// - `daemon::idle_reaper`
    /// - `SessionManager::prune_managed` (`session_manager/prune.rs`), which backs
    ///   `POST /api/v1/sessions/managed/prune` — plain `tm session prune`. This is
    ///   the largest remaining gap, not a footnote: it is the bulk path operators
    ///   actually use, so every tm-owned worktree it tears down leaves behind the
    ///   stale `.git/worktrees` entry #5913 set out to close.
    ///
    /// That third one cannot be closed at this layer even in principle. The repair
    /// needs the removed workspace's path, and `PrunedSession` — the wire response —
    /// carries one only as `retained_workspace_path`, which is `Some` exactly when
    /// the worktree was KEPT. A clean removal, the case that strands the git entry,
    /// reports `None`, so no client could reconstruct where to prune.
    ///
    /// What: POSTs the decommission endpoint via the typed [`crate::client::DaemonClient`],
    /// logs the key names of any response field this client does not model yet,
    /// repairs the base repo's worktree bookkeeping per
    /// [`worktree_prune_dir`], and returns the daemon's whole verdict. A missing
    /// session is an `Err` naming the id — the transport maps the 404, so the
    /// prune sweep's fail-closed loop keeps counting it as a failed row.
    /// Test: `decommission_managed_id_prunes_stale_worktree_bookkeeping`,
    /// `project_ctl_decommission_prunes_stale_worktree_bookkeeping`,
    /// `decommission_entry_points_agree_on_every_verdict`,
    /// `session_decommission_not_found_errors`.
    pub async fn decommission_managed_id(
        &self,
        id: &str,
    ) -> anyhow::Result<ManagedDecommissionOutcome> {
        self.decommission_managed_id_with(id, false).await
    }

    /// Resolve `target` and decommission it, honouring `--force` (#7660).
    ///
    /// Why: `tm session decommission` has to see the daemon's kept-workspace
    /// reason to exit non-zero, which [`CommandResult`] does not carry.
    /// What: the fuzzy resolution [`Self::managed_decommission`] does, then
    /// [`Self::decommission_managed_id_with`]; the outcome is returned whole.
    /// Test: `session_decommission_routed_fails_naming_why_the_workspace_was_kept`,
    /// `session_decommission_routed_force_removes_a_provisioning_only_worktree`.
    pub async fn decommission_managed_target(
        &self,
        target: &str,
        force: bool,
    ) -> anyhow::Result<ManagedDecommissionOutcome> {
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(CommandResult::Error(msg)) => anyhow::bail!("{msg}"),
            Err(other) => anyhow::bail!("could not resolve {target}: {other:?}"),
        };
        self.decommission_managed_id_with(&id, force).await
    }

    /// [`Self::decommission_managed_id`] with the `--force` flag (#7660).
    pub async fn decommission_managed_id_with(
        &self,
        id: &str,
        force: bool,
    ) -> anyhow::Result<ManagedDecommissionOutcome> {
        let outcome = self
            .client()
            .decommission_managed_session_with(id, force)
            .await?;
        if !outcome.unrecognized.is_empty() {
            tracing::debug!(
                fields = ?outcome.unrecognized.keys().collect::<Vec<_>>(),
                "decommission response carried fields this client does not model"
            );
        }
        if let Some(dir) = worktree_prune_dir(&outcome) {
            // Best-effort: a workspace that was never a git worktree makes this a
            // no-op, and a git that fails has nothing to do with the teardown that
            // already succeeded.
            let out = std::process::Command::new("git")
                .args(["-C", &dir.to_string_lossy(), "worktree", "prune"])
                .output();
            if let Err(e) = out {
                tracing::debug!(dir = %dir.display(), "git worktree prune failed: {e}");
            }
        }
        Ok(outcome)
    }

    /// `managed-decommission` — decommission a session (terminal; removes the
    /// workspace only when tm provisioned it).
    ///
    /// Why: decommission may leave the workspace on disk — an adopted or
    /// local-path workspace is never tm's to delete, a worktree holding unsaved
    /// work is refused, and a removal can fail. The daemon reports which happened;
    /// this method used to discard that verdict, so every renderer downstream had
    /// to guess and the CLI guessed "removed" every time (#5899).
    /// What: resolves the fuzzy id-or-name — the one step the bulk prune caller
    /// does not need, since it already holds canonical ids — then delegates to
    /// [`Self::decommission_managed_id`] and forwards the daemon's
    /// `workspace_removed` verdict into [`CommandResult::ManagedLifecycle`]
    /// verbatim, `None` included.
    /// Test: `executor_decommission_reports_daemon_workspace_verdict`,
    /// `decommission_entry_points_agree_on_every_verdict`,
    /// `decommission_message_honours_every_verdict`.
    pub(super) async fn managed_decommission(&self, target: &str) -> CommandResult {
        let id = match self.resolve_managed(target).await {
            Ok(s) => s.id,
            Err(err) => return err,
        };
        match self.decommission_managed_id(&id).await {
            // #5899: carry the daemon's workspace verdict instead of dropping it.
            Ok(outcome) => lifecycle(outcome.summary, "decommissioned", outcome.workspace_removed),
            Err(e) => CommandResult::Error(format!("decommission failed: {e}")),
        }
    }
}

/// Where — if anywhere — a decommission must run `git worktree prune`.
///
/// Why: the daemon has two removal branches and only one of them leaves git's
/// bookkeeping stale. An in-project worktree it did NOT own is removed with
/// `git worktree remove`, which prunes daemon-side, and the daemon reports
/// `workspace_path_was: None`. A workspace tm OWNED is removed with a plain
/// `remove_dir_all`, which git never learns about — the entry lingers in `git
/// worktree list` until something prunes it — and that is exactly the case the
/// daemon reports a path for. So `workspace_path_was.is_some()` already IS the
/// "git does not know yet" signal; this is not belt-and-braces on the branch
/// that self-cleans, and it is not a duplicate prune either.
/// What: returns the workspace's PARENT directory when the daemon both removed
/// the workspace and named it, and `None` otherwise. The parent is used because
/// the workspace itself is gone: git discovers the base repo by walking up from
/// there, which finds it for an in-project layout and finds nothing (a harmless
/// no-op) for a standalone clone.
/// Test: `worktree_prune_dir_fires_only_on_a_named_removal`,
/// `decommission_managed_id_prunes_stale_worktree_bookkeeping`.
pub(super) fn worktree_prune_dir(
    outcome: &ManagedDecommissionOutcome,
) -> Option<std::path::PathBuf> {
    if outcome.workspace_removed != Some(true) {
        return None;
    }
    let was = std::path::Path::new(outcome.workspace_path_was.as_deref()?);
    Some(was.parent().unwrap_or(was).to_path_buf())
}

/// Map a managed summary + verb to a [`CommandResult::ManagedLifecycle`].
///
/// Why: the three lifecycle verbs (stop/resume/decommission) all report the same
/// id/name/state shape; one helper keeps their result construction identical.
/// What: builds the lifecycle result from the updated summary, the verb that
/// produced it, and — for decommission only — the daemon's workspace verdict
/// (`None` for stop/resume, which never touch the workspace).
/// Test: covered by the managed lifecycle dispatch tests.
fn lifecycle(
    summary: ManagedSessionSummary,
    action: &str,
    workspace_removed: Option<bool>,
) -> CommandResult {
    CommandResult::ManagedLifecycle {
        id: summary.id,
        name: summary.name,
        state: summary.state,
        action: action.to_string(),
        workspace_removed,
    }
}

/// Whether `target` is already a canonical managed-session id (a UUID).
///
/// Why: managed ids are UUIDs while friendly names/prefixes are not; detecting
/// an id lets `managed_get` skip the list round-trip and hit the per-session
/// endpoint directly. Friendly names still need the list to resolve.
/// What: returns `true` when `target` parses as a `uuid::Uuid`.
/// Test: `is_session_id_detects_uuid_vs_name` in `tests.rs`.
pub(super) fn is_session_id(target: &str) -> bool {
    uuid::Uuid::parse_str(target).is_ok()
}