trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
//! HTTP handlers for the #1508 ephemeral bulk-teardown + by-state prune routes.
//!
//! Why: the managed-session API needs two new teardown endpoints — one to tear
//! down every ephemeral (test) session, and a general by-state prune that can also
//! purge the legacy 239 stale records. They live in their own file so the route
//! module (`mod.rs`) stays under the 500-SLOC production cap.
//! What: [`decommission_ephemeral_route`] (POST …/managed/decommission-ephemeral)
//! and [`prune_managed_route`] (POST …/managed/prune) handlers, plus the
//! [`PruneRequest`] body. Both delegate to the shared
//! [`crate::session_manager::SessionManager`] prune engine so HTTP, CLI, and MCP
//! share ONE implementation.
//! Test: `prune_route_*` in tests/session_manager_mvp.rs.

use std::sync::Arc;

use axum::{Json, extract::State, response::IntoResponse};
use serde::Deserialize;
use tracing::warn;

use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::state::DaemonState;
use crate::session_manager::prune::OrphanSweepOutcome;
use crate::session_manager::worktree_reclaim::ReclaimMode;
use crate::session_manager::worktree_reclaim_preview::preview_rows;
use crate::session_manager::worktree_scope::PruneScope;
use crate::session_manager::{DirtyWorktreePolicy, PruneFilter};

/// Request body for POST /api/v1/sessions/managed/prune (#1508).
///
/// Why: the by-state prune must let an operator choose WHICH records to target
/// (`ephemeral`/`stopped`/`decommissioned`/`all`), preview with `dry_run`, and —
/// only when they really mean it — also include RUNNING sessions via
/// `include_active`. Modelling it as a typed body keeps the safety defaults
/// explicit (running sessions are spared unless `include_active` is `true`).
/// What: `state` (the [`PruneFilter`] spelling), `dry_run` (default false),
/// `include_active` (default false), and `invoking_session` (#6118, default
/// absent).
/// Test: `prune_route_dry_run_reports`, `prune_route_rejects_bad_state`,
/// `prune_route_rejects_an_unparseable_invoking_session`.
#[derive(Debug, Deserialize)]
pub struct PruneRequest {
    /// Which records to target: `ephemeral` | `stopped` | `decommissioned` |
    /// `deleted` | `all` | `unresolvable`.
    pub state: String,
    /// When true, report what WOULD be pruned without mutating anything.
    #[serde(default)]
    pub dry_run: bool,
    /// When true, also tear down RUNNING (`Active`/`Provisioning`) sessions.
    /// Defaults to false — the fail-closed safety default.
    #[serde(default)]
    pub include_active: bool,
    /// The managed session id this prune was invoked FROM, which is then never
    /// a target (#6118).
    ///
    /// Why: the daemon cannot discover this — it occupies no pane — so the
    /// caller supplies it. The CLI sends `$TM_MANAGED_SESSION_ID` when the
    /// operator is inside a managed session. Absent means "the caller is not a
    /// session", which is correct for the MCP tool and for an ad-hoc shell.
    /// A value present but unparseable is REJECTED rather than dropped: silently
    /// ignoring it would run the sweep with the protection the caller asked for
    /// switched off.
    #[serde(default)]
    pub invoking_session: Option<String>,
}

/// Query string for POST …/managed/decommission-ephemeral (#6118).
///
/// Why: the verb had no preview mode, so an operator could only learn its
/// selection by running it. A query param rather than a body keeps every
/// existing bodyless caller working — axum's `Json` extractor rejects an empty
/// body, so adding a body would have broken the CLI, the MCP path, and any
/// script that POSTs nothing.
/// What: `dry_run` (default false — the pre-#6118 behaviour).
/// Test: `ephemeral_route_dry_run_reports_without_tearing_down`.
#[derive(Debug, Deserialize)]
pub struct EphemeralQuery {
    /// When true, report what WOULD be torn down and mutate nothing.
    #[serde(default)]
    pub dry_run: bool,
}

/// POST /api/v1/sessions/managed/decommission-ephemeral — bulk-tear-down ephemeral (#1508).
///
/// Why: the one-shot "clean up all my throwaway test sessions" verb for e2e
/// harnesses and operators. REAL sessions default `ephemeral=false` and are
/// unreachable, so this can never harm durable work.
/// What: delegates to
/// [`SessionManager::sweep_all_ephemeral`](crate::session_manager::SessionManager::sweep_all_ephemeral)
/// and returns `{ decommissioned: <count>, dry_run: <bool> }`.
///
/// 🔴 #6118: `dry_run` is ECHOED, and that echo is load-bearing, not cosmetic.
/// This daemon is long-lived by design — a CLI upgrade never bounces it — and
/// axum silently drops a query param the handler does not declare, so a daemon
/// predating this change accepts `?dry_run=true` and performs a REAL teardown,
/// returning 200. The status alone cannot tell that apart from an honoured
/// preview; only the echoed field can, which is why the CLI refuses to report a
/// dry run the daemon did not confirm. Same failure shape, and the same remedy,
/// as `session_picker_prune::decommission_dead_record`'s `workspace_removed`
/// check.
/// Test: `decommission_ephemeral_route_tears_down_only_ephemeral` in
/// tests/session_manager_mvp.rs;
/// `ephemeral_route_dry_run_reports_without_tearing_down` in this file;
/// the CLI's refusal by `decommission_ephemeral_refuses_a_dry_run_a_stale_daemon_ignored`.
pub async fn decommission_ephemeral_route(
    State(state): State<Arc<DaemonState>>,
    axum::extract::Query(q): axum::extract::Query<EphemeralQuery>,
) -> impl IntoResponse {
    decommission_ephemeral_core(&state, q.dry_run).await // #6288
}

/// The transport-neutral body of `POST .../managed/decommission-ephemeral`
/// (#6288), served over the socket as `mpm.managed.decommission_ephemeral`.
///
/// Test: `managed_decommission_ephemeral_parity` in
/// `daemon::rpc::managed_tests`.
pub(crate) async fn decommission_ephemeral_core(
    state: &Arc<DaemonState>,
    dry_run: bool,
) -> RouteOutcome {
    let mgr = state.session_manager().await;
    match mgr.sweep_all_ephemeral(dry_run).await {
        Ok(outcome) => RouteOutcome::ok(&serde_json::json!({
            "decommissioned": outcome.count(),
            "dry_run": dry_run,
        })),
        Err(e) => RouteOutcome::text(500, e.to_string()),
    }
}

/// Request body for POST /api/v1/sessions/managed/prune-worktrees (#1840).
///
/// Why: the orphaned-worktree sweep should default to dry-run so operators can
/// preview what will be removed before committing.
/// What: `dry_run` (default true — safe preview default) and `discard_dirty`
/// (#4091, default FALSE — the only way to reach
/// [`DirtyWorktreePolicy::ForceDiscard`], i.e. to delete a worktree that still
/// holds uncommitted or unpushed work; omitting the field, or sending `{}`,
/// always yields the safe skip-and-report behaviour).
/// Test: `prune_worktrees_request_defaults_to_skip_dirty`.
#[derive(Debug, Deserialize)]
pub struct PruneWorktreesRequest {
    /// When true (the default), report orphans without deleting anything.
    #[serde(default = "default_dry_run")]
    pub dry_run: bool,
    /// When true, ALSO remove worktrees holding uncommitted/unpushed work
    /// (#4091). Defaults to false — the fail-safe default.
    #[serde(default)]
    pub discard_dirty: bool,
    /// When true, ALSO run the merged-pull-request reclaim pass (#2919).
    ///
    /// Defaults to false. This is the ONLY way to reach
    /// [`ReclaimMode::Remove`]; no timer, hook, or daemon sweep sets it, so
    /// merged-PR reclamation is never unattended. It respects `dry_run` and is
    /// independent of `discard_dirty` — the merged-PR pass never destroys
    /// unsaved work under any combination of flags.
    #[serde(default)]
    pub merged_prs: bool,
    /// The managed session id this sweep was invoked FROM (#6806).
    ///
    /// Why: gate 2 refuses a worktree any live session claims, and a session
    /// pruning worktrees it created inside its own workspace was blocked by
    /// its OWN claim — 31 clean, pushed, merged worktrees in one observed run.
    /// The daemon cannot discover the caller (it occupies no pane), so the
    /// caller supplies it, exactly as `PruneRequest::invoking_session` does.
    /// The CLI sends `$TM_MANAGED_SESSION_ID`. Absent means "the caller is not
    /// a session", which is correct for the MCP tool and an ad-hoc shell, and
    /// leaves every claim foreign — the pre-#6806 behaviour.
    ///
    /// A value present but unparseable is REJECTED rather than dropped:
    /// silently ignoring it would run the sweep with the self-attribution the
    /// caller asked for switched off, and report every one of its own
    /// worktrees as blocked by a stranger.
    #[serde(default)]
    pub invoking_session: Option<String>,
    /// The checkout the caller is working in; when set, both passes touch only
    /// worktrees that checkout's registry lists (#8782).
    ///
    /// Absent means every registered project — the automatic sweep's and the
    /// MCP tool's scope. A value that does not resolve is REJECTED, never read
    /// as "absent", so a typo cannot widen a prune to every project.
    #[serde(default)]
    pub project_root: Option<String>,
    /// The orphan paths a `--force` run's own preview listed (#8782). When
    /// set, the orphan sweep surveys and removes nothing outside it.
    #[serde(default)]
    pub only_orphan_paths: Option<Vec<String>>,
    /// The merged-PR paths that preview listed (#8782); the same bound for the
    /// merged-PR pass.
    #[serde(default)]
    pub only_merged_paths: Option<Vec<String>>,
    /// The orphan paths that preview said `--discard-dirty` would discard
    /// unsaved work from (#8782). When set, a dirty tree it does not list is
    /// kept, even under `discard_dirty`.
    #[serde(default)]
    pub only_discard_paths: Option<Vec<String>>,
}

fn default_dry_run() -> bool {
    true
}

/// POST /api/v1/sessions/managed/prune-worktrees — remove orphaned worktree dirs (#1840).
///
/// Why: sessions decommissioned before Fix 1a (#1840), or where
/// `git worktree remove` failed, may leave stale `.worktrees/<session-id>/`
/// directories. This endpoint removes them safely, never touching a directory
/// that belongs to an active session.
/// What: collects active workspace paths and the managed workspace root, then
/// delegates to [`SessionManager::prune_orphaned_worktrees`](crate::session_manager::SessionManager::prune_orphaned_worktrees). Returns
/// `{ dry_run, paths, owner_unknown_paths, agent_owned_paths, skipped_dirty }`
/// (#3649 added the second field: worktrees conservatively skipped because
/// their ownership sentinel had no resolvable owner — never auto-deleted,
/// surfaced here for operator review; #4091 adds the third: worktrees skipped
/// because they still hold uncommitted or unpushed work, each an object with
/// `path`, `reason`, `dirty_files`, and `unpushed_commits`). Dry-run is the
/// default, and so is skipping dirty worktrees — `discard_dirty: true` is the
/// only path to a destructive removal of unsaved work.
/// Test: `prune_worktrees_route_dry_run`,
/// `prune_worktrees_request_defaults_to_skip_dirty`,
/// `prune_worktrees_route_rejects_an_unparseable_invoking_session` (#6806),
/// `prune_spares_a_stopped_records_workspace` (#4288).
pub async fn prune_worktrees_route(
    State(state): State<Arc<DaemonState>>,
    Json(req): Json<PruneWorktreesRequest>,
) -> impl IntoResponse {
    prune_worktrees_core(&state, req).await // #6288
}

/// The transport-neutral body of `POST .../managed/prune-worktrees` (#6288),
/// served over the socket as `mpm.managed.prune_worktrees`.
///
/// Test: `managed_prune_worktrees_parity_defaults_to_a_preview` in
/// `daemon::rpc::managed_tests`.
pub(crate) async fn prune_worktrees_core(
    state: &Arc<DaemonState>,
    req: PruneWorktreesRequest,
) -> RouteOutcome {
    // #6806: reject a present-but-unparseable caller id before any work, for
    // the reason #6118 gives — dropping it would run the sweep with the
    // self-attribution the caller asked for silently switched off, and report
    // every one of the caller's own worktrees as blocked by a stranger.
    if let Some(raw) = req.invoking_session.as_deref()
        && raw.parse::<uuid::Uuid>().is_err()
    {
        let msg = "invalid invoking_session: not a UUID — refusing the prune rather than \
                   running it without the requested caller attribution (#6806)"
            .to_string();
        warn!("prune-worktrees route: {msg}");
        return RouteOutcome::text(400, msg);
    }
    // #8782: resolved before any work, and a project root that does not
    // resolve is a 400 rather than a daemon-global sweep.
    let scope = match PruneScope::from_request(
        req.project_root.as_deref(),
        req.only_orphan_paths.as_deref(),
        req.only_merged_paths.as_deref(),
        req.only_discard_paths.as_deref(),
    ) {
        Ok(scope) => scope,
        Err(msg) => {
            warn!("prune-worktrees route: {msg}");
            return RouteOutcome::text(400, msg);
        }
    };
    let mgr = state.session_manager().await;
    let records = mgr.list().await;
    // #4288 (item 4 of #4207): DELIBERATELY UNFILTERED. Do NOT "tidy this up"
    // by adding `.filter(|r| r.state == ManagedSessionState::Active)` — every
    // record with a `workspace_path` belongs in this set, whatever its state.
    //
    // Why: a `SessionRecord`'s state is bookkeeping, NOT a liveness signal. It
    // is written by reconcile/stop/hook paths that can miss, race, or be
    // skipped entirely, so live sessions are routinely observed carrying a
    // terminal state. Measured on this repo 2026-07-28: session
    // `2eb72dca-de08-481b-8dfa-22ab7f81b1f9` was RUNNING (tmux pane `%981`,
    // `pane_current_path` inside its own worktree) while `sessions.json`
    // recorded it as `state: "stopped"`, holding 12 modified tracked files,
    // 31 untracked files, and 1 unpushed commit.
    //
    // What narrowing this set costs, measured rather than assumed (#4288):
    // such a worktree stops being spared and becomes an orphan CANDIDATE, so
    // this route's DRY-RUN preview reports a live worktree as reclaimable —
    // a false report an operator may then act on. It does NOT by itself delete
    // anything: the real (`dry_run: false`) path re-reads the store for
    // `prune_orphaned_worktrees`'s Phase 2 `fresh_active` snapshot, itself
    // deliberately unfiltered, and that second read still spares the candidate
    // immediately before deletion. The two reads are defense-in-depth; data
    // loss needs BOTH narrowed. Do not read that as permission to narrow one
    // "because the other covers it" — that argument applied twice is exactly
    // how a pair of independent boundaries collapses into none.
    //
    // Pinned by `prune_spares_a_stopped_records_workspace` in this file's test
    // module (which fails on the dry-run preview alone), and end-to-end on the
    // automatic GC path by `reap_spares_a_stopped_records_workspace` in
    // `session_manager::reap_orphaned_worktrees_tests`.
    let in_use_workspace_paths: Vec<std::path::PathBuf> = records
        .iter()
        .filter_map(|r| r.workspace_path.clone())
        .collect();
    let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
    let repos_root = crate::core::trusty_tools_config::workspace_root(&config);
    // Item 7 (#1845): propagate scan failures as HTTP 500 instead of silently
    // returning an empty path list (which could mask the underlying error).
    // #4091: the ONLY place `ForceDiscard` can be requested, and only via an
    // explicit `discard_dirty: true` in the request body.
    let policy = if req.discard_dirty {
        DirtyWorktreePolicy::ForceDiscard
    } else {
        DirtyWorktreePolicy::Skip
    };
    // #7357: the route is the entry point, so it resolves the adopted anchors
    // under the daemon's own framework root — the root `project_register`
    // wrote them to. Every scan below takes them as a parameter.
    let adopted = crate::project::adopted_anchors_under(state.framework_root());
    // #8782: a checkout the scan never reaches finds nothing; say so.
    let project_known = scope.orphan.project_known(&repos_root, &adopted);
    match mgr
        .prune_orphaned_worktrees_in(
            &repos_root,
            &in_use_workspace_paths,
            req.dry_run,
            policy,
            &adopted,
            &scope.orphan,
        )
        .await
    {
        Ok(outcome) => {
            let paths: Vec<String> = outcome
                .removed
                .iter()
                .map(|p| p.to_string_lossy().into_owned())
                .collect();
            let owner_unknown_paths: Vec<String> = outcome
                .owner_unknown
                .iter()
                .map(|p| p.to_string_lossy().into_owned())
                .collect();
            // #4311: attributed to a dispatched agent — owned, and reclaimed by
            // that agent's exit rather than this sweep. Reported because these
            // arrived in `owner_unknown_paths` before they carried a
            // sentinel, and losing them from the response would trade a
            // "cannot reclaim" line for silence.
            let agent_owned_paths: Vec<String> = outcome
                .agent_owned
                .iter()
                .map(|p| p.to_string_lossy().into_owned())
                .collect();
            // #2919: the merged-PR pass runs only on the explicit opt-in, and
            // only ever in Report mode under `dry_run`. It re-reads the
            // in-use set itself via `in_use_workspace_paths` above, which was
            // captured moments ago from the same unfiltered store read.
            let merged = if req.merged_prs {
                let mode = if req.dry_run {
                    ReclaimMode::Report
                } else {
                    ReclaimMode::Remove
                };
                // #7504: through the ONE entry point that binds the production
                // probes. The assembly used to live inline here; a second copy
                // for the daemon's automatic sweep is exactly the divergence the
                // common-entry-point rule forbids, so both callers now ask
                // `merged_pr_reclaim::reclaim`. The only thing this route still
                // decides is Report-vs-Remove, from its own `dry_run`.
                // #6806: the caller's own id, so gate 2 can tell its claim from
                // a stranger's. Validated ABOVE, before any survey work.
                match crate::daemon::services::merged_pr_reclaim::reclaim(
                    state,
                    &repos_root,
                    mode,
                    req.invoking_session.clone(),
                    scope.merged.clone(),
                )
                .await
                {
                    Ok(o) => {
                        // #8782: every path the pass would remove, and every path
                        // it keeps because its PR state is unknown, each with its
                        // project and reason.
                        let preview = preview_rows(&o.survey);
                        serde_json::json!({
                            "reclaimable_paths": preview.reclaim,
                            "unknown_paths": preview.unknown,
                            "removed": o.removed,
                            "removed_bytes": o.removed_bytes,
                            "refused_at_recheck": o.refused_at_recheck,
                            "removal_failed": o.removal_failed,
                            // #8782: git failed after deleting content.
                            "partially_removed": o.partially_removed,
                            // #5829: the agent-ownership gate has spared these
                            // since #5661, but reported nothing — so a run that
                            // protected a live agent's tree was indistinguishable
                            // from one that found nothing to reclaim.
                            "spared_agent_owned": o.survey.agent_owned,
                            // #6507: one line per non-reclaimable candidate naming
                            // the GATE that refused it. Without it a plain
                            // `Blocked` — every gate but 4 — appears in no field of
                            // this reply and in no log line, which is how a merged,
                            // clean worktree sat unreclaimed with nothing saying
                            // why.
                            "blocked_reasons": o.survey.blocked_reasons,
                            "reclaimable": o.survey.reclaimable,
                            "reclaimable_measured": o.survey.reclaimable_measured,
                            "reclaimable_bytes": o.survey.reclaimable_bytes,
                            "total_bytes": o.survey.total_bytes,
                            "pr_state_unknown": o.survey.pr_state_unknown,
                            // #6561: without these three, `0 reclaimable` and
                            // "the `gh` call failed for every worktree" are the
                            // same reply. The renderer prints them beside the
                            // reclaimable count.
                            "not_inspected": o.survey.not_inspected,
                            "lookup_failed": o.survey.lookup_failed,
                            "lookup_failure": o.survey.lookup_failure,
                        })
                    }
                    Err(e) => {
                        // A panicked pass reclaimed nothing; say so rather than
                        // omitting the key, which would read as "not requested".
                        warn!("prune-worktrees route: merged-PR pass panicked: {e}");
                        serde_json::json!({ "error": e })
                    }
                }
            } else {
                serde_json::Value::Null
            };
            RouteOutcome::ok(&serde_json::json!({
                "dry_run": req.dry_run,
                // #8782: echoed so a client can refuse a daemon that predates
                // the scope and would have run daemon-global.
                "scope": scope.echo(project_known),
                "orphan_rows": orphan_rows(&outcome),
                "paths": paths,
                "owner_unknown_paths": owner_unknown_paths,
                "agent_owned_paths": agent_owned_paths,
                "skipped_dirty": outcome.skipped_dirty,
                // #8782: git failed after deleting content.
                "partially_removed": outcome.partially_removed,
                "merged_prs": merged,
            }))
        }
        Err(e) => {
            warn!("prune-worktrees route: orphan scan failed: {e}");
            RouteOutcome::text(500, format!("orphan worktree scan failed: {e}"))
        }
    }
}

/// The orphan pass's paths as preview rows: path, owning checkout, reason (#8782).
///
/// What: one row per `removed` path. The project is the registry root the
/// scan carried, so this runs no `git`. A path whose unsaved work
/// `--discard-dirty` destroys says so, with what the dirty check found, and
/// carries `discards_unsaved_work: true` — the CLI's discard allowlist.
/// Test: `orphan_rows_name_the_owning_checkout`,
/// `the_orphan_preview_names_unsaved_work_that_discard_dirty_destroys`.
pub(crate) fn orphan_rows(outcome: &OrphanSweepOutcome) -> Vec<serde_json::Value> {
    outcome
        .removed
        .iter()
        .map(|path| {
            let project = outcome.registry_roots.get(path).map_or_else(
                || "(owning checkout unresolved)".to_string(),
                |r| r.to_string_lossy().into_owned(),
            );
            let dirt = outcome.discarded_dirty.iter().find(|d| d.path == *path);
            let reason = match dirt {
                Some(dirt) => format!(
                    "orphaned — its owning session has ended; it holds unsaved work ({}) — \
                     discarded (--discard-dirty)",
                    dirt.reason
                ),
                None => {
                    "orphaned — its owning session has ended and it holds no unsaved work".into()
                }
            };
            serde_json::json!({
                "path": path.to_string_lossy(),
                "project": project,
                "reason": reason,
                "discards_unsaved_work": dirt.is_some(),
            })
        })
        .collect()
}

/// POST /api/v1/sessions/managed/prune — by-state prune + compaction (#1508).
///
/// Why: the general teardown tool, exposed so the legacy 239 stale records can be
/// purged over HTTP with the SAME engine that cleans up ephemeral sessions.
/// What: parses the `state` filter (400 on an unknown value), then delegates to
/// [`SessionManager::prune_managed`](crate::session_manager::SessionManager::prune_managed)
/// with the request's `dry_run`/`include_active`. Returns the
/// [`crate::session_manager::PruneOutcome`] JSON (its `dry_run`, `filter`, and
/// per-session `action` list).
///
/// #6118: a present-but-unparseable `invoking_session` is a 400. Dropping it
/// would run the sweep with the self-exclusion the caller explicitly asked for
/// silently disabled — the failure arm has to be louder than the success arm it
/// protects.
/// Test: `prune_route_dry_run_reports`, `prune_route_rejects_bad_state` in
/// tests/session_manager_mvp.rs;
/// `prune_route_rejects_an_unparseable_invoking_session` in this file.
pub async fn prune_managed_route(
    State(state): State<Arc<DaemonState>>,
    Json(req): Json<PruneRequest>,
) -> impl IntoResponse {
    prune_managed_core(&state, req).await // #6288
}

/// The transport-neutral body of `POST .../managed/prune` (#6288), served over
/// the socket as `mpm.managed.prune`.
///
/// Test: `managed_prune_parity`,
/// `managed_prune_rejects_an_unparseable_invoking_session` in
/// `daemon::rpc::managed_tests`.
pub(crate) async fn prune_managed_core(
    state: &Arc<DaemonState>,
    req: PruneRequest,
) -> RouteOutcome {
    let filter = match PruneFilter::parse(&req.state) {
        Ok(f) => f,
        Err(e) => {
            warn!("prune route: {e}");
            return RouteOutcome::text(400, e);
        }
    };
    let invoker = match req
        .invoking_session
        .as_deref()
        .map(str::parse::<uuid::Uuid>)
    {
        None => None,
        Some(Ok(u)) => Some(crate::session_manager::record::ManagedSessionId::from(u)),
        Some(Err(_)) => {
            let msg = "invalid invoking_session: not a UUID — refusing the prune rather than \
                       running it without the requested self-exclusion (#6118)"
                .to_string();
            warn!("prune route: {msg}");
            return RouteOutcome::text(400, msg);
        }
    };
    let mgr = state.session_manager().await;
    // `caller: None` — the HTTP route is an operator surface, never a session
    // acting on its own behalf; the #3649 owner gate does not apply here.
    match mgr
        .prune_managed(filter, req.dry_run, req.include_active, None, invoker)
        .await
    {
        Ok(outcome) => RouteOutcome::ok(&outcome),
        Err(e) => RouteOutcome::text(500, e.to_string()),
    }
}

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

    /// #4091: a request body that says nothing about dirty worktrees must
    /// deserialize to the SAFE behaviour. An omitted field is the overwhelming
    /// majority of real calls (the CLI's non-`--discard-dirty` path, and any
    /// third-party caller), so the serde default is the actual guard here.
    #[test]
    fn prune_worktrees_request_defaults_to_skip_dirty() {
        let req: PruneWorktreesRequest = serde_json::from_str("{}").expect("empty body parses");
        assert!(req.dry_run, "an unspecified prune must preview, not delete");
        assert!(
            !req.discard_dirty,
            "an unspecified prune must NEVER discard uncommitted work"
        );

        // #2919: the same guarantee for the merged-PR pass. An omitted field —
        // every pre-#2919 caller — must never enable a GitHub-state-driven
        // deletion.
        assert!(
            !req.merged_prs,
            "an unspecified prune must NEVER run the merged-PR reclaim pass"
        );

        let explicit: PruneWorktreesRequest =
            serde_json::from_str(r#"{"dry_run":false,"discard_dirty":true,"merged_prs":true}"#)
                .expect("explicit body parses");
        assert!(explicit.discard_dirty, "the opt-in must round-trip");
        assert!(explicit.merged_prs, "the #2919 opt-in must round-trip");
    }

    /// 🔴 #6118 FAIL-OPEN GUARD: an unparseable `invoking_session` REFUSES the
    /// prune rather than running it without the self-exclusion.
    ///
    /// Why: the field's whole purpose is keeping the sweep off the caller's own
    /// session. Dropping a malformed value and proceeding would run exactly the
    /// sweep the caller asked to be protected from — the measured `--state all
    /// --include-active` case that selected the operator's own running session.
    /// A 400 costs one retry; the open arm costs the terminal the command was
    /// typed in.
    /// What: posts a well-formed body whose `invoking_session` is not a UUID and
    /// asserts 400. The CONTROL sends the same body with the field ABSENT and
    /// asserts it is accepted, so the test cannot pass merely because the route
    /// rejects everything.
    /// #6806: the same refusal for the WORKTREE sweep's caller id.
    ///
    /// Why: dropping a malformed value here leaves every claim foreign, so the
    /// caller's own worktrees come back blocked by a stranger — the shipped
    /// defect, silently reinstated. The failure arm has to be louder than the
    /// success arm it protects.
    /// What: posts a body whose `invoking_session` is not a UUID and asserts
    /// 400. The CONTROL sends the field ABSENT and asserts acceptance, so the
    /// test cannot pass merely because the route rejects everything.
    /// Test: this function IS the test.
    #[tokio::test]
    async fn prune_worktrees_route_rejects_an_unparseable_invoking_session() {
        let root = crate::test_support::hermetic_temp_dir();
        let state =
            Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);

        let bad = prune_worktrees_route(
            State(state.clone()),
            Json(PruneWorktreesRequest {
                dry_run: true,
                discard_dirty: false,
                merged_prs: false,
                invoking_session: Some("not-a-uuid".into()),
                project_root: None,
                only_orphan_paths: None,
                only_merged_paths: None,
                only_discard_paths: None,
            }),
        )
        .await
        .into_response();
        assert_eq!(
            bad.status(),
            StatusCode::BAD_REQUEST,
            "a malformed caller id must stop the sweep, not be dropped"
        );

        // CONTROL: absent is legitimate (an ad-hoc shell, the MCP tool).
        let ok = prune_worktrees_route(
            State(state),
            Json(PruneWorktreesRequest {
                dry_run: true,
                discard_dirty: false,
                merged_prs: false,
                invoking_session: None,
                project_root: None,
                only_orphan_paths: None,
                only_merged_paths: None,
                only_discard_paths: None,
            }),
        )
        .await
        .into_response();
        assert_ne!(
            ok.status(),
            StatusCode::BAD_REQUEST,
            "an absent caller id is legitimate and must be accepted"
        );
    }

    /// 🔴 #8782: a `project_root` that does not resolve is a 400, never a
    /// daemon-global sweep.
    ///
    /// Fails when the route reads the unresolvable root as "no scope"
    /// (`WorktreeScope::all()` in the `Err` arm): it then answers 200 over
    /// every project. A dry run, so even that arm deletes nothing.
    #[tokio::test]
    async fn prune_worktrees_route_rejects_an_unresolvable_project_root() {
        let root = crate::test_support::hermetic_temp_dir();
        let state =
            Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
        let resp = prune_worktrees_route(
            State(state),
            Json(PruneWorktreesRequest {
                dry_run: true,
                discard_dirty: false,
                merged_prs: false,
                invoking_session: None,
                project_root: Some("/nonexistent/8782/project".into()),
                only_orphan_paths: None,
                only_merged_paths: None,
                only_discard_paths: None,
            }),
        )
        .await
        .into_response();
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    /// Test: this function IS the test.
    #[tokio::test]
    async fn prune_route_rejects_an_unparseable_invoking_session() {
        let root = crate::test_support::hermetic_temp_dir();
        let state =
            Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);

        let bad = prune_managed_route(
            State(state.clone()),
            Json(PruneRequest {
                state: "unresolvable".into(),
                dry_run: true,
                include_active: false,
                invoking_session: Some("not-a-uuid".into()),
            }),
        )
        .await
        .into_response();
        assert_eq!(
            bad.status(),
            StatusCode::BAD_REQUEST,
            "a malformed self-exclusion must stop the prune, not be dropped"
        );

        // CONTROL: absent is legitimate (an ad-hoc shell, the MCP tool).
        let ok = prune_managed_route(
            State(state),
            Json(PruneRequest {
                state: "unresolvable".into(),
                dry_run: true,
                include_active: false,
                invoking_session: None,
            }),
        )
        .await
        .into_response();
        assert_eq!(ok.status(), StatusCode::OK);
    }

    /// #6118: the ephemeral route's `dry_run` is honoured AND echoed.
    ///
    /// Why: the echo is what lets the CLI tell a real preview from an older
    /// daemon that dropped the param and swept for real. A route that honours
    /// the flag but does not report it is indistinguishable from one that
    /// ignored it.
    /// What: posts `?dry_run=true` against an empty store and asserts both keys
    /// come back, with `dry_run: true`.
    /// Test: this function IS the test.
    #[tokio::test]
    async fn ephemeral_route_dry_run_reports_without_tearing_down() {
        let root = crate::test_support::hermetic_temp_dir();
        let state =
            Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
        let body = body_json(
            decommission_ephemeral_route(
                State(state),
                axum::extract::Query(EphemeralQuery { dry_run: true }),
            )
            .await
            .into_response(),
        )
        .await;
        assert_eq!(
            body.get("dry_run").and_then(serde_json::Value::as_bool),
            Some(true),
            "the route must echo the honoured preview flag: {body}"
        );
        assert_eq!(
            body.get("decommissioned")
                .and_then(serde_json::Value::as_u64),
            Some(0),
            "an empty store sweeps nothing: {body}"
        );
    }

    /// Read a route response's JSON body.
    async fn body_json(resp: axum::response::Response) -> serde_json::Value {
        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
            .await
            .expect("route body must be readable");
        serde_json::from_slice(&bytes).expect("route body must be JSON")
    }

    /// #4288: a `Stopped` record's workspace is spared by the orphan sweep —
    /// the active-set construction above must stay UNFILTERED by record state.
    ///
    /// Why: session state is not a liveness signal (see the comment at the
    /// construction site — a live session was measured recorded as `stopped`
    /// while holding 12 modified files and an unpushed commit). The obvious
    /// tidy-up, `.filter(|r| r.state == Active)`, would drop that record's
    /// workspace out of the spared set and hand a LIVE worktree to the
    /// reclaim path. This test exists to turn that tidy-up red.
    ///
    /// Non-vacuity: the fixture is stamped with an aged, well-formed ownership
    /// sentinel naming a never-registered owner, so EVERY downstream gate
    /// (#3649 ownership, the `git worktree list` cross-check, the #4091 dirty
    /// gate) already votes "reclaim". The CONTROL below asserts exactly that
    /// against an EMPTY active set — so if a future change makes the fixture
    /// unreclaimable for some unrelated reason, the control fails loudly
    /// instead of letting the real assertions pass for the wrong reason.
    /// Membership in `in_use_workspace_paths` is therefore the ONLY thing
    /// under test here.
    /// Test: this function IS the test.
    ///
    /// `await_holding_lock` is the point, not an oversight: the route reads the
    /// workspace root from the process environment, so the crate-wide
    /// `env_test_lock` must span the awaited route calls or a sibling env test
    /// can clobber the override mid-request. Each `#[tokio::test]` gets its own
    /// thread and current-thread runtime, so a blocking sync guard serialises
    /// those threads without any chance of deadlocking the executor.
    #[allow(clippy::await_holding_lock)]
    #[serial_test::serial]
    #[tokio::test]
    async fn prune_spares_a_stopped_records_workspace() {
        use crate::session_manager::record::ManagedSessionId;
        use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
        use crate::session_manager::{ManagedSessionState, SessionRecord};

        let fx = GitWorktreeFixture::new();
        let wt = fx.add_worktree("stopped-but-live");
        GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
        let wt_str = wt.to_string_lossy().into_owned();

        let root = crate::test_support::hermetic_temp_dir();
        let state =
            Arc::new(DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await);
        let mgr = state.session_manager().await;

        // CONTROL: with an EMPTY active set the fixture IS reclaimable. Dry-run
        // so nothing is deleted before the real assertions run.
        let control = mgr
            .prune_orphaned_worktrees(&fx.repos_root, &[], true, DirtyWorktreePolicy::Skip, &[])
            .await
            .expect("control sweep must not error");
        assert!(
            control.removed.contains(&wt),
            "CONTROL: {wt_str} must be reclaimable with an empty active set, \
             otherwise this test proves nothing; removed={:?} owner_unknown={:?} \
             skipped_dirty={:?}",
            control.removed,
            control.owner_unknown,
            control.skipped_dirty
        );

        // Register the worktree to a record and drive it to `Stopped` — the
        // exact shape observed on a session that was actually still running.
        let record = mgr
            .create_with_id(
                ManagedSessionId::new(),
                "pinned by #4288".into(),
                Some(wt.clone()),
                None,
                Some(wt.clone()),
                None,
                None,
                crate::runtime::RuntimeKind::default(),
                false,
                false,
            )
            .await
            .expect("seed session record");
        let stopped = SessionRecord {
            state: ManagedSessionState::Stopped,
            ..record
        };
        mgr.store
            .write()
            .await
            .upsert(stopped)
            .await
            .expect("persist the Stopped record");

        // Point the route's repos-root resolver at the fixture.
        //
        // This test issues a DRY-RUN sweep only — it must never run a deleting
        // sweep whose target root comes from a process-global env var. If a
        // concurrent test restored that var mid-call, a destructive sweep would
        // target the operator's real `~/trusty-mpm-projects`, and the
        // three-read defense would NOT save it: the caller set and the Phase 2
        // re-read both draw from this empty isolated store, so real worktrees
        // are absent from both. The deleting half also bought zero mutation
        // coverage (its assertions pass under the M1 mutation), so it is gone.
        //
        // BOTH guards are required and they do not interoperate:
        // `env_test_lock` serialises the env-precedence tests, while
        // `#[serial_test::serial]` serialises `connectors::tm_tests`, which
        // mutates this same var in this same binary under `serial` alone.
        let _env = crate::core::trusty_tools_config::env_test_lock();
        // SAFETY: guarded by env_test_lock; removed below before the asserts.
        unsafe {
            std::env::set_var(
                crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV,
                &fx.repos_root,
            )
        };

        let preview = body_json(
            prune_worktrees_route(
                State(state.clone()),
                Json(PruneWorktreesRequest {
                    dry_run: true,
                    discard_dirty: false,
                    merged_prs: false,
                    invoking_session: None,
                    project_root: None,
                    only_orphan_paths: None,
                    only_merged_paths: None,
                    only_discard_paths: None,
                }),
            )
            .await
            .into_response(),
        )
        .await;
        // SAFETY: guarded by env_test_lock, still held.
        unsafe { std::env::remove_var(crate::core::trusty_tools_config::WORKSPACE_ROOT_ENV) };

        let strings = |v: &serde_json::Value, key: &str| -> Vec<String> {
            v.get(key)
                .and_then(|x| x.as_array())
                .unwrap_or_else(|| panic!("route body must carry `{key}`: {v}"))
                .iter()
                .map(|s| {
                    s.as_str()
                        .unwrap_or_else(|| panic!("`{key}` must hold strings: {v}"))
                        .to_owned()
                })
                .collect()
        };

        // Dry-run `paths` IS the reclaimable set (see `prune_orphaned_worktrees`).
        let preview_paths = strings(&preview, "paths");
        assert!(
            !preview_paths.contains(&wt_str),
            "a Stopped record's workspace must NOT be reclaimable; \
             {wt_str} appeared in the dry-run set {preview_paths:?}"
        );
        let preview_unknown = strings(&preview, "owner_unknown_paths");
        assert!(
            !preview_unknown.contains(&wt_str),
            "a spared workspace is never even a candidate, so it must not be \
             reported as owner-unknown either; got {preview_unknown:?}"
        );
    }
}