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
//! The one place a [`LiveClaims`] set is built from the session store (#7232).
//!
//! Why: gate 2's claim set was assembled independently at each call site — the
//! `prune-worktrees` route, the MCP disk survey — and each one read
//! [`SessionManager::list`] and turned every stored `workspace_path` into a
//! claim. That read has no liveness in it: the store tombstones records rather
//! than dropping them, so the claim set was a permanent ledger of every
//! directory any session ever occupied. One `deleted` record for the adopted
//! pane `tm-bobmatnyc`, holding the org-level path
//! `~/trusty-mpm-projects/bobmatnyc`, therefore blocked every worktree in every
//! project beneath it — seven repositories, "would remove 0", the same dead
//! session named on every candidate. Building the set in ONE place is what lets
//! the liveness probe be added once instead of at each site, and what stops the
//! next site from omitting it.
//!
//! What: [`SessionManager::workspace_claims`] pairs each record's
//! `workspace_path` with a [`ClaimLiveness`] derived from
//! [`SessionManager::observed_live_managed_names`] — the crate's existing tmux
//! probe, which propagates "tmux could not be observed" as `Err` rather than as
//! an empty set (#5856).
//!
//! # Fail direction
//!
//! Toward LIVE, which is toward refusing the removal. A probe that errored, a
//! record whose tmux name is not managed-shaped, and a name the probe reported
//! as present all yield [`ClaimLiveness::Live`]. Only a probe that ANSWERED and
//! did not list a managed name marks that record's claim
//! [`ClaimLiveness::SessionGone`]. The record's own `state` field is never
//! consulted: #2919 measured a live session occupying a directory whose record
//! read terminal, and that finding stands.
//!
//! Test: `dead_sessions_claims_are_discarded_and_live_ones_are_not`,
//! `an_unobservable_tmux_leaves_every_claim_live` in
//! `worktree_claim_source_tests.rs`.
use std::time::Duration;
use super::manager::SessionManager;
use super::worktree_reclaim_claim::{ClaimLiveness, LiveClaims, WorkspaceClaim};
use super::worktree_reclaim_ownership::{LinkHistory, SessionOwners};
/// Wall-clock ceiling for the tmux liveness probe this module runs (#7965).
///
/// Why: `tmux list-sessions` against a healthy server answers in single-digit
/// milliseconds, so 3 seconds is a hundredfold headroom and still finite. The
/// probe is a synchronous subprocess pair (`ensure_server_up`, then the list),
/// and before #7965 an unresponsive tmux server held the calling thread for as
/// long as it liked — on the `prune-worktrees` route that thread is a tokio
/// WORKER, i.e. one of the handful the daemon has to answer requests with.
/// What: on expiry the probe is treated exactly as an errored probe — see the
/// module's Fail direction section: every claim stays LIVE and every removal is
/// refused.
/// Test: `a_slow_tmux_probe_is_bounded_and_leaves_every_claim_live`.
pub(crate) const TMUX_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
impl SessionManager {
/// Every stored workspace claim, with each one's session probed for life.
///
/// Why: see the module doc — this is the single producer, so the liveness
/// probe cannot be forgotten by a call site.
/// What: one `tmux list-sessions` for the whole set, then one claim per
/// record that has a `workspace_path`, and one [`LiveClaims::owners`] entry
/// per record whether or not it has one (#7652). `caller` is the invoking
/// managed session id, passed straight through to [`LiveClaims::caller`].
/// Test: as the module doc, plus
/// `worktree_7652_owners_include_a_record_with_no_workspace_path` and
/// `worktree_7652_an_errored_tmux_probe_is_refused`.
pub(crate) async fn workspace_claims(&self, caller: Option<String>) -> LiveClaims {
// #5856: `Err` means tmux could not be observed, which is not an empty
// tmux. `None` here — from an error OR from #7965's timeout — is read by
// `liveness_of` as "every claim stays live".
let live_names = self.bounded_live_managed_names().await;
let records = self.list().await;
// #7652: built from EVERY record. Gate 4 judges a sentinel's owner by its
// record, and the claim list below drops a record with no workspace.
let owners = SessionOwners::observed(records.iter().map(|r| {
(
r.id.to_string(),
liveness_of(live_names.as_ref(), &r.tmux_name),
)
}))
// #7771: an agent owner file names the CLAUDE session that dispatched
// it, and condition (d) permits the caller's own trees.
.with_aliases(records.iter().filter_map(|r| {
let claude = r.claude_session_id.clone()?;
Some((claude, r.id.to_string()))
}))
.with_caller(caller.clone())
// #7771: a Claude id a restart or `/clear` replaced is named only by
// the #7617 sidecar, never by a record.
.with_history(self.link_history())
// #7771: Claude Code's own process registry proves an owner no record
// names ended, or that a live record's tmux runs a newer session.
.with_tmux(
records
.iter()
.map(|r| (r.id.to_string(), r.tmux_name.clone())),
)
.with_claude(self.claude_registry());
let claims = records
.into_iter()
.filter_map(|r| {
let path = r.workspace_path?;
Some(WorkspaceClaim::with_liveness(
r.id.to_string(),
path,
liveness_of(live_names.as_ref(), &r.tmux_name),
))
})
.collect();
LiveClaims {
claims,
caller,
owners,
}
}
/// The #7617 session-link history beside this store (#7771).
///
/// Why: the sidecar lives under the framework root, and every production
/// construction roots this store at `<framework root>/session-manager`.
/// What: [`LinkHistory::read`] on the data dir's parent when the data dir is
/// named `session-manager`; [`LinkHistory::Absent`] otherwise, which leaves
/// a superseded Claude id undeterminable, as before #7771.
/// Test: `worktree_7771_a_superseded_sessions_open_delegation_tree_is_reclaimed`,
/// `worktree_7771_an_unreadable_link_history_keeps_the_tree`.
fn link_history(&self) -> LinkHistory {
let data_dir = self.data_dir();
match data_dir.parent() {
Some(root) if data_dir.file_name() == Some("session-manager".as_ref()) => {
LinkHistory::read(root)
}
_ => LinkHistory::Absent,
}
}
/// The tmux liveness probe, off the caller's thread and bounded (#7965).
///
/// Why: [`super::SessionManager::observed_live_managed_names`] is two
/// synchronous subprocesses. Running it inline blocked whatever thread asked
/// — for the `prune-worktrees` route that is a tokio worker, and for the
/// automatic sweep it is a blocking-pool thread that then never finished.
/// Neither may depend on tmux answering.
/// What: hands the probe to the blocking pool and waits at most
/// [`TMUX_PROBE_TIMEOUT`]. `None` on ANY of error, panic or expiry — which
/// `liveness_of` reads as "every claim stays live", the refusing direction.
///
/// A `spawn_blocking` task cannot be cancelled, so an expired probe keeps its
/// blocking thread until tmux returns. That is deliberate: the thread it no
/// longer holds is the CALLER's, which is the one a request needs.
/// Test: `a_slow_tmux_probe_is_bounded_and_leaves_every_claim_live`.
async fn bounded_live_managed_names(&self) -> Option<std::collections::HashSet<String>> {
let tmux = std::sync::Arc::clone(&self.tmux);
let probe =
tokio::task::spawn_blocking(move || super::dedup::live_managed_names_of(tmux.as_ref()));
match tokio::time::timeout(TMUX_PROBE_TIMEOUT, probe).await {
Ok(Ok(Ok(names))) => Some(names),
Ok(Ok(Err(e))) => {
tracing::warn!(
"workspace claims: tmux could not be observed ({e}); claims stay live"
);
None
}
Ok(Err(e)) => {
tracing::warn!("workspace claims: the tmux probe panicked ({e}); claims stay live");
None
}
Err(_) => {
tracing::warn!(
"workspace claims: tmux did not answer within {}s; claims stay live (#7965)",
TMUX_PROBE_TIMEOUT.as_secs()
);
None
}
}
}
}
/// Classify one record's tmux name against the probe's answer (#7232).
///
/// Why: three of the four inputs must produce `Live`, and writing that as a
/// single expression at the call site invites a future edit to invert one of
/// them. The one path to `SessionGone` is explicit here instead.
/// What: `SessionGone` only when the probe answered (`Some`), the record's name
/// is one the probe would have listed had it existed
/// ([`trusty_common::session_naming::is_managed_session_name`] — the same
/// filter `observed_live_managed_names` applies), and that name is absent.
/// Test: `dead_sessions_claims_are_discarded_and_live_ones_are_not`,
/// `an_unmanaged_tmux_name_is_never_read_as_dead`.
fn liveness_of(
live_names: Option<&std::collections::HashSet<String>>,
tmux_name: &str,
) -> ClaimLiveness {
let Some(names) = live_names else {
return ClaimLiveness::Live;
};
// A name the probe filters out could never appear in `names`, so its
// absence proves nothing about the session.
if !crate::core::names::is_managed_session_name(tmux_name) {
return ClaimLiveness::Live;
}
if names.contains(tmux_name) {
ClaimLiveness::Live
} else {
ClaimLiveness::SessionGone
}
}
#[cfg(test)]
#[path = "worktree_claim_source_tests.rs"]
mod worktree_claim_source_tests;