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
//! 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;