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
//! Operator-facing rendering for the merged-PR reclaim pass (#2919).
//!
//! Why: split out of `managed.rs` to keep that file under the 500-SLOC
//! production cap. It is also a genuinely separate concern: the merged-PR
//! pass's refusals have a different shape from the orphan sweep's — a worktree
//! spared here because its pull request is still OPEN is not a dirty-tree skip
//! and must not be filed as one.
//! What: [`print_merged_pr_pass`], which reads the `merged_prs` object the
//! prune-worktrees route returns and prints reclaimed paths, the byte total,
//! and every re-check refusal.
//! Test: `merged_pr_pass_prints_nothing_for_a_null_body`,
//! `merged_pr_pass_reports_reclaimed_paths_and_bytes`,
//! `merged_pr_pass_surfaces_recheck_refusals`.

/// Render the `merged_prs` half of a prune-worktrees reply (#2919).
///
/// Why: a reclaim pass that deleted directories and said nothing is
/// indistinguishable from one that did nothing — the post-mortem's eighth
/// constraint ("report real before/after numbers … not merely a claim") exists
/// because that has happened here before.
/// What: prints one line per reclaimed path to stdout, then a summary naming
/// either the candidate count (dry run) or the reclaimed count and bytes, then
/// [`diagnostic_lines`] to stderr. A `null`/absent object prints nothing —
/// that is the shape the route returns when the pass was not requested.
/// Test: `merged_pr_pass_prints_nothing_for_a_null_body`,
/// `merged_pr_pass_reports_reclaimed_paths_and_bytes`,
/// `merged_pr_pass_surfaces_recheck_refusals`,
/// `merged_pr_pass_surfaces_an_agent_owned_skip`.
pub(crate) fn print_merged_pr_pass(merged: Option<&serde_json::Value>, dry_run: bool) {
    let Some(merged) = merged.filter(|m| !m.is_null()) else {
        return;
    };
    if let Some(err) = merged.get("error").and_then(serde_json::Value::as_str) {
        eprintln!("merged-PR pass failed: {err} — nothing was reclaimed (#2919)");
        return;
    }
    let array = |key: &str| {
        merged
            .get(key)
            .and_then(serde_json::Value::as_array)
            .cloned()
            .unwrap_or_default()
    };
    let reclaimed = array("removed");
    for p in &reclaimed {
        if let Some(s) = p.as_str() {
            println!("{s}");
        }
    }
    if dry_run {
        let candidates = merged
            .get("reclaimable")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let bytes = merged
            .get("reclaimable_bytes")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        // #2919: the byte figure is a sum over the MEASURED subset, so it never
        // appears without its measured-of-total qualifier — a single 17.8 GiB
        // worktree can eat the whole measurement budget and leave the rest of
        // the reclaimable set uncounted.
        let measured = merged
            .get("reclaimable_measured")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        // #6561: the classification clause rides on the SAME line as the
        // reclaimable count, so "0 reclaimable" can never be read without
        // whether anything was actually classified.
        let unclassified = classification_clause(merged);
        println!(
            "merged-PR pass: {candidates} worktree(s) reclaimable; {bytes} byte(s) \
             across {measured} of {candidates} measured{unclassified} (#2919)"
        );
    } else {
        let bytes = merged
            .get("removed_bytes")
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0);
        let unclassified = classification_clause(merged);
        println!(
            "merged-PR pass: reclaimed {} worktree(s), {bytes} byte(s){unclassified} (#2919)",
            reclaimed.len()
        );
    }
    for line in diagnostic_lines(merged) {
        eprintln!("{line}");
    }
}

/// What the pass could NOT classify, as a clause for the summary line (#6561).
///
/// Why: a reclaimable count of zero has two causes that read identically —
/// nothing had landed, or nothing could be looked up. Live on 2026-09-02 the
/// second held for all 261 registered worktrees (the daemon inherits neither
/// `GH_TOKEN` nor `GH_CONFIG_DIR`, so `gh pr list` exited 4) and the pass
/// printed `0 worktree(s) reclaimable; 0 byte(s) across 0 of 0 measured` with
/// no hint of it. These three counts are what tell them apart, so they share
/// the reclaimable count's line rather than sitting in a stderr diagnostic the
/// operator may not have kept.
/// What: `"; could not classify: …"` naming any non-zero
/// `lookup_failed` (with the reason `gh` gave), `pr_state_unknown`, and
/// `not_inspected`. All three zero — the healthy case — returns an empty
/// string, so a clean run's line is unchanged.
/// Test: `merged_pr_pass_names_a_failed_lookup_beside_the_reclaimable_count`,
/// `merged_pr_pass_adds_no_clause_when_everything_classified`.
fn classification_clause(merged: &serde_json::Value) -> String {
    let count = |key: &str| {
        merged
            .get(key)
            .and_then(serde_json::Value::as_u64)
            .unwrap_or(0)
    };
    let (failed, unknown, skipped) = (
        count("lookup_failed"),
        count("pr_state_unknown"),
        count("not_inspected"),
    );
    let mut parts = Vec::new();
    if failed > 0 {
        let reason = merged
            .get("lookup_failure")
            .and_then(serde_json::Value::as_str)
            .unwrap_or("no reason given");
        parts.push(format!("{failed} pull-request lookup(s) FAILED ({reason})"));
    }
    if unknown > 0 {
        parts.push(format!("{unknown} pull-request state(s) indeterminate"));
    }
    if skipped > 0 {
        parts.push(format!("{skipped} not inspected before the deadline"));
    }
    if parts.is_empty() {
        return String::new();
    }
    format!("; could not classify: {}", parts.join(", "))
}

/// Every stderr diagnostic the merged-PR pass owes the operator (#2919, #5829).
///
/// Why: pure so the lines can be ASSERTED rather than merely executed. The
/// previous renderer printed straight to stderr, so its tests could only prove
/// it did not panic — which is why `spared_agent_owned` being dropped entirely
/// was invisible to them.
/// What: three families, each `"  <label>: <path>: <reason>"`. A worktree spared
/// because a dispatched agent owns it comes FIRST: it is the only one naming a
/// tree that is still being written to, and #5829 exists because the sweep
/// stayed silent about it. Then the re-check near-misses, then removals that
/// failed. An absent or non-array key contributes nothing.
/// Test: `merged_pr_pass_surfaces_an_agent_owned_skip`,
/// `merged_pr_pass_surfaces_recheck_refusals`.
fn diagnostic_lines(merged: &serde_json::Value) -> Vec<String> {
    let strings = |key: &str| -> Vec<String> {
        merged
            .get(key)
            .and_then(serde_json::Value::as_array)
            .map(|a| {
                a.iter()
                    .filter_map(|v| v.as_str().map(str::to_owned))
                    .collect()
            })
            .unwrap_or_default()
    };
    let mut out = Vec::new();
    // #5829: a live agent's tree was spared. The operator has to see this — it
    // is the difference between "nothing was reclaimable" and "something was
    // deliberately protected, and here is who for".
    for s in strings("spared_agent_owned") {
        out.push(format!("  spared — a dispatched agent owns it: {s}"));
    }
    // A candidate the survey approved but the fresh re-check refused is a
    // near-miss: the workspace changed underneath the pass.
    for s in strings("refused_at_recheck") {
        out.push(format!("  refused at re-check: {s}"));
    }
    for s in strings("removal_failed") {
        out.push(format!("  removal FAILED (still on disk): {s}"));
    }
    // #6507: every candidate the survey refused, naming the gate. The three
    // families above disclose only what happened AFTER classification, so a
    // worktree refused during classification by any gate but 4 appeared
    // nowhere — which is why one merged, clean worktree read as simply absent
    // from the report for three verification passes running.
    for s in strings("blocked_reasons") {
        out.push(format!("  {s}"));
    }
    out
}

/// Is `url` the trusty-console gateway base (`http://<console>/api/mpm`)? (#8347)
fn is_gateway_url(url: &str) -> bool {
    url.trim_end_matches('/')
        .ends_with(trusty_mpm::core::discovery::GATEWAY_PATH)
}

/// The prune-worktrees endpoint, addressed to the daemon itself (#8347).
///
/// Why: `tm` resolves its base URL gateway-first, so with the console running
/// the request went to `http://<console>/api/mpm/api/v1/…`. The console proxy
/// cuts every forwarded call at its 30 s client timeout, and a merged-PR survey
/// runs for minutes, so the proxy answered 502 for a healthy daemon.
/// What: joins `/api/v1/sessions/managed/prune-worktrees` onto `direct` when
/// `url` is the gateway base, else onto `url`, with any trailing `/` removed.
/// The result always carries exactly one `/api/` prefix.
/// Test: `prune_worktrees_url_bypasses_the_gateway_prefix`.
pub(crate) fn prune_worktrees_url(url: &str, direct: &str) -> String {
    let base = if is_gateway_url(url) { direct } else { url };
    format!(
        "{}/api/v1/sessions/managed/prune-worktrees",
        base.trim_end_matches('/')
    )
}

/// Whether `url`'s host is this machine: `localhost` or a loopback IP (#8347).
fn is_loopback_url(url: &str) -> bool {
    let Ok(parsed) = reqwest::Url::parse(url) else {
        return false;
    };
    let Some(host) = parsed.host_str() else {
        return false;
    };
    host.eq_ignore_ascii_case("localhost")
        || host
            .trim_start_matches('[')
            .trim_end_matches(']')
            .parse::<std::net::IpAddr>()
            .is_ok_and(|ip| ip.is_loopback())
}

/// The prune-worktrees endpoint this invocation may POST to (#8347).
///
/// Why: the console gateway cuts a long survey at its 30 s proxy timeout, so a
/// gateway base must be bypassed. But the bypass resolves the LOCAL daemon, and
/// a gateway on another host fronts a different daemon: redirecting there would
/// run a destructive prune against the wrong fleet, which #1737 forbids.
/// What: a non-gateway `url` is used as given. A gateway on a loopback host is
/// replaced by `direct()` (lock file, then default), which must answer
/// `GET /health` before anything is posted. A gateway on any other host is an
/// error that names the daemon URL to pass instead.
/// Test: `prune_endpoint_refuses_a_remote_gateway`,
/// `prune_endpoint_resolves_a_loopback_gateway_to_the_daemon`,
/// `prune_endpoint_errors_when_the_direct_daemon_does_not_answer`.
pub(crate) async fn prune_endpoint(
    client: &reqwest::Client,
    url: &str,
    direct: impl FnOnce() -> String,
) -> anyhow::Result<String> {
    if !is_gateway_url(url) {
        return Ok(prune_worktrees_url(url, url));
    }
    anyhow::ensure!(
        is_loopback_url(url),
        "prune-worktrees will not run through the console gateway at {url}: it cannot \
         address that host's daemon directly, and it will not retarget the local one \
         (#8347, #1737). Pass the daemon's own URL with --url or TRUSTY_MPM_URL."
    );
    let direct = direct();
    // #8347: probe the daemon before a destructive POST is addressed to it.
    let direct = trusty_mpm::core::resolve_daemon_url_probing(client, Some(&direct))
        .await
        .map_err(|e| anyhow::anyhow!("prune-worktrees bypasses the console gateway: {e}"))?;
    Ok(prune_worktrees_url(url, &direct))
}

/// Turn a prune-worktrees transport failure into the operator's error (#7884).
///
/// Why: a timed-out sweep printed reqwest's bare "error sending request for
/// url", which does not say the daemon went silent, or that removals may have
/// happened before the client gave up.
/// What: a timeout becomes an error naming the timeout, stating that nothing
/// is reported as removed, and naming the dry run that shows what remains.
/// Every other error passes through unchanged.
/// Test: `prune_worktrees_reports_a_timeout_as_an_error`.
fn prune_transport_error(e: reqwest::Error) -> anyhow::Error {
    if !e.is_timeout() {
        return e.into();
    }
    anyhow::anyhow!(
        "prune-worktrees timed out waiting for the daemon (#7884): {e}. Nothing is reported \
         as removed, but the daemon may still be sweeping — re-run without `--force` to see \
         what remains."
    )
}

/// `tm session prune --worktrees [--dry-run]` — remove orphaned per-session worktrees (#1840).
///
/// Why: sessions decommissioned before Fix 1a (#1840), or where
/// `git worktree remove` failed, leave stale `.worktrees/<session-id>/`
/// directories on disk. This verb lets operators clean them up safely — only
/// dirs without a corresponding active session are ever removed.
/// What: POSTs `/api/v1/sessions/managed/prune-worktrees` with
/// `{ dry_run, discard_dirty }`; prints one path per removed (or would-remove)
/// directory and a summary count, then prints every worktree the #4091
/// dirty-tree gate refused to touch (path + reason) to stderr so a skip is
/// never silent — a skipped worktree still holds work the operator needs to
/// deal with by hand.
///
/// #6806: the request carries `invoking_session`, so the merged-PR pass's
/// liveness gate can tell the caller's own claim on a worktree from another
/// session's. Without it every claim reads as foreign and a session blocks its
/// own trees. It is a PARAMETER rather than an inline `$TM_MANAGED_SESSION_ID`
/// read so this function performs no process-global env work — the call site in
/// `commands::session` resolves the id.
/// Test: HTTP path covered by integration test; CLI parse by
/// `cli_parses_session_prune_worktrees` and
/// `cli_prune_worktrees_discard_dirty_is_opt_in`; the #5830 timeout override by
/// `merged_pr_request_outlives_the_default_client_timeout`; the caller id by
/// `prune_worktrees_sends_the_invoking_session`.
pub(crate) async fn session_prune_worktrees(
    client: &reqwest::Client,
    url: &str,
    dry_run: bool,
    discard_dirty: bool,
    merged_prs: bool,
    invoking_session: Option<String>,
) -> anyhow::Result<()> {
    // #8347: a loopback gateway is bypassed for the local daemon; a remote one
    // is refused rather than silently retargeted (#1737).
    let endpoint =
        prune_endpoint(client, url, || trusty_mpm::core::resolve_daemon_url(None)).await?;
    let mut request = client.post(endpoint).json(&serde_json::json!({
        "dry_run": dry_run,
        "discard_dirty": discard_dirty,
        // #2919: the merged-PR reclaim pass, off unless explicitly asked for.
        "merged_prs": merged_prs,
        // #6806: the daemon occupies no pane and cannot discover who is
        // asking, so the caller names itself. Absent outside a managed
        // session, which leaves every claim foreign — the pre-#6806 gate.
        "invoking_session": invoking_session,
    }));
    if merged_prs {
        // #5830: the merged-PR survey runs synchronously in the handler and
        // takes minutes, so the client's 10s default aborted every invocation.
        request = request.timeout(trusty_mpm::client::http_client::RECLAIM_SURVEY_REQUEST_TIMEOUT);
        // The wait is long and the daemon streams nothing, so say what is
        // happening — an operator with no output cannot tell a running survey
        // from a wedged one.
        eprintln!(
            "merged-PR pass: surveying every registered worktree (classify, then byte-walk) \
             — this takes minutes on a large workspace and prints nothing until it \
             finishes (#5830)"
        );
    }
    // #7884: a timeout is a named error, never a generic transport line.
    let resp = request.send().await.map_err(prune_transport_error)?;
    let body: serde_json::Value = resp
        .error_for_status()?
        .json()
        .await
        .map_err(prune_transport_error)?;
    let paths = body
        .get("paths")
        .and_then(serde_json::Value::as_array)
        .cloned()
        .unwrap_or_default();
    let mut printed = 0usize;
    // Item 6 (#1845): non-string entries in the `paths` array are unexpected
    // (the server controls the format) but must not crash the CLI. Warn to
    // stderr so the operator is aware, rather than silently dropping the entry.
    for p in &paths {
        if let Some(s) = p.as_str() {
            println!("{s}");
            printed += 1;
        } else {
            eprintln!("warning: prune-worktrees: unexpected non-string path entry: {p}");
        }
    }
    let verb = if dry_run { "would remove" } else { "removed" };
    println!("{verb} {printed} orphaned worktree dir(s)");

    // #4091: surface every dirty-skip. These are worktrees that WOULD have
    // been reclaimed but still hold unsaved work; staying quiet about them is
    // how the sweep silently loses work.
    let skipped = body
        .get("skipped_dirty")
        .and_then(serde_json::Value::as_array)
        .cloned()
        .unwrap_or_default();
    if !skipped.is_empty() {
        eprintln!(
            "skipped {} worktree(s) holding uncommitted or unpushed work (#4091) — \
             review them by hand; `--discard-dirty` would destroy this work:",
            skipped.len()
        );
        for entry in &skipped {
            let path = entry
                .get("path")
                .and_then(serde_json::Value::as_str)
                .unwrap_or("<unknown path>");
            let reason = entry
                .get("reason")
                .and_then(serde_json::Value::as_str)
                .unwrap_or("<no reason reported>");
            eprintln!("  {path}: {reason}");
        }
    }

    // #2919: the merged-PR pass reports separately, because its refusals have a
    // different shape — a worktree can be spared here for being on an OPEN PR,
    // which is not a dirty-tree skip and must not be filed as one.
    if merged_prs {
        print_merged_pr_pass(body.get("merged_prs"), dry_run);
    }
    Ok(())
}

#[cfg(test)]
#[path = "managed_merged_prs_tests.rs"]
mod managed_merged_prs_tests;