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
//! The session-safe reclaim rule for a harness agent-store tree (#7771, #8301).
//!
//! Why: a tree under `.claude/worktrees/` was refused forever when it carried
//! no owner file, which is every tree made by hand with `git worktree add` —
//! and `tm pr merge` removed ANOTHER session's idle, resumable agent tree with
//! no ownership check at all. Both paths now ask one question, answered here:
//! is any live party still entitled to this tree?
//! What: [`owner_refusal`] applies conditions (a)–(d) of the #7771 rule. (a) no
//! harness lock names a running pid whose start time matches the lock; (b) no
//! non-terminal delegation names the owner file's agent, unless the session
//! that dispatched it provably ended; (c) no process stands in the tree; (d)
//! the owner file's session is the caller or provably ended. An owner file
//! that names nobody makes (b) and (d) vacuous.
//!
//! # Fail direction
//!
//! Every probe that cannot answer keeps the tree with a named reason
//! (ADR-0045): an unparsable lock reason, a process table that cannot be read,
//! an owner file that cannot be read or parsed (#8511), a cwd probe that fails,
//! and a session nothing proves ended.
//! Test: `worktree_owner_gate_tests`.
use std::path::Path;
use super::worktree_ownership::{
AgentDelegationState, AgentWorktreeOwner, OwnerReadError, SentinelOwner,
};
use super::worktree_registry::{
harness_lock_pid_in_reason, is_harness_agent_lock_reason, list_registered_worktrees,
pid_liveness,
};
/// How far a lock's recorded start may sit from the process's own start.
///
/// Why: both sides are whole seconds, taken by different clocks at different
/// moments, so exact equality would call a live holder "reused".
pub(crate) const START_TOLERANCE_SECS: i64 = 2;
/// What git's lock on a tree says about who holds it (#7771 condition a).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LockLiveness {
/// Git lists the tree and reports no lock.
Unlocked,
/// A harness lock whose pid is gone or reused — released, carrying why.
Stale(String),
/// A harness lock whose pid runs with the recorded start time.
Held(String),
/// An operator lock, or a lock nothing could judge — keep, carrying why.
Refused(String),
}
impl LockLiveness {
/// The refusal this answer carries, or `None` for a released tree.
pub(crate) fn refusal(&self) -> Option<String> {
match self {
Self::Unlocked | Self::Stale(_) => None,
Self::Held(why) | Self::Refused(why) => Some(why.clone()),
}
}
}
/// The UTC start time a harness lock reason records, as Unix seconds (#7771).
///
/// Why: a pid alone cannot tell the holder from a later process that reused
/// its number; the start time the harness writes beside it can.
/// What: the text between `start ` and the closing `)`, read as
/// `Thu Sep 24 02:08:36 2026` in UTC (whitespace-normalised, so a space-padded
/// day parses too). `None` when the marker is absent or does not parse.
/// Test: `lock_start_reads_the_measured_reason_shape`,
/// `lock_start_is_none_for_a_reason_without_one`.
pub(crate) fn parse_lock_start(reason: &str) -> Option<i64> {
let rest = reason.split_once(" start ")?.1;
parse_start_stamp(rest.split_once(')').map_or(rest, |(s, _)| s))
}
/// A `Thu Sep 24 02:08:36 2026` UTC process-start stamp as Unix seconds.
///
/// Why: #7771 — Claude Code's own session registry writes a process's start in
/// the same shape the harness lock does, so both are read by one parser.
/// Test: `lock_start_reads_the_measured_reason_shape`,
/// `claude_registry_reads_the_measured_entry_shape`.
pub(crate) fn parse_start_stamp(stamp: &str) -> Option<i64> {
// The weekday is dropped: it restates the date, and a writer that got it
// wrong must not turn a readable start into "no start".
let normal = stamp
.split_whitespace()
.skip(1)
.collect::<Vec<_>>()
.join(" ");
chrono::NaiveDateTime::parse_from_str(&normal, "%b %d %H:%M:%S %Y")
.ok()
.map(|t| t.and_utc().timestamp())
}
/// Judge one lock reason against the process table (#7771 condition a).
///
/// Why: pure over its two probes, so every failure arm is testable — no live
/// process can be made to fail a table read on demand.
/// What: an operator-shaped reason refuses. A harness reason with no pid
/// refuses. `pid_alive` answering `Some(false)` is stale; `None` refuses. A
/// running pid is held only when `start_of` matches the recorded start within
/// [`START_TOLERANCE_SECS`]; a mismatch is stale (reused), and an unreadable
/// start on either side refuses.
/// Test: `judge_lock_releases_a_dead_pid`, `judge_lock_releases_a_reused_pid`,
/// `judge_lock_holds_a_live_matching_pid`, `judge_lock_refuses_every_probe_failure`.
pub(crate) fn judge_lock(
reason: &str,
pid_alive: &dyn Fn(u32) -> Option<bool>,
start_of: &dyn Fn(u32) -> Result<i64, String>,
) -> LockLiveness {
if !is_harness_agent_lock_reason(reason) {
return LockLiveness::Refused(format!(
"git-locked by the operator (`{reason}`) — a standing veto no reclaim overrides"
));
}
let Some(pid) = harness_lock_pid_in_reason(reason) else {
return LockLiveness::Refused(format!(
"the harness lock `{reason}` names no pid this can read, so its holder cannot be \
judged (#7771, ADR-0045)"
));
};
match pid_alive(pid) {
Some(false) => return LockLiveness::Stale(format!("lock pid {pid} is gone")),
None => {
return LockLiveness::Refused(format!(
"whether lock pid {pid} runs could not be read from the process table \
(#7771, ADR-0045)"
));
}
Some(true) => {}
}
let Some(recorded) = parse_lock_start(reason) else {
return LockLiveness::Refused(format!(
"lock pid {pid} runs and the lock records no readable start time, so a reused pid \
cannot be told from the holder (#7771, ADR-0045)"
));
};
match start_of(pid) {
Ok(actual) if (actual - recorded).abs() <= START_TOLERANCE_SECS => {
LockLiveness::Held(format!(
"git still reports the harness's agent-lifetime lock (`{reason}`), and pid {pid} \
runs with the start time it recorded (#6561, #7771)"
))
}
Ok(actual) => LockLiveness::Stale(format!(
"lock pid {pid} was reused (lock start {recorded}, process start {actual})"
)),
Err(e) => LockLiveness::Refused(format!(
"lock pid {pid} runs and its start time could not be read: {e} (#7771, ADR-0045)"
)),
}
}
/// `secs` in the harness lock's own start format, UTC (#7771).
///
/// Test: `lock_start_round_trips_its_own_format`.
#[cfg(test)]
pub(crate) fn format_lock_start(secs: i64) -> String {
chrono::DateTime::from_timestamp(secs, 0)
.map(|t| t.format("%a %b %e %H:%M:%S %Y").to_string())
.unwrap_or_default()
}
/// The start time of `pid`, as Unix seconds, from the process table.
pub(crate) fn process_start_secs(pid: u32) -> Result<i64, String> {
use sysinfo::{Pid, ProcessesToUpdate, System};
let pid = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
let start = sys
.process(pid)
.ok_or_else(|| "the process table holds no entry for it".to_string())?
.start_time();
i64::try_from(start).map_err(|e| format!("start time out of range: {e}"))
}
/// Ask git, then the process table, who holds `path` (#7771 condition a).
///
/// Test: `lock_liveness_of_an_unlocked_tree_is_unlocked`,
/// `lock_liveness_releases_a_real_lock_whose_pid_is_gone`,
/// `lock_liveness_holds_a_real_lock_naming_this_process`,
/// `lock_liveness_refuses_a_path_git_cannot_place`.
pub(crate) fn lock_liveness(path: &Path) -> LockLiveness {
let Some(registered) = list_registered_worktrees(path) else {
return LockLiveness::Refused(
"git could not be asked whether this tree is locked (#7771, ADR-0045)".into(),
);
};
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let found = registered
.into_iter()
.find(|w| std::fs::canonicalize(&w.path).unwrap_or_else(|_| w.path.clone()) == canonical);
let Some(record) = found else {
return LockLiveness::Refused("git does not list this path as a worktree".into());
};
if !record.locked {
return LockLiveness::Unlocked;
}
let reason = record.lock_reason.unwrap_or_default();
judge_lock(&reason, &pid_liveness, &process_start_secs)
}
/// Unlock `path` if, judged right now, its lock is a stale harness lock
/// (#7771).
///
/// Why: `git worktree remove` refuses any locked tree, so a lock whose holder
/// is gone must be released before a removal the gates already approved.
/// What: re-judges the lock; unlocked is a no-op, stale runs
/// `git worktree unlock`, and every other answer refuses.
/// Test: `release_stale_lock_unlocks_a_dead_pids_lock`,
/// `release_stale_lock_refuses_a_live_holder`.
pub(crate) fn release_stale_lock(path: &Path) -> Result<(), String> {
match lock_liveness(path) {
LockLiveness::Unlocked => Ok(()),
LockLiveness::Stale(why) => {
let out = super::worktree_safety::git_command(path, &["worktree", "unlock", "."])
.output()
.map_err(|e| format!("`git worktree unlock` could not run ({why}): {e}"))?;
if out.status.success() {
tracing::info!(path = %path.display(), "released a stale harness lock — {why} (#7771)");
return Ok(());
}
Err(format!(
"`git worktree unlock` failed ({why}): {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
LockLiveness::Held(why) | LockLiveness::Refused(why) => Err(why),
}
}
/// Whether the session an owner file names may lose its tree (#7771 d).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SessionEnd {
/// It is the session running the reclaim.
Caller,
/// The #7652 evidence proves it ended.
Ended,
/// It is live, or its liveness probe errored.
Live,
/// Nothing can show it ended — carrying why.
Undeterminable(String),
}
/// The owner file, through the strict `worktree_ownership` reader (#7771,
/// #8511).
///
/// Why: a hand-made tree has no owner file and may be reclaimed, but an owner
/// file that exists and cannot be read may be a live party's truncated claim,
/// so the two must not fold together. Only `worktree_ownership` knows where the
/// marker lives (admin dir or legacy in-tree), so nothing here builds its path.
/// What: `Ok(None)` for no owner file, or one that names nobody (an empty
/// pre-#3649 marker): (b) and (d) are vacuous and (a), (c) and the later gates
/// decide. `Ok(Some(owner))` for an agent or session owner. `Err(why)` when the
/// file cannot be read, does not parse, or the tree's `.git` entry does not
/// resolve — the caller keeps the tree (ADR-0045).
/// Test: `owner_refusal_reclaims_a_tree_with_no_owner_file`,
/// `owner_refusal_honours_a_marker_only_in_the_admin_dir`,
/// `owner_refusal_honours_a_pre_migration_in_tree_marker`,
/// `owner_refusal_keeps_a_tree_whose_git_entry_does_not_resolve`,
/// `classify_blocks_an_agent_store_worktree_with_an_unreadable_sentinel`.
pub(crate) fn read_owner(path: &Path) -> Result<Option<SentinelOwner>, String> {
match super::worktree_ownership::read_sentinel_owner_strict(path) {
Ok(None | Some(SentinelOwner::Unknown)) => Ok(None),
Ok(owner) => Ok(owner),
Err(OwnerReadError::Unreadable { path, reason }) => Err(format!(
"its owner file {} cannot be read ({reason}), so who owns the tree cannot be \
determined (#7771, #8511, ADR-0045)",
path.display()
)),
Err(OwnerReadError::Corrupt { path, reason }) => Err(format!(
"its owner file {} does not parse ({reason}), so who owns the tree cannot be \
determined (#7771, #8511, ADR-0045)",
path.display()
)),
}
}
/// The inputs [`owner_refusal`] judges, each injectable for its tests.
pub(crate) struct OwnerGate<'a> {
/// (b) The delegation registry's answer for the owner file's agent.
pub agent_state: &'a dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState,
/// (d) Whether a named session is the caller or provably ended.
pub session_end: &'a dyn Fn(&str) -> SessionEnd,
/// (a) Git's lock, judged against the process table.
pub lock: &'a dyn Fn(&Path) -> LockLiveness,
/// (c) A process standing in the tree, or why that could not be asked.
pub cwd_holder: &'a dyn Fn(&Path) -> Option<String>,
}
impl OwnerGate<'_> {
/// The production probes, around the caller's (b) and (d) answers.
pub(crate) fn host<'a>(
agent_state: &'a dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState,
session_end: &'a dyn Fn(&str) -> SessionEnd,
) -> OwnerGate<'a> {
OwnerGate {
agent_state,
session_end,
lock: &lock_liveness,
cwd_holder: &super::worktree_liveness::process_holding,
}
}
}
/// Why no reclaim may take `path`, or `None` when (a)–(d) all hold (#7771).
///
/// Why: see the module doc — the one rule the prune survey, its pre-delete
/// re-check and `tm pr cleanup` all apply.
/// What: (a) the lock, then the owner file (one naming nobody skips (b) and
/// (d); one that cannot be read keeps the tree — [`read_owner`]), then (b) and
/// (d) against an agent or session owner, then (c) last,
/// because it is the one probe that costs a process-table walk. A `Live`
/// delegation whose dispatching session is [`SessionEnd::Ended`] is stale and
/// does not refuse (#7771); a held lock or a process in the tree still does.
/// Test: `owner_refusal_reclaims_a_tree_with_no_owner_file`,
/// `owner_refusal_keeps_a_tree_a_live_pid_locks`,
/// `owner_refusal_keeps_a_live_delegations_tree`,
/// `worktree_7771_a_dead_sessions_open_delegation_is_stale`,
/// `worktree_7771_an_unregistered_claude_process_keeps_a_live_delegations_tree`,
/// `worktree_7771_a_stale_delegation_still_yields_to_a_held_lock`,
/// `owner_refusal_keeps_another_live_sessions_tree`,
/// `owner_refusal_permits_the_callers_own_agent_tree`,
/// `owner_refusal_permits_an_ended_sessions_agent_tree`,
/// `owner_refusal_keeps_a_tree_a_process_stands_in`,
/// `owner_refusal_keeps_a_session_nothing_proves_ended`.
pub(crate) fn owner_refusal(path: &Path, gate: &OwnerGate<'_>) -> Option<String> {
if let Some(why) = (gate.lock)(path).refusal() {
return Some(why);
}
let session = match read_owner(path) {
Err(why) => return Some(why),
Ok(None | Some(SentinelOwner::Unknown)) => None,
Ok(Some(SentinelOwner::Agent(owner, _))) => {
let session = owner.parent_session_id.0.to_string();
let end = (gate.session_end)(&session);
// #7771: a delegation record still `Running` after its dispatching
// session provably ended is stale — the agent ran inside that
// session and died with it. Only `Ended` releases it; the caller's
// own live agent, and every unproven session, still keep the tree.
// A registry-only `Ended` needs a complete registry read, one that
// lists every running Claude Code process (#7771 critic).
if end != SessionEnd::Ended && (gate.agent_state)(&owner) == AgentDelegationState::Live
{
return Some(format!(
"owned by dispatched agent {} — a delegation naming it has not ended \
(#5661, #7771)",
owner.agent_id
));
}
Some((session, end))
}
Ok(Some(SentinelOwner::Known(owner, _))) => {
let session = owner.to_string();
let end = (gate.session_end)(&session);
Some((session, end))
}
};
if let Some((session, end)) = session {
match end {
SessionEnd::Caller | SessionEnd::Ended => {}
SessionEnd::Live => {
return Some(format!(
"its owner file names session {session}, which is live and is not the \
calling session — that session may still resume work here (#7771)"
));
}
SessionEnd::Undeterminable(why) => {
return Some(format!(
"its owner file names session {session}, and nothing proves that session \
ended: {why} (#7771, ADR-0045)"
));
}
}
}
(gate.cwd_holder)(path)
}
#[cfg(test)]
#[path = "worktree_owner_gate_tests.rs"]
mod worktree_owner_gate_tests;