trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! The verdict a merged-PR reclaim candidate carries, and the gate that set it
//! (#2919, #6507).
//!
//! Why: split out of `worktree_reclaim` so that file stays under the 500-SLOC
//! production cap. The two halves also answer different questions — that module
//! RUNS the gates, this one is the vocabulary their answers are expressed in,
//! and the operator surfaces (`managed_merged_prs`, the prune route) read only
//! this vocabulary.
//! Test: `worktree_reclaim_tests` — every gate's refusal test asserts on the
//! kind and the gate recorded here.

use serde::Serialize;

/// Which of [`classify`]'s gates refused a candidate (#6507).
///
/// Why: the survey used to record a refusal as prose only, and the HTTP report
/// disclosed a per-candidate reason for gate 4 alone. Every other refusal —
/// including the gate-6 squash-merge misread this issue is about — was
/// invisible at every log level and in every field of the reply, so three
/// verification passes attributed one live worktree's refusal by elimination
/// and got it wrong. Naming the gate as DATA rather than as wording keeps the
/// operator surface from having to match on a message string.
/// What: one variant per gate, plus [`Deadline`](Self::Deadline) for the
/// candidate the survey ran out of time to inspect.
/// Test: `survey_names_the_gate_that_blocked_each_candidate`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReclaimGate {
    /// Gate 0 — the operator's keep-list names this worktree (#6927).
    ///
    /// Why FIRST rather than last: DOC-73 §16.4 states the keep-list overrides
    /// every other gate, and every other gate costs `git` and `gh`
    /// subprocesses. A standing operator veto that could be reached only after
    /// paying for the answers it makes irrelevant would be both slower and
    /// weaker — the refusal would name whichever gate happened to fire first
    /// instead of naming the operator's own decision.
    /// Test: `classify_blocks_a_keep_listed_worktree`,
    /// `classify_keep_list_outranks_a_merged_clean_worktree`.
    KeepList,
    /// Gate 1 — git's own admission verdict.
    Admission,
    /// Gate 2 — a session still claims the workspace.
    Liveness,
    /// Gate 3 — trusty-mpm cannot remove this worktree.
    Removability,
    /// Gate 4 — a dispatched agent owns it.
    AgentOwnership,
    /// Gate 4b — the ownership sentinel names a managed session that is not
    /// provably finished (#7652).
    ///
    /// Why its own variant rather than [`Liveness`](Self::Liveness): gate 2
    /// permitted this candidate, so labelling the refusal gate 2 would send an
    /// operator to a claim set that said yes.
    /// Test: `worktree_7652_a_live_owners_nested_worktree_is_refused`.
    SessionOwnership,
    /// Gate 5 — the branch's pull-request state is not a merge.
    PrState,
    /// Gate 6 — the working tree holds unsaved work.
    UnsavedWork,
    /// Not a gate: the survey's classify budget expired first.
    Deadline,
}

impl ReclaimGate {
    /// The operator-facing name of this gate.
    pub(crate) fn label(self) -> &'static str {
        match self {
            Self::KeepList => "gate 0 (owner keep-list)",
            Self::Admission => "gate 1 (admission)",
            Self::Liveness => "gate 2 (liveness)",
            Self::Removability => "gate 3 (removability)",
            Self::AgentOwnership => "gate 4 (agent ownership)",
            Self::SessionOwnership => "gate 4b (session ownership)",
            Self::PrState => "gate 5 (pull-request state)",
            Self::UnsavedWork => "gate 6 (unsaved work)",
            Self::Deadline => "the survey deadline",
        }
    }
}

/// Whether a worktree may be reclaimed, or the first reason it may not (#2919).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReclaimVerdict {
    /// Every gate passed; the named pull request is the landing evidence.
    Reclaimable {
        /// The merged pull request that proves the branch's work landed.
        pr: u64,
    },
    /// Every gate passed with NO pull request: the tree's content is already
    /// on `base`, byte for byte (#7889).
    ///
    /// Why a separate variant rather than a `Reclaimable` with no number: the
    /// operator surfaces print "landing evidence is PR #N", and a zero there
    /// would read as a pull request. The evidence here is of a different kind,
    /// so it is a different variant — and a reader matching on
    /// `Reclaimable { pr }` cannot silently treat it as one.
    /// Test: `worktree_7889_classify_admits_a_landed_tree_with_no_pull_request`.
    ReclaimableLandedContent {
        /// The ref the content was judged against, e.g. `origin/main`.
        base: String,
    },
    /// Refused — `reason` names the FIRST gate that said no.
    Blocked {
        /// Which gate refused (#6507).
        gate: ReclaimGate,
        /// Operator-facing explanation of the refusal.
        reason: String,
    },
    /// Refused by gate 4 because a DISPATCHED AGENT owns the worktree (#5829).
    ///
    /// Why a separate variant rather than a `Blocked` carrying agent wording:
    /// the operator has to be TOLD about this one. Every other refusal leaves
    /// a directory the operator can see and re-run against; this one spares a
    /// tree an agent is working in right now, and a sweep that spared it
    /// silently is indistinguishable from a sweep that found nothing to do —
    /// which is what `--merged-prs --force` printed while the gate was working
    /// correctly. Matching on the reason STRING to recover the distinction
    /// would re-couple the operator surface to the wording of a refusal
    /// message, so the survey records the kind instead.
    ///
    /// #6507: the survey discloses this kind through `ReclaimSurvey::agent_owned`
    /// alone. `blocked_reasons` skips it by VARIANT rather than by gate, because
    /// gate 1's harness lock builds it too, so no candidate is named twice.
    /// Test: `survey_discloses_a_live_agents_spared_worktree`,
    /// `classify_blocks_a_live_agents_worktree`,
    /// `survey_lists_an_agent_held_candidate_in_exactly_one_place`.
    BlockedByAgent {
        /// Which gate refused (#6507) — gate 1 or gate 4.
        gate: ReclaimGate,
        /// Operator-facing explanation naming the agent being protected.
        reason: String,
    },
}

impl ReclaimVerdict {
    /// Shorthand for a refusal.
    pub(crate) fn blocked(gate: ReclaimGate, reason: impl Into<String>) -> Self {
        Self::Blocked {
            gate,
            reason: reason.into(),
        }
    }

    /// Shorthand for gate 4's agent-ownership refusal (#5829).
    pub(crate) fn blocked_by_agent(gate: ReclaimGate, reason: impl Into<String>) -> Self {
        Self::BlockedByAgent {
            gate,
            reason: reason.into(),
        }
    }

    /// True when this verdict permits deletion.
    ///
    /// #7889: two kinds of landing evidence admit — a merged pull request, and
    /// content that is already on the landing base. Both still require every
    /// gate ahead of gate 5 to have passed.
    pub(crate) fn is_reclaimable(&self) -> bool {
        matches!(
            self,
            Self::Reclaimable { .. } | Self::ReclaimableLandedContent { .. }
        )
    }

    /// This verdict as ONE operator-facing decision sentence (#8109).
    ///
    /// Why: a reclaim entry carried no reason while a blocked entry did, so a
    /// no-PR worktree reclaimed on 2026-09-16 could not be audited the way its
    /// blocked siblings could. #7885 has the same root: a pass that logs only
    /// its deletions leaves no record of what it decided about the rest.
    /// What: a rendered string rather than a `tracing` call, so a unit test
    /// asserts the wording — the shape
    /// [`RemovalAudit::line`](super::worktree_removal_audit::RemovalAudit::line)
    /// already uses. A refusal names its gate and reason; a grant names its
    /// landing evidence — the pull request, or the base holding its content.
    /// Test: `a_decision_line_names_the_gate_and_the_reason`,
    /// `a_decision_line_for_a_grant_names_its_landing_evidence`.
    pub(crate) fn decision(&self) -> String {
        match self {
            Self::Reclaimable { pr } => format!("reclaimable — landing evidence is PR #{pr}"),
            Self::ReclaimableLandedContent { base } => {
                format!("reclaimable — landing evidence is its content, already on {base}")
            }
            Self::Blocked { gate, reason } | Self::BlockedByAgent { gate, reason } => {
                format!("refused at {} — {reason}", gate.label())
            }
        }
    }
}

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