trusty-mpm 1.7.4

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Real Claude Code session launch + session-exit poll for the metaharness
//! (#1049 WI-A, #1051 WI-B).
//!
//! Why: the re-scoped POC drives a REAL `claude` CLI session through trusty-mpm's
//! existing machinery rather than an in-process trusty-code orchestrator. This
//! module owns the launch ritual — `prepare_isolated_session` (deploy agents/skills/
//! CLAUDE.md/PM-prompt/MCP) → `SessionManager::create_with_id` (tmux host rooted
//! at the project dir) → `ClaudeCodeAdapter::spawn` (launch `claude`) → send the
//! task into the pane — and the time-bounded poll that waits for the session to
//! exit. The poll's *decision* (continue vs. time-out) is a pure function so it
//! is unit-testable WITHOUT a live `claude` or tmux; the live launch is
//! integration-level and gated `#[ignore]` (#1053 follow-up).
//! What: [`PollDecision`] + [`poll_decision`] are the pure deadline logic;
//! [`LaunchOutcome`] reports how a launch ended; [`launch_and_wait`] performs the
//! full live sequence (provision-skipped local dir) and returns the outcome plus
//! the final pane transcript for diagnostics; [`new_session_manager`] builds a
//! standalone, daemon-free [`SessionManager`].
//! Test: `launch::tests` cover [`poll_decision`] exhaustively; the live
//! `launch_and_wait` path is exercised only by the `#[ignore]` end-to-end test.

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::{Context as _, Result};
use tracing::{info, warn};

use trusty_mpm::core::session_launch::prepare_isolated_session;
use trusty_mpm::runtime::{RuntimeKind, build_adapter};
use trusty_mpm::session_manager::real_tmux::RealTmuxDriver;
use trusty_mpm::session_manager::{ManagedSessionId, ManagedTmuxDriver, SessionManager};

/// Interval between session-exit liveness probes during the poll loop.
///
/// Why: a fixed, modest cadence keeps the poll responsive without busy-spinning
/// on `tmux has-session`. Centralising it keeps the live loop and the (future)
/// tuning in one place.
/// What: 2 seconds between `session_exists` probes.
/// Test: side-effect timing; the decision logic is tested via [`poll_decision`].
pub(crate) const POLL_INTERVAL: Duration = Duration::from_secs(2);

/// How many trailing pane lines to capture for the diagnostic transcript.
///
/// Why: on failure/timeout the operator needs enough scrollback to see what the
/// session was doing, but not the entire unbounded history.
/// What: the last 400 lines of the tmux pane.
/// Test: side-effect-only (capture is exercised by the live test).
pub(crate) const TRANSCRIPT_LINES: usize = 400;

/// The pure decision a single poll tick makes given the elapsed time.
///
/// Why: separating the *decision* (keep waiting vs. give up) from the *I/O*
/// (probe tmux, sleep) makes the time-bound logic deterministic and unit-testable
/// without a clock or a live session.
/// What: `Continue` (deadline not yet reached — probe again) or `TimedOut` (the
/// elapsed time met/exceeded the budget).
/// Test: `poll_decision_*` arms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PollDecision {
    /// The timeout has not elapsed; the caller should probe again.
    Continue,
    /// The timeout budget has been reached or exceeded; stop waiting.
    TimedOut,
}

/// Decide whether a poll loop should continue or has timed out.
///
/// Why: the live loop must stop after a bounded wall-clock budget so a stuck
/// session never hangs the command forever (#1051). Expressing the rule as a
/// pure `(elapsed, timeout) -> decision` function lets us assert the boundary
/// behaviour exactly, with no sleeping in tests.
/// What: returns [`PollDecision::TimedOut`] when `elapsed >= timeout`, else
/// [`PollDecision::Continue`]. The boundary is inclusive so a zero-budget poll
/// times out immediately rather than probing once.
/// Test: `poll_decision_continues_before_deadline`,
/// `poll_decision_times_out_at_deadline`, `poll_decision_times_out_after_deadline`,
/// `poll_decision_zero_timeout_times_out_immediately`.
pub(crate) fn poll_decision(elapsed: Duration, timeout: Duration) -> PollDecision {
    if elapsed >= timeout {
        PollDecision::TimedOut
    } else {
        PollDecision::Continue
    }
}

/// How a [`launch_and_wait`] run ended.
///
/// Why: the caller maps the launch result to a verification step and a process
/// exit code; an explicit enum makes the exited-vs-timed-out distinction clear.
/// What: `Exited` (the tmux session disappeared within the budget — `claude`
/// finished) or `TimedOut` (the budget elapsed while the session was still
/// alive). Either way the caller still runs artifact verification.
/// Test: the variants are produced by the `#[ignore]` live test; the boundary
/// that selects them is tested via [`poll_decision`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LaunchOutcome {
    /// The session exited on its own within the timeout budget.
    Exited,
    /// The timeout budget elapsed while the session was still running.
    TimedOut,
}

impl LaunchOutcome {
    /// A stable status token for the structured report.
    ///
    /// Why: the JSON report needs a machine-readable token independent of the
    /// human prose.
    /// What: `"exited"` or `"timed-out"`.
    /// Test: `launch_outcome_status_tokens`.
    pub(crate) fn status(&self) -> &'static str {
        match self {
            LaunchOutcome::Exited => "exited",
            LaunchOutcome::TimedOut => "timed-out",
        }
    }
}

/// Result of a live launch: how it ended plus the final pane transcript.
///
/// Why: the caller needs both the outcome (to decide exit code) and the captured
/// transcript (to fold into the failure report, #1052 observability scope).
/// What: bundles the [`LaunchOutcome`], the tmux session name, and the captured
/// pane transcript (possibly empty if capture failed).
/// Test: produced only by the live `#[ignore]` end-to-end test.
#[derive(Debug)]
pub(crate) struct LaunchReport {
    /// How the launched session ended.
    pub(crate) outcome: LaunchOutcome,
    /// The tmux session name the harness created.
    pub(crate) tmux_name: String,
    /// The captured pane transcript (last [`TRANSCRIPT_LINES`] lines).
    pub(crate) transcript: String,
}

/// Build a standalone, daemon-free [`SessionManager`] backed by real tmux.
///
/// Why: the POC must run WITHOUT the trusty-mpm daemon, but still reuse the
/// daemon's session bookkeeping. Constructing a `SessionManager` directly over a
/// throwaway state dir gives the same `create_with_id` / `observe` machinery the
/// daemon uses, with no HTTP server in the loop.
/// What: discovers a [`RealTmuxDriver`] (erroring with an actionable message when
/// `tmux` is absent) and loads a `SessionManager` rooted at `state_dir`.
/// Test: side-effect/IO-heavy (requires `tmux`); covered by the live test.
pub(crate) async fn new_session_manager(state_dir: &Path) -> Result<SessionManager> {
    let tmux = RealTmuxDriver::discover()
        .context("tmux not found — the metaharness POC requires tmux to host the claude session")?;
    let tmux: Arc<dyn ManagedTmuxDriver> = Arc::new(tmux);
    SessionManager::new(state_dir, tmux)
        .await
        .context("failed to initialise the standalone session manager")
}

/// Launch a real `claude` session for `project_dir` and wait for it to exit.
///
/// Why: this is the WI-A launch path (#1049) plus the WI-B poll (#1051), wired
/// in-process with no daemon. It reuses the EXACT machinery a daemon launch uses
/// ([`prepare_isolated_session`], [`SessionManager::create_with_id`], [`build_adapter`] →
/// `ClaudeCodeAdapter::spawn`) so the POC exercises the production path, not a
/// parallel one.
/// What: in order — (1) `prepare_isolated_session` deploys agents/skills/CLAUDE.md/PM
/// prompt/MCP into `project_dir` (a prep failure is non-fatal: logged, the launch
/// proceeds); (2) creates a tmux session rooted at `project_dir` via
/// `create_with_id` (cwd = project, so NO git clone happens — the `--no-provision`
/// behaviour); (3) builds the Claude Code adapter over the caller's
/// `framework_root` (#8233) and `spawn`s `claude` in the
/// pane; (4) when `task` is `Some`, sends it into the pane (after a short settle
/// so `claude` is ready to receive it) — this is how the demo task reaches the
/// session, mirroring `SessionManager::send_input`; (5) polls `session_exists`
/// every [`POLL_INTERVAL`] until the session exits or `timeout` elapses; (6)
/// captures the final pane transcript and decommissions the session. Returns a
/// [`LaunchReport`].
/// Test: integration-level (live `claude` + tmux); the `#[ignore]` end-to-end
/// test in `tests/` drives it. The pure poll boundary is covered by
/// [`poll_decision`] tests.
pub(crate) async fn launch_and_wait(
    mgr: &SessionManager,
    project_dir: &Path,
    framework_root: &Path,
    task: Option<&str>,
    timeout: Duration,
) -> Result<LaunchReport> {
    // (1) Deploy the custom instructions. Non-fatal: a missing framework only
    // means the session launches without trusty-mpm agents/skills.
    // #4203: deploy through the isolated seam — the pane created at (2) is
    // rooted at `project_dir` and `ClaudeCodeAdapter::spawn` always appends
    // `--setting-sources project,local` (`runtime/claude_code.rs`), so a
    // `$HOME/.claude` deploy would never be read by the session it prepares.
    // #7685: keep the trusty-memory reachability preparation resolved, so the
    // adapter at (3) reuses it instead of probing a second time.
    let memory_reachable = match prepare_isolated_session(project_dir, None) {
        Ok(report) => {
            info!(
                agents = report.deploy.deployed.len(),
                project = %project_dir.display(),
                "meta run: prepared session (agents/skills/CLAUDE.md/MCP deployed)"
            );
            Some(report.memory_reachable)
        }
        // #4752: a compiled-prompt write failure refuses the launch; every
        // other prep failure stays non-fatal (#2149).
        Err(e) if e.is_fatal() => anyhow::bail!("{e}"),
        Err(e) => {
            warn!(
                project = %project_dir.display(),
                "meta run: session preparation failed (continuing): {e}"
            );
            None
        }
    };

    // (2) Create the tmux host rooted at the local project dir. Passing
    // `cwd = Some(project_dir)` and NO workspace/repo means the manager creates
    // the pane directly in the existing directory — no provisioner, no git clone.
    let id = ManagedSessionId::new();
    let task_label = task.unwrap_or("metaharness session").to_string();
    let record = mgr
        .create_with_id(
            id,
            task_label,
            Some(project_dir.to_path_buf()),
            None,
            None,
            None,
            None,
            RuntimeKind::ClaudeCode,
            false,
            false, // not SM-owned: meta-launch uses an existing local dir (#1511)
        )
        .await
        .context("failed to create the tmux session for the metaharness")?;
    info!(
        id = %record.id,
        tmux = %record.tmux_name,
        "meta run: tmux session created (no provision — local dir used in place)"
    );

    // (3) Launch `claude` in the pane via the SAME adapter the daemon uses.
    // `gh_env`: empty — this CLI-driven metaharness path runs outside the
    // daemon's `DaemonState`/`ProjectRegistry`, so #3025's spawn-env
    // injection (which needs the registry) is out of scope here, matching
    // the existing scope boundary around the bare-`tm` in-place relaunch
    // path (`build_inplace_resume_command`).
    // #7685: `None` only when preparation failed before resolving one.
    // #8233: `framework_root` arrives from the command entry point, which plays
    // `DaemonState`'s role on this daemon-free path. Resolving a layout here
    // instead is the #4203 defect.
    let adapter = build_adapter(
        RuntimeKind::ClaudeCode,
        mgr.tmux_driver(),
        memory_reachable,
        framework_root,
    );
    adapter
        .spawn(
            &record.tmux_name,
            project_dir,
            &record.task,
            &record.id.to_string(),
            &[],
        )
        .context("failed to spawn the claude runtime in the tmux pane")?;
    info!(tmux = %record.tmux_name, "meta run: claude runtime spawned");

    // (4) Deliver the task to the session. `claude` reads the PM prompt from the
    // `--append-system-prompt-file` set by `prepare_isolated_session`; the concrete task
    // is typed into the pane through the shared readiness-gated injection seam
    // (`SessionManager::inject_task_when_ready`, #1903/#1299) — which polls for
    // the runtime to be ready instead of the blind fixed sleep this prototype
    // used before that seam existed.
    if let Some(task) = task {
        match mgr.inject_task_when_ready(&record.id, task).await {
            Ok(true) => info!(tmux = %record.tmux_name, "meta run: demo task delivered to session"),
            Ok(false) => {
                warn!(tmux = %record.tmux_name, "meta run: runtime never became ready; demo task not delivered")
            }
            Err(e) => warn!(tmux = %record.tmux_name, "meta run: failed to inject demo task: {e}"),
        }
    }

    // (5) Poll for the session to exit, time-bounded.
    let start = Instant::now();
    let outcome = loop {
        let alive = mgr.observe(&record.id, 1).await.map(|o| o.runtime_active);
        match alive {
            Ok(false) => break LaunchOutcome::Exited,
            Ok(true) => {}
            Err(e) => warn!(tmux = %record.tmux_name, "meta run: liveness probe failed: {e}"),
        }
        if poll_decision(start.elapsed(), timeout) == PollDecision::TimedOut {
            break LaunchOutcome::TimedOut;
        }
        // TODO(M1 POC hardening follow-up): the effective timeout can overshoot
        // the requested budget by up to one POLL_INTERVAL, since we sleep a full
        // interval before re-checking the deadline. Acceptable for the POC; a
        // tighter deadline-aware sleep is a tracked follow-up (issue number to be
        // attached separately).
        tokio::time::sleep(POLL_INTERVAL).await;
    };
    info!(
        tmux = %record.tmux_name,
        outcome = outcome.status(),
        elapsed_secs = start.elapsed().as_secs(),
        "meta run: poll loop finished"
    );

    // (6) Capture the final transcript BEFORE teardown, then decommission so the
    // POC leaves no stray tmux session behind.
    let transcript = mgr
        .capture_pane(&record.id, TRANSCRIPT_LINES)
        .await
        .unwrap_or_default();
    if let Err(e) = mgr.decommission(&record.id, None).await {
        warn!(tmux = %record.tmux_name, "meta run: decommission failed (best-effort): {e}");
    }

    Ok(LaunchReport {
        outcome,
        tmux_name: record.tmux_name,
        transcript,
    })
}

/// Resolve the throwaway state directory for the standalone session store.
///
/// Why: the daemon-free `SessionManager` needs a place to persist its record
/// store; rooting it under the project keeps a run self-contained and avoids
/// colliding with a real daemon's store.
/// What: returns `<project_dir>/.trusty-mpm/meta-sessions`.
/// Test: `state_dir_is_project_scoped`.
pub(crate) fn state_dir(project_dir: &Path) -> PathBuf {
    project_dir.join(".trusty-mpm").join("meta-sessions")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn poll_decision_continues_before_deadline() {
        assert_eq!(
            poll_decision(Duration::from_secs(5), Duration::from_secs(10)),
            PollDecision::Continue
        );
    }

    #[test]
    fn poll_decision_times_out_at_deadline() {
        // The boundary is inclusive: elapsed == timeout is a time-out.
        assert_eq!(
            poll_decision(Duration::from_secs(10), Duration::from_secs(10)),
            PollDecision::TimedOut
        );
    }

    #[test]
    fn poll_decision_times_out_after_deadline() {
        assert_eq!(
            poll_decision(Duration::from_secs(11), Duration::from_secs(10)),
            PollDecision::TimedOut
        );
    }

    #[test]
    fn poll_decision_zero_timeout_times_out_immediately() {
        assert_eq!(
            poll_decision(Duration::from_secs(0), Duration::from_secs(0)),
            PollDecision::TimedOut
        );
    }

    #[test]
    fn launch_outcome_status_tokens() {
        assert_eq!(LaunchOutcome::Exited.status(), "exited");
        assert_eq!(LaunchOutcome::TimedOut.status(), "timed-out");
    }

    /// `tm meta run` hands the adapter the reachability preparation resolved
    /// (#7685).
    ///
    /// Why: this path spawns a real `claude` in tmux, so its end-to-end test is
    /// `#[ignore]`d and cannot count probes. Discarding the prepared value and
    /// passing `None` made the adapter probe trusty-memory a second time.
    /// What: reads this file's production half and asserts the prepared value is
    /// bound and appears in `build_adapter`'s argument list, with no `None` left
    /// in its place. The argument list is read as a whole so a later argument or
    /// a rustfmt rewrap cannot fail the guard for a reason it does not name.
    /// Test: itself.
    #[test]
    fn meta_run_hands_the_adapter_the_prepared_reachability() {
        const SRC: &str = include_str!("launch.rs");
        let production = SRC.split("#[cfg(test)]").next().unwrap_or(SRC);
        assert!(
            production.contains("Some(report.memory_reachable)"),
            "the prepared reachability must be kept"
        );
        // #8233 gave `build_adapter` a fourth argument and rustfmt wrapped the
        // call across lines, so read its ARGUMENT LIST rather than pinning one
        // line's exact spelling — which is what broke this guard.
        let args = production
            .split_once("build_adapter(")
            .and_then(|(_, rest)| rest.split_once(");"))
            .map(|(args, _)| args)
            .expect("the production half must call build_adapter");
        assert!(
            args.contains("memory_reachable"),
            "build_adapter must receive the prepared reachability, got: {args}"
        );
        assert!(
            !args.contains("None"),
            "a `None` here makes the adapter probe trusty-memory again, got: {args}"
        );
    }

    #[test]
    fn state_dir_is_project_scoped() {
        let dir = state_dir(Path::new("/work/proj"));
        assert_eq!(
            dir,
            Path::new("/work/proj/.trusty-mpm/meta-sessions"),
            "state dir must be project-scoped"
        );
    }
}