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
//! Is a live process standing in this directory? (#4311, DOC-66 §1.3)
//!
//! Why: every gate guarding a worktree removal today asks a REGISTRY — the
//! session store's `workspace_path`s, and the delegation tracker's registered
//! trees (`agent_worktree_reap::paths_in_use`). A process nothing registered is
//! invisible to all of them. On 2026-08-15 a `trusty-memory serve --foreground`
//! started by hand inside an agent worktree ran for a day; session_manager and
//! delegation tracking both had no record of it, so `git worktree remove
//! --force` would have deleted the directory out from under a running process
//! holding open descriptors in it. That gap was dormant while the reaper reaped
//! nothing; making agent worktrees attributable is what wakes it up, so the
//! check lands in the same change.
//!
//! What: [`process_holding`] asks the OS, not a registry — one `lsof` call
//! listing every process's current working directory, prefix-matched against
//! the candidate.
//!
//! # Fail direction
//!
//! Toward IN USE. `lsof` missing, unspawnable, stalled past its ceiling (#7540),
//! or returning output this cannot parse all resolve to "something may be in there", never to "free" — the
//! [ADR-0045](../../../../docs/adr/0045-distinguish-absent-from-undeterminable-on-destructive-paths.md)
//! rule that an empty observation on a destructive path is UNDETERMINABLE and
//! not ABSENT. On a machine with no `lsof` this refuses every reap and says so,
//! which is the correct trade for a `git worktree remove --force`.
//!
//! # Stated gap: this misses the agent process itself
//!
//! State the limit at its real width, not its flattering one. This catches a
//! process that `cd`-ed into the tree. It does NOT reliably catch the agent the
//! worktree was granted to: measured on this machine, every running `claude`
//! process has its cwd at a project root and none inside `.claude/worktrees/`.
//! Coverage of a live agent is therefore incidental — it exists only where that
//! agent happened to `cd`. What this gate reliably catches is the class of
//! long-running command started BY an agent from inside its tree, which is the
//! 2026-08-15 `trusty-memory serve --foreground` shape.
//!
//! The agent process itself is covered by the registry instead:
//! `agent_worktree_reap::paths_in_use` reads every non-terminal delegation in
//! every session. The two are complementary, and neither alone is sufficient.
//!
//! `lsof +D <path>` would additionally catch a process holding a descriptor
//! inside the tree with its cwd elsewhere, at a cost that scales with the tree —
//! an agent worktree here carries a 1.5 GiB `target/`. Not paid for on every
//! reap, and not the fix for the gap above.
//! Test: the `#[cfg(test)]` suite in `worktree_liveness_tests.rs`.
use Path;
use Command;
use Duration;
// #7540: the crate's kill-on-timeout runner; `git_ceiling` wraps it for git only.
use crate;
/// Name a live process whose working directory is inside `path`, or explain why
/// the question could not be answered.
///
/// Why: the one gate in the removal chain that consults the operating system
/// rather than a record trusty-mpm wrote itself, so a process nobody registered
/// still protects its directory.
/// What: `Some(reason)` means DO NOT REMOVE — either a process was found (the
/// reason names its pid, command and cwd) or the probe could not complete (the
/// reason says which step failed). `None` means the probe ran and found
/// nothing, which is the only result that permits removal.
///
/// `path` is canonicalized first so a symlinked candidate still matches the
/// resolved cwd `lsof` reports; a canonicalize failure is itself an
/// undeterminable answer, not a pass.
/// Test: `liveness_reports_a_process_standing_in_the_directory`,
/// `liveness_ignores_a_sibling_directory`,
/// `liveness_treats_a_missing_lsof_as_in_use`,
/// `liveness_kills_a_probe_that_outlives_its_ceiling`,
/// `liveness_treats_an_unparsable_probe_as_in_use`.
pub
/// The probe binary. One literal so the doc, the error text and the call agree.
const LSOF: &str = "lsof";
/// The ceiling on one `lsof` listing (#7540 critic round).
///
/// Why: a healthy listing takes about a second; a stalled one (a hung NFS or
/// SMB mount) would otherwise hold the reclaim tick, and every later sweep,
/// forever. Fifteen healthy listings' worth is past any live answer.
const LSOF_TIMEOUT: Duration = from_secs;
/// The `lsof` invocation listing every process's current working directory.
///
/// Why: `-d cwd` restricts the descriptor set to the one entry per process this
/// check cares about, which is what keeps a system-wide listing to roughly a
/// thousand lines and about a second. `-w` suppresses the warnings `lsof` emits
/// for processes it may not examine — those are expected for another user's
/// processes and are not a probe failure. `-b` (#7540) avoids the kernel calls
/// that block on a hung mount; measured on macOS and on Linux `lsof` 4.99.4, it
/// still reports every cwd, this process's own included.
/// Run a cwd listing, killing it past `budget`.
///
/// Why: the command and the ceiling are parameters so the fail-toward-in-use
/// arms can be exercised against an absent binary and a hung one. Neither is
/// configuration: the one production call site passes [`lsof_command`] and
/// [`LSOF_TIMEOUT`].
/// What: runs through [`run_bounded`], which kills and reaps the child's whole
/// process group at the deadline (#7540). `Ok(stdout)` for a zero exit;
/// `Err(reason)` for a spawn failure, a timeout or a non-zero exit, each
/// carrying the text the caller reports as its refusal.
/// Test: `liveness_treats_a_missing_lsof_as_in_use`,
/// `liveness_kills_a_probe_that_outlives_its_ceiling`.
/// Find the first process in an `lsof -F pcn` listing whose cwd is under `root`.
///
/// Why: split from the spawn so the parser is testable against fixed text,
/// including the shapes that must NOT be read as "free".
/// What: `lsof`'s field output repeats `p<pid>`, `c<command>`, then `n<path>`;
/// the `p`/`c` values carry forward until the next process set. Returns
/// `Some(reason)` for the first match, and for any listing that fails the
/// SELF-VISIBILITY proof below.
///
/// # Why "the listing was non-empty" is not proof it saw anything
///
/// Without root, `lsof` reports only the caller's own processes and exits 0
/// regardless — measured on this machine, `lsof -w -d cwd -F pcn` returned 429
/// of 667 processes and ZERO of the 229 owned by other users. A flag that only
/// asks "did ANY `n` line appear" is satisfied by that partial listing, so
/// blindness to the very process that matters reads as a clean negative, which
/// is a PERMIT on a `git worktree remove --force`. That is #4470's
/// empty-`lsof`-is-not-`Free` defect in a second subsystem.
///
/// So the proof is positive and specific: the listing must contain THIS
/// process's own pid with a working directory. If the probe cannot see the
/// process that ran it, its silence about every other process carries no
/// information at all.
/// Test: `liveness_reports_a_process_standing_in_the_directory`,
/// `liveness_ignores_a_sibling_directory`,
/// `liveness_treats_an_unparsable_probe_as_in_use`,
/// `liveness_treats_an_empty_probe_as_in_use`,
/// `liveness_treats_a_listing_that_cannot_see_this_process_as_in_use`.