trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! Gate 4 of the merged-PR reclaim: WHO owns a candidate worktree, and is that
//! owner provably finished (#5661, #7652).
//!
//! Why: split out of `worktree_reclaim` so that file stays under the 500-SLOC
//! production cap, and because gate 4 answers a question the other gates do
//! not ask. Gates 1-3 and 5-6 read git, a claim set and the working tree; gate 4
//! reads the `.trusty-mpm-worktree` OWNERSHIP SENTINEL and asks whether the party
//! it names is still working. The sentinel names one of two kinds of owner, so
//! the gate has two halves: [`agent_ownership_blocks`] for a dispatched agent
//! and [`session_ownership_blocks`] for a managed session. A third check,
//! [`unattributed_nested_blocks`], covers the sentinel that names nobody.
//!
//! # Fail direction
//!
//! Every check permits only on POSITIVE evidence that the owner has ended.
//! Silence, an unread store and an unobservable probe all refuse (ADR-0045).
//! Test: `worktree_reclaim_tests`, `worktree_reclaim_owner_liveness_tests`.

use std::collections::HashMap;
use std::path::Path;

use super::worktree_claude_registry::ClaudeRegistry;
use super::worktree_owner_gate::{OwnerGate, SessionEnd, owner_refusal};
use super::worktree_ownership::{
    AgentDelegationState, AgentWorktreeOwner, SentinelOwner, is_harness_agent_worktree,
    read_sentinel_owner,
};
use super::worktree_reclaim_claim::{ClaimLiveness, ClaimState};
use super::worktree_registry::{HarnessLockState, harness_lock_state};

/// Resolves the delegation registry's answer for the agent a sentinel names.
///
/// Why: named so the four call sites that pass one around agree on the shape,
/// and so a caller that has no registry to consult has to say so explicitly by
/// returning [`AgentDelegationState::Unknown`] rather than by passing an empty
/// list that reads as "nobody claims it".
pub(crate) type AgentStateProbe<'a> = &'a dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState;

/// Why a DISPATCHED AGENT's ownership forbids reclaiming `path`, or `None` when
/// it does not (#5661).
///
/// Why: `SentinelOwner::Agent` exists to stop an agent-owned worktree being
/// reclaimed by a sweep that cannot tell whether the agent is still working.
/// The merged-PR reclaim path never read the sentinel, so it deleted three live
/// agents' worktrees on 2026-08-15/16, two of them holding unpushed commits.
///
/// What: a tree inside the harness agent store ([`is_harness_agent_worktree`])
/// is judged by [`owner_refusal`], the #7771 rule, with `owners` answering
/// condition (d). Before #7771 an agent-store tree whose sentinel named nobody
/// was refused forever, which kept every hand-made tree.
///
/// Outside the store, the sentinel is read through [`read_sentinel_owner`] and
/// [`SentinelOwner::Agent`] whose agent the registry calls
/// [`Live`](AgentDelegationState::Live) refuses. An
/// [`Unknown`](AgentDelegationState::Unknown) registry answer consults git's
/// durable harness lock instead (#6561): `Released` permits; `Held` and
/// `Undeterminable` refuse. An absent or unparsable sentinel there is left to
/// the later gates.
/// Test: `classify_blocks_a_live_agents_worktree`,
/// `classify_blocks_an_agent_the_harness_still_holds_after_a_restart`,
/// `classify_allows_a_finished_agents_merged_worktree`,
/// `worktree_7771_a_hand_made_tree_is_reclaimed`,
/// `classify_allows_a_merged_agent_tree_the_harness_released`,
/// `classify_blocks_an_agent_tree_git_cannot_be_asked_about`.
pub(crate) fn agent_ownership_blocks(
    path: &Path,
    agent_state: AgentStateProbe<'_>,
    owners: &SessionOwners,
) -> Option<String> {
    // #7771: an agent-store tree, with or without an owner file, is judged by
    // the session-safe rule — a live lock holder, a live delegation, a live
    // foreign owning session, or a process standing in it keeps it.
    if is_harness_agent_worktree(path) {
        let session_end = |id: &str| owners.session_end(id);
        return owner_refusal(path, &OwnerGate::host(agent_state, &session_end));
    }
    match read_sentinel_owner(path) {
        SentinelOwner::Agent(owner, _) => match agent_state(&owner) {
            AgentDelegationState::Live => Some(format!(
                "owned by dispatched agent {} — a delegation naming it has not ended, so it is \
                 still working in this tree (#5661)",
                owner.agent_id
            )),
            // #6561: the registry's silence is not the last word — ask git.
            AgentDelegationState::Unknown => match harness_lock_state(path) {
                HarnessLockState::Released => None,
                HarnessLockState::Held => Some(format!(
                    "owned by dispatched agent {} and git still reports the harness's \
                     agent-lifetime lock on this worktree (#6561)",
                    owner.agent_id
                )),
                HarnessLockState::Undeterminable => Some(format!(
                    "owned by dispatched agent {} — the delegation registry holds no record of \
                     that agent, and git could not be asked whether the harness still holds the \
                     worktree. Two silences are not an answer: undeterminable, not absent \
                     (#5661, #6561, ADR-0045)",
                    owner.agent_id
                )),
            },
            AgentDelegationState::Ended => None,
        },
        SentinelOwner::Known(..) | SentinelOwner::Unknown => None,
    }
}

/// Every stored session record's liveness, keyed by session id (#7652).
///
/// Why: gate 4's session half has to answer "is the session this sentinel names
/// finished?" from the RECORD that names it, not from gate 2's claim list. The
/// claim list holds only records that carry a `workspace_path`, so a live owner
/// whose record has none was indistinguishable there from an owner nobody
/// recorded. This map is built from every record, with or without a workspace.
/// What: `None` until a store read fills it, so a caller that never read the
/// store (the `Default`, and the survey's fallback when the claim probe returned
/// nothing) refuses rather than permits. Each entry reuses the claim set's
/// [`ClaimLiveness`]: `SessionGone` only when the tmux probe answered and did
/// not list the record's managed tmux name; `Live` for a listed name, an
/// unmanaged name, and a probe that errored.
/// Test: `worktree_7652_owners_include_a_record_with_no_workspace_path`,
/// `worktree_7652_an_unread_owner_map_refuses`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SessionOwners {
    /// Session id (as `ManagedSessionId` displays it) → that record's liveness.
    by_id: Option<HashMap<String, ClaimLiveness>>,
    /// #7771: the session running the reclaim, which may take its own trees.
    caller: Option<String>,
    /// #7771: Claude Code session id → every managed session id recording it.
    /// An agent owner file names the dispatching CLAUDE session, not the
    /// managed record, and a resumed session can be recorded more than once.
    aliases: HashMap<String, Vec<String>>,
    /// #7771: every Claude id each managed record EVER carried (the #7617
    /// sidecar), for an id a restart or `/clear` has since replaced.
    history: LinkHistory,
    /// #7771: managed id → its record's tmux session name.
    tmux: HashMap<String, String>,
    /// #7771: Claude Code's per-process registry, for an id no record proves.
    claude: ClaudeRegistry,
}

/// The #7617 session-link history, as the reclaim read it (#7771).
///
/// Why: an agent tree's owner file names a Claude session id that a restart or
/// `/clear` has since replaced on the record, so the record no longer names it.
/// On one host 185 of 191 agent trees were spared for that reason alone. The
/// sidecar keeps every id a record ever carried; the reclaim needs to know
/// whether it read that sidecar, and why not when it could not.
/// Test: `session_end_judges_a_superseded_claude_id_by_its_history`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) enum LinkHistory {
    /// No sidecar was consulted, or none exists: no superseded-id evidence.
    #[default]
    Absent,
    /// Claude id → the managed ids whose link file lists it.
    Read(HashMap<String, Vec<String>>),
    /// The sidecar exists and could not be read — carrying why.
    Unreadable(String),
}

impl LinkHistory {
    /// The history under `framework_root`, read strictly (#7771).
    pub(crate) fn read(framework_root: &Path) -> Self {
        match crate::core::session_links::link_history(framework_root) {
            Ok(map) if map.is_empty() => Self::Absent,
            Ok(map) => Self::Read(map),
            Err(why) => Self::Unreadable(why),
        }
    }
}

impl SessionOwners {
    /// This map, naming the calling session (#7771).
    pub(crate) fn with_caller(mut self, caller: Option<String>) -> Self {
        self.caller = caller;
        self
    }

    /// This map, resolving Claude session ids to managed ids (#7771).
    pub(crate) fn with_aliases(
        mut self,
        pairs: impl IntoIterator<Item = (String, String)>,
    ) -> Self {
        for (claude, managed) in pairs {
            self.aliases.entry(claude).or_default().push(managed);
        }
        self
    }

    /// This map, with the session-link history a superseded Claude id is
    /// judged by (#7771).
    pub(crate) fn with_history(mut self, history: LinkHistory) -> Self {
        self.history = history;
        self
    }

    /// This map, knowing each managed record's tmux session (#7771).
    pub(crate) fn with_tmux(mut self, pairs: impl IntoIterator<Item = (String, String)>) -> Self {
        self.tmux.extend(pairs);
        self
    }

    /// This map, with Claude Code's per-process registry (#7771).
    pub(crate) fn with_claude(mut self, claude: ClaudeRegistry) -> Self {
        self.claude = claude;
        self
    }

    /// Whether session `id` (managed or Claude) is the caller or provably
    /// ended (#7771 condition d).
    ///
    /// What: [`SessionEnd::Caller`] when `id` is the caller. A Claude id is
    /// judged by EVERY managed record aliasing it, strictest first: any `Live`
    /// record is `Live`, then any record nothing can judge is
    /// `Undeterminable`, then a caller record is `Caller`; `Ended` needs every
    /// record gone. One record is judged by [`Self::alias_end`]. An id that is
    /// neither a current alias nor a stored record is judged by
    /// [`Self::superseded_end`], then by [`Self::registry_end`].
    /// Test: `session_end_resolves_a_claude_id_through_its_alias`,
    /// `session_end_keeps_a_claude_id_any_live_record_holds`,
    /// `session_end_ranks_a_live_alias_above_the_caller_alias`,
    /// `session_end_refuses_an_unread_map`,
    /// `session_end_judges_a_superseded_claude_id_by_its_history`,
    /// `worktree_7771_an_unrecorded_owner_whose_process_is_gone_is_reclaimed`.
    pub(crate) fn session_end(&self, id: &str) -> SessionEnd {
        if self.caller.as_deref() == Some(id) {
            return SessionEnd::Caller;
        }
        // #7771 critic: a Claude session resumed into two records is not
        // judged by whichever pair was recorded last.
        if let Some(end) = self.aliases.get(id).and_then(|records| {
            records
                .iter()
                .map(|m| self.alias_end(id, m))
                .max_by_key(strictness)
        }) {
            return end;
        }
        match self.record_end(id) {
            // #7771: not a current alias and not a managed record — a Claude id
            // a restart or `/clear` may have replaced, or one no record names.
            SessionEnd::Undeterminable(why) if self.stored(id) == Some(false) => {
                let end = self
                    .superseded_end(id)
                    .unwrap_or(SessionEnd::Undeterminable(why));
                self.registry_end(id, end)
            }
            end => end,
        }
    }

    /// Whether Claude Code's registry may add evidence: never over an unread
    /// store or an unreadable link sidecar (#7771, ADR-0045).
    fn registry_usable(&self) -> bool {
        self.by_id.is_some() && !matches!(self.history, LinkHistory::Unreadable(_))
    }

    /// `end` for an id no record carries, corrected by Claude Code's own
    /// process registry (#7771).
    ///
    /// Why: an owner session `tm` never recorded, or whose record was
    /// compacted, has nothing in the store to prove it ended. What: a running
    /// process registered to `id` makes it `Live` whatever the history says;
    /// an `Undeterminable` becomes `Ended` only on the registry's positive
    /// proof ([`ClaudeRegistry::session_end`]), which a read missing any
    /// running Claude Code process never gives, and otherwise keeps both
    /// reasons.
    /// Test: `worktree_7771_an_unrecorded_owner_whose_process_is_gone_is_reclaimed`,
    /// `worktree_7771_an_unregistered_claude_process_keeps_a_live_delegations_tree`,
    /// `session_end_a_live_registry_entry_outranks_a_superseded_history`,
    /// `worktree_7771_a_registry_probe_error_keeps_the_tree`,
    /// `worktree_7771_an_unreadable_sidecar_outranks_the_registry`.
    fn registry_end(&self, id: &str, end: SessionEnd) -> SessionEnd {
        if !self.registry_usable() {
            return end;
        }
        match (end, self.claude.session_end(id)) {
            // #7771: a running process outranks a history-superseded `Ended`.
            (_, Some(SessionEnd::Live)) => SessionEnd::Live,
            (SessionEnd::Undeterminable(_), Some(SessionEnd::Ended)) => SessionEnd::Ended,
            (SessionEnd::Undeterminable(why), Some(SessionEnd::Undeterminable(more))) => {
                SessionEnd::Undeterminable(format!("{why}; {more}"))
            }
            (end, _) => end,
        }
    }

    /// One aliasing record's answer for Claude id `id` (#7771).
    ///
    /// What: [`Self::record_end`], except that a `Live` record is `Ended` for
    /// `id` when Claude Code's registry shows its tmux session running another
    /// Claude session and no process running `id` — a relaunch the record's
    /// `claude_session_id` never caught up with.
    /// Test: `worktree_7771_a_session_replaced_in_its_tmux_window_is_reclaimed`,
    /// `worktree_7771_a_live_id_beside_a_newer_one_keeps_the_tree`,
    /// `worktree_7771_a_live_record_with_no_registered_process_is_kept`.
    fn alias_end(&self, id: &str, managed: &str) -> SessionEnd {
        let end = self.record_end(managed);
        let replaced = || {
            self.tmux
                .get(managed)
                .is_some_and(|t| self.claude.replaced_in(id, t))
        };
        if end == SessionEnd::Live && self.registry_usable() && replaced() {
            return SessionEnd::Ended;
        }
        end
    }

    /// Whether `managed` is a stored record; `None` when the store was not read.
    fn stored(&self, managed: &str) -> Option<bool> {
        self.by_id.as_ref().map(|by_id| by_id.contains_key(managed))
    }

    /// A Claude id no record carries NOW, judged by the records that once
    /// carried it (#7771), or `None` when the history does not name it.
    ///
    /// Why: a managed session runs one Claude session at a time, and its
    /// record's `claude_session_id` moves to the new id on every restart or
    /// `/clear`. An id the history links to a stored record, and that no record
    /// carries now, is a Claude session that record has left — and a dispatched
    /// agent runs inside its dispatching session, so it has ended too.
    /// What: `Ended` only when every managed id the history links to `id` is a
    /// stored record. A linked id with no stored record (compacted, or never
    /// persisted) is `Undeterminable`, as is a history that could not be read.
    /// A live agent still holding the tree is caught by the harness lock and the
    /// cwd probe ([`super::worktree_owner_gate::owner_refusal`]), not here.
    /// Test: `session_end_judges_a_superseded_claude_id_by_its_history`.
    fn superseded_end(&self, id: &str) -> Option<SessionEnd> {
        let map = match &self.history {
            LinkHistory::Absent => return None,
            LinkHistory::Unreadable(why) => {
                return Some(SessionEnd::Undeterminable(format!(
                    "the session-link history could not be read ({why}), so whether a record \
                     once carried it is unknown"
                )));
            }
            LinkHistory::Read(map) => map,
        };
        let records = map.get(id)?;
        if let Some(missing) = records.iter().find(|m| self.stored(m) != Some(true)) {
            return Some(SessionEnd::Undeterminable(format!(
                "the session-link history links it to session {missing}, which no stored record \
                 names"
            )));
        }
        Some(SessionEnd::Ended)
    }

    /// One managed record's answer: the caller is `Caller`, then the #7652
    /// evidence — `SessionGone` is `Ended`, `Live` is `Live`, and an unread map
    /// or an unrecorded id is `Undeterminable`.
    fn record_end(&self, managed: &str) -> SessionEnd {
        if self.caller.as_deref() == Some(managed) {
            return SessionEnd::Caller;
        }
        let Some(by_id) = &self.by_id else {
            return SessionEnd::Undeterminable("the session store was not read".into());
        };
        match by_id.get(managed) {
            Some(ClaimLiveness::SessionGone) => SessionEnd::Ended,
            Some(ClaimLiveness::Live) => SessionEnd::Live,
            None => SessionEnd::Undeterminable("no stored session record names it".into()),
        }
    }

    /// An owner map read from a store, one `(id, liveness)` pair per record.
    ///
    /// A duplicated id keeps `Live` if any entry says so: the fail-closed
    /// direction, since one live record must not be outvoted by a stale one.
    pub(crate) fn observed(records: impl IntoIterator<Item = (String, ClaimLiveness)>) -> Self {
        let mut by_id: HashMap<String, ClaimLiveness> = HashMap::new();
        for (id, liveness) in records {
            let entry = by_id.entry(id).or_insert(liveness);
            if liveness == ClaimLiveness::Live {
                *entry = ClaimLiveness::Live;
            }
        }
        Self {
            by_id: Some(by_id),
            ..Self::default()
        }
    }
}

/// How strongly one record's [`SessionEnd`] keeps a tree; the strictest wins.
fn strictness(end: &SessionEnd) -> u8 {
    match end {
        SessionEnd::Ended => 0,
        SessionEnd::Caller => 1,
        SessionEnd::Undeterminable(_) => 2,
        SessionEnd::Live => 3,
    }
}

/// Why a MANAGED SESSION's ownership forbids reclaiming `path`, or `None` when
/// it does not (#7652).
///
/// Why: #7652 narrowed gate 2 so a live foreign session's PROJECT-ROOT claim no
/// longer vetoes the worktrees nested under it. Gate 4 then answered `None` for
/// every [`SentinelOwner::Known`] whatever that session's state, so a worktree a
/// live session created directly, merged and clean, was deleted under it.
///
/// What: for a `Known` sentinel, permits ONLY when the owner is provably gone.
/// The evidence is exactly the one #7232 accepts for discarding a claim: the
/// store holds a record with this id, its tmux name is managed-shaped, and a
/// tmux probe that ANSWERED does not list it. That evidence does not depend on
/// the record carrying a `workspace_path`, so a record without one is judged the
/// same way. The calling session is permitted too (#7771). A tree in the
/// harness agent store is gate 4's, judged by [`owner_refusal`] with the
/// harness lock first (#7771). Every other answer refuses:
///
/// - the owner map was never read (an unreadable registry);
/// - no stored record names the owner. The sentinel stores no pid or pane, so a
///   record is the only thing that could evidence that session's end, and its
///   absence evidences nothing. That absence is not always temporary:
///   `prune_managed` COMPACTS `Decommissioned` and `Deleted` tombstones out of
///   the store (`prune.rs`, `is_tombstone`), so after `tm sessions prune` a tree
///   whose sentinel names a compacted session is permanently unreclaimable by
///   this path. The escape is an operator's own `git worktree remove`, which the
///   refusal names (#7652 critic round 2);
/// - the record's tmux session is listed, its name is unmanaged, or the tmux
///   probe errored. The map already reports all three as `Live`.
///
/// Agent and unattributed sentinels are [`agent_ownership_blocks`]'s question.
/// Test: `worktree_7652_a_live_owners_nested_worktree_is_refused`,
/// `worktree_7652_a_dead_owners_nested_worktree_is_reclaimed`,
/// `worktree_7652_an_owner_with_no_record_is_refused`,
/// `worktree_7652_an_errored_tmux_probe_is_refused`,
/// `worktree_7652_an_unanswerable_claim_probe_reclaims_nothing`,
/// `worktree_7652_an_unread_owner_map_refuses`,
/// `worktree_7652_a_held_harness_lock_outranks_an_ended_sentinel_session`.
pub(crate) fn session_ownership_blocks(path: &Path, owners: &SessionOwners) -> Option<String> {
    // #7771: gate 4 already judged an agent-store tree by the full rule, the
    // harness lock included.
    if is_harness_agent_worktree(path) {
        return None;
    }
    let SentinelOwner::Known(owner, _) = read_sentinel_owner(path) else {
        return None;
    };
    // #7771: the calling session may take a tree its own sentinel names.
    if owners.session_end(&owner.to_string()) == SessionEnd::Caller {
        return None;
    }
    let Some(by_id) = &owners.by_id else {
        return Some(format!(
            "owned by session {owner}, and the session store was not read, so nothing can show \
             that session has ended (#7652, ADR-0045)"
        ));
    };
    match by_id.get(&owner.to_string()) {
        Some(ClaimLiveness::SessionGone) => None,
        Some(ClaimLiveness::Live) => Some(format!(
            "owned by session {owner}, which tmux still lists, or whose liveness the tmux probe \
             could not establish (#7652)"
        )),
        // #7652 critic round 2: `tm sessions prune` compacts tombstones, so this
        // can be permanent — name the escape rather than implying it will clear.
        None => Some(format!(
            "owned by session {owner}, and no stored session record names it, so nothing can \
             show that session has ended — unrecorded is not dead. If that session's record was \
             compacted by `tm sessions prune`, no later run will recover it: confirm nobody is \
             working in this tree and remove it yourself with `git worktree remove` (#7652, \
             ADR-0045)"
        )),
    }
}

/// Why an UNATTRIBUTED tree nested under a live foreign session's claim may not
/// be reclaimed, or `None` when this check does not apply (#7652 critic round).
///
/// Why: gate 2 permits a live foreign project-root claim over a nested tree
/// ([`ClaimState::ForeignNested`]) because the sentinel attributes the tree
/// instead. A sentinel that is absent, empty, malformed or unreadable
/// attributes nothing, so the live claim is the only ownership evidence left.
/// Before #7652, gate 2 refused this tree.
/// What: refuses exactly `ForeignNested` × [`SentinelOwner::Unknown`]. Every
/// other claim state, and every sentinel that names an owner, is left to the
/// other gates. The survey's `classify` and the delete loop's two re-checks all
/// call it.
///
/// Reading only `ForeignNested` is why gate 2 reports that state ahead of
/// `CallerNested` (#7652 critic round 2): when the invoking session claims the
/// enclosing checkout too, as two sessions sharing one project do, reporting the
/// caller's claim first left this check nothing to fire on.
/// Test: `worktree_7652_an_unattributed_tree_under_a_live_foreign_claim_is_refused`,
/// `worktree_7652_gate_4c_survives_a_caller_claim_on_the_same_checkout`.
pub(crate) fn unattributed_nested_blocks(path: &Path, claim: &ClaimState) -> Option<String> {
    let ClaimState::ForeignNested { session, .. } = claim else {
        return None;
    };
    // #7771: an agent-store tree nests under the shared checkout every session
    // claims; gate 4 judged it by the full rule instead.
    if is_harness_agent_worktree(path) {
        return None;
    }
    match read_sentinel_owner(path) {
        SentinelOwner::Unknown => Some(format!(
            "session {session} claims a workspace containing this worktree, and the ownership \
             sentinel is absent, empty, malformed or unreadable, so nothing attributes the tree \
             to anyone else — the live claim stands (#7652, ADR-0045)"
        )),
        SentinelOwner::Known(..) | SentinelOwner::Agent(..) => None,
    }
}