Skip to main content

hatchery_node/
worktree_service.rs

1pub(crate) use gate4agent_node_core::managed_worktree::{ManagedWorktreeLeaseRecord, ManagedWorktreeSessionHolder};
2use crate::git_worktree::{paths_equal, validate_managed_allocation_root};
3use crate::protocol::{
4    GitObjectId, ManagedWorktreeCleanupFailure, ManagedWorktreeGitScope,
5    ManagedWorktreeLeaseId, ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState,
6    ManagedWorktreeRetention, NodeIncarnationId, SessionRecordId, WorktreeProfileId,
7    WorktreeProfileRevision, WorkspaceId, MAX_MANAGED_WORKTREE_LEASES,
8};
9use gate4agent_node_wire::random_nonce;
10use gate4agent_types::{AgentInstanceId, SessionGeneration};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15const MAX_MANAGED_WORKTREE_TOMBSTONES: usize = MAX_MANAGED_WORKTREE_LEASES;
16const MAX_BRANCH_PREFIX_BYTES: usize = 256;
17const MAX_BASE_REVISION_BYTES: usize = 1_024;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct ManagedWorktreeProfile {
21    profile_id: WorktreeProfileId,
22    revision: WorktreeProfileRevision,
23    allocation_root: String,
24    branch_prefix: String,
25    base: String,
26    retention: ManagedWorktreeRetention,
27}
28
29impl ManagedWorktreeProfile {
30    pub fn new(
31        profile_id: WorktreeProfileId,
32        revision: WorktreeProfileRevision,
33        allocation_root: impl AsRef<Path>,
34        branch_prefix: impl Into<String>,
35        base: impl Into<String>,
36        retention: ManagedWorktreeRetention,
37    ) -> Result<Self, String> {
38        let allocation_root = allocation_root.as_ref();
39        if !allocation_root.is_absolute() {
40            return Err("managed worktree allocation root must be absolute".to_owned());
41        }
42        let canonical = std::fs::canonicalize(allocation_root)
43            .map_err(|error| format!("managed worktree allocation root is unavailable: {error}"))?;
44        if !canonical.is_dir() {
45            return Err("managed worktree allocation root must be a directory".to_owned());
46        }
47        let allocation_root = canonical.into_os_string().into_string().map_err(|path| {
48            format!(
49                "managed worktree allocation root is not valid Unicode: {}",
50                path.to_string_lossy(),
51            )
52        })?;
53        let allocation_root = crate::platform::normalize_canonical_root(allocation_root);
54        let branch_prefix = branch_prefix.into();
55        if branch_prefix.is_empty()
56            || branch_prefix.len() > MAX_BRANCH_PREFIX_BYTES
57            || branch_prefix.chars().any(char::is_control)
58            || branch_prefix.ends_with('/')
59        {
60            return Err("managed worktree branch prefix is invalid".to_owned());
61        }
62        let base = base.into();
63        if base.is_empty()
64            || base.len() > MAX_BASE_REVISION_BYTES
65            || base.chars().any(char::is_control)
66        {
67            return Err("managed worktree base revision is invalid".to_owned());
68        }
69        Ok(Self {
70            profile_id,
71            revision,
72            allocation_root,
73            branch_prefix,
74            base,
75            retention,
76        })
77    }
78
79    pub fn profile_id(&self) -> &WorktreeProfileId {
80        &self.profile_id
81    }
82
83    pub fn revision(&self) -> &WorktreeProfileRevision {
84        &self.revision
85    }
86
87    pub fn allocation_root(&self) -> &str {
88        &self.allocation_root
89    }
90
91    pub fn branch_prefix(&self) -> &str {
92        &self.branch_prefix
93    }
94
95    pub fn base(&self) -> &str {
96        &self.base
97    }
98
99    pub fn retention(&self) -> ManagedWorktreeRetention {
100        self.retention
101    }
102
103    pub(crate) fn validate_for_workspace(&self, source_root: &str) -> Result<(), String> {
104        validate_managed_allocation_root(source_root, &self.allocation_root)
105            .map_err(|error| error.message)
106    }
107
108    pub(crate) fn validate_target_authority(
109        &self,
110        lease_id: &ManagedWorktreeLeaseId,
111        target_root: &str,
112    ) -> Result<(), String> {
113        let current_root = std::fs::canonicalize(&self.allocation_root)
114            .map_err(|error| format!("managed worktree allocation root is unavailable: {error}"))?;
115        let current_root = crate::platform::normalize_canonical_root(
116            current_root.into_os_string().into_string()
117                .map_err(|_| "managed worktree allocation root is not valid Unicode".to_owned())?,
118        );
119        if !paths_equal(&current_root, &self.allocation_root) {
120            return Err("managed worktree allocation root identity changed".to_owned());
121        }
122        let target = Path::new(target_root);
123        let expected = PathBuf::from(&self.allocation_root).join(lease_id.as_str());
124        if target != expected
125            || target.file_name().and_then(|name| name.to_str()) != Some(lease_id.as_str())
126            || target.components().any(|component| matches!(
127                component,
128                std::path::Component::CurDir | std::path::Component::ParentDir,
129            ))
130        {
131            return Err("managed worktree target is not the exact allocated lease child".to_owned());
132        }
133        let parent = target.parent()
134            .ok_or_else(|| "managed worktree target has no parent".to_owned())?;
135        let canonical_parent = std::fs::canonicalize(parent)
136            .map_err(|error| format!("managed worktree target parent is unavailable: {error}"))?;
137        let canonical_parent = crate::platform::normalize_canonical_root(
138            canonical_parent.into_os_string().into_string()
139                .map_err(|_| "managed worktree target parent is not valid Unicode".to_owned())?,
140        );
141        if !paths_equal(&canonical_parent, &self.allocation_root)
142        {
143            return Err("managed worktree target is not an exact child of its allocation root".to_owned());
144        }
145        Ok(())
146    }
147}
148
149#[derive(Clone, Debug, Default, Eq, PartialEq)]
150pub(crate) struct ManagedWorktreeRegistry {
151    leases: BTreeMap<ManagedWorktreeLeaseId, ManagedWorktreeLeaseRecord>,
152    tombstones: BTreeMap<ManagedWorktreeLeaseId, ManagedWorktreeLeaseRecord>,
153}
154
155impl ManagedWorktreeRegistry {
156    pub(crate) fn from_records(
157        records: Vec<ManagedWorktreeLeaseRecord>,
158        tombstones: Vec<ManagedWorktreeLeaseRecord>,
159    ) -> Result<Self, String> {
160        if records.len() > MAX_MANAGED_WORKTREE_LEASES
161            || tombstones.len() > MAX_MANAGED_WORKTREE_TOMBSTONES
162        {
163            return Err("managed worktree durable registry exceeds its bounded capacity".to_owned());
164        }
165        let mut registry = Self::default();
166        let mut workspace_ids = BTreeSet::new();
167        let mut target_roots = BTreeSet::new();
168        let mut branches = BTreeSet::new();
169        for record in records {
170            if record.state == ManagedWorktreeLeaseState::Removed
171                || !workspace_ids.insert(record.workspace_id.clone())
172                || !target_roots.insert(crate::platform::root_identity(&record.target_root))
173                || !branches.insert(record.branch.clone())
174                || registry.leases.insert(record.lease_id.clone(), record).is_some()
175            {
176                return Err("managed worktree durable registry is inconsistent".to_owned());
177            }
178        }
179        for record in tombstones {
180            if record.state != ManagedWorktreeLeaseState::Removed
181                || registry.leases.contains_key(&record.lease_id)
182                || registry.tombstones.insert(record.lease_id.clone(), record).is_some()
183            {
184                return Err("managed worktree tombstone registry is inconsistent".to_owned());
185            }
186        }
187        Ok(registry)
188    }
189
190    pub(crate) fn records(&self) -> Vec<ManagedWorktreeLeaseRecord> {
191        self.leases.values().cloned().collect()
192    }
193
194    pub(crate) fn tombstones(&self) -> Vec<ManagedWorktreeLeaseRecord> {
195        self.tombstones.values().cloned().collect()
196    }
197
198    pub(crate) fn snapshots(&self) -> Vec<ManagedWorktreeLeaseSnapshot> {
199        self.leases.values().map(ManagedWorktreeLeaseRecord::snapshot).collect()
200    }
201
202    pub(crate) fn get(&self, lease_id: &ManagedWorktreeLeaseId) -> Option<&ManagedWorktreeLeaseRecord> {
203        self.leases.get(lease_id).or_else(|| self.tombstones.get(lease_id))
204    }
205
206    pub(crate) fn get_mut(
207        &mut self,
208        lease_id: &ManagedWorktreeLeaseId,
209    ) -> Option<&mut ManagedWorktreeLeaseRecord> {
210        self.leases.get_mut(lease_id)
211    }
212
213    pub(crate) fn active_records(&self) -> impl Iterator<Item = &ManagedWorktreeLeaseRecord> {
214        self.leases.values()
215    }
216
217    pub(crate) fn git_scope(
218        &self,
219        workspace_id: &WorkspaceId,
220        branch: Option<&str>,
221    ) -> Option<ManagedWorktreeGitScope> {
222        let lease = self.leases.values().find(|lease| {
223            &lease.workspace_id == workspace_id && branch == Some(lease.branch.as_str())
224        })?;
225        if lease.source_workspace_id == *workspace_id || !lease.has_holders() {
226            return None;
227        }
228        Some(ManagedWorktreeGitScope {
229            lease_id: lease.lease_id.clone(),
230            source_workspace_id: lease.source_workspace_id.clone(),
231            branch: lease.branch.clone(),
232            base_commit: GitObjectId::new(lease.base_commit.clone()).ok()?,
233            active_session_count: u16::try_from(lease.session_holders.len()).unwrap_or(u16::MAX),
234            managed_record_count: u16::try_from(lease.record_holders.len()).unwrap_or(u16::MAX),
235        })
236    }
237
238    pub(crate) fn allocate(
239        &mut self,
240        source_workspace_id: WorkspaceId,
241        profile: &ManagedWorktreeProfile,
242        base_commit: String,
243        now: u64,
244    ) -> Result<ManagedWorktreeLeaseRecord, String> {
245        if self.leases.len() >= MAX_MANAGED_WORKTREE_LEASES {
246            return Err("managed worktree lease capacity is full".to_owned());
247        }
248        for _ in 0..8 {
249            let nonce = random_nonce()
250                .map_err(|error| format!("managed worktree identity generation failed: {error}"))?;
251            let suffix = hex_suffix(&nonce[..12]);
252            let lease_id = ManagedWorktreeLeaseId::new(format!("mw-{suffix}"))
253                .map_err(|error| error.to_string())?;
254            if self.leases.contains_key(&lease_id) || self.tombstones.contains_key(&lease_id) {
255                continue;
256            }
257            let workspace_id = WorkspaceId::new(format!("managed-{suffix}"))
258                .map_err(|error| error.to_string())?;
259            let target = PathBuf::from(profile.allocation_root()).join(lease_id.as_str());
260            let target_root = target.into_os_string().into_string().map_err(|path| {
261                format!("managed worktree target is not valid Unicode: {}", path.to_string_lossy())
262            })?;
263            let branch = format!("{}/{}", profile.branch_prefix(), lease_id.as_str());
264            let record = ManagedWorktreeLeaseRecord {
265                lease_id: lease_id.clone(),
266                source_workspace_id,
267                workspace_id,
268                profile_id: profile.profile_id().clone(),
269                profile_revision: profile.revision().clone(),
270                target_root,
271                branch,
272                base_commit,
273                expected_head: None,
274                retention: profile.retention(),
275                state: ManagedWorktreeLeaseState::Allocating,
276                session_holders: Vec::new(),
277                record_holders: Vec::new(),
278                cleanup_failure: None,
279                created_at_unix_ms: now,
280                updated_at_unix_ms: now,
281            };
282            self.leases.insert(lease_id, record.clone());
283            return Ok(record);
284        }
285        Err("could not allocate a unique managed worktree identity".to_owned())
286    }
287
288    pub(crate) fn remove_unmutated(&mut self, lease_id: &ManagedWorktreeLeaseId) {
289        self.leases.remove(lease_id);
290    }
291
292    pub(crate) fn tombstone(
293        &mut self,
294        lease_id: &ManagedWorktreeLeaseId,
295        now: u64,
296    ) -> Option<ManagedWorktreeLeaseRecord> {
297        let mut record = self.leases.remove(lease_id)?;
298        record.state = ManagedWorktreeLeaseState::Removed;
299        record.session_holders.clear();
300        record.record_holders.clear();
301        record.cleanup_failure = None;
302        record.updated_at_unix_ms = now;
303        while self.tombstones.len() >= MAX_MANAGED_WORKTREE_TOMBSTONES {
304            let oldest = self
305                .tombstones
306                .values()
307                .min_by_key(|item| (item.updated_at_unix_ms, item.lease_id.as_str()))
308                .map(|item| item.lease_id.clone());
309            if let Some(oldest) = oldest {
310                self.tombstones.remove(&oldest);
311            } else {
312                break;
313            }
314        }
315        self.tombstones.insert(record.lease_id.clone(), record.clone());
316        Some(record)
317    }
318
319    pub(crate) fn clear_stale_session_holders(&mut self, incarnation: NodeIncarnationId, now: u64) {
320        for lease in self.leases.values_mut() {
321            let previous = lease.session_holders.len();
322            lease.session_holders.retain(|holder| holder.incarnation_id == incarnation);
323            if previous != lease.session_holders.len() {
324                lease.updated_at_unix_ms = now;
325            }
326        }
327    }
328
329    pub(crate) fn bind_session(
330        &mut self,
331        lease_id: &ManagedWorktreeLeaseId,
332        holder: ManagedWorktreeSessionHolder,
333        record_id: Option<SessionRecordId>,
334        now: u64,
335    ) -> Result<ManagedWorktreeLeaseSnapshot, String> {
336        let lease = self.leases.get_mut(lease_id)
337            .ok_or_else(|| "managed worktree lease does not exist".to_owned())?;
338        if !lease.session_holders.contains(&holder) {
339            lease.session_holders.push(holder);
340        }
341        if let Some(record_id) = record_id {
342            if !lease.record_holders.contains(&record_id) {
343                lease.record_holders.push(record_id);
344            }
345        }
346        lease.state = ManagedWorktreeLeaseState::InUse;
347        lease.cleanup_failure = None;
348        lease.updated_at_unix_ms = now;
349        Ok(lease.snapshot())
350    }
351
352    pub(crate) fn release_session(
353        &mut self,
354        lease_id: &ManagedWorktreeLeaseId,
355        incarnation_id: NodeIncarnationId,
356        instance_id: AgentInstanceId,
357        generation: SessionGeneration,
358        now: u64,
359    ) -> Option<ManagedWorktreeLeaseSnapshot> {
360        let lease = self.leases.get_mut(lease_id)?;
361        lease.session_holders.retain(|holder| {
362            holder.incarnation_id != incarnation_id
363                || holder.instance_id != instance_id
364                || holder.generation != generation
365        });
366        if !lease.has_holders() && lease.state == ManagedWorktreeLeaseState::InUse {
367            lease.state = if lease.retention == ManagedWorktreeRetention::Retain {
368                ManagedWorktreeLeaseState::Retained
369            } else {
370                ManagedWorktreeLeaseState::Ready
371            };
372        }
373        lease.updated_at_unix_ms = now;
374        Some(lease.snapshot())
375    }
376
377    pub(crate) fn lease_for_workspace(&self, workspace_id: &WorkspaceId) -> Option<ManagedWorktreeLeaseId> {
378        self.leases.values().find(|lease| &lease.workspace_id == workspace_id)
379            .map(|lease| lease.lease_id.clone())
380    }
381
382    pub(crate) fn reattach_record_holders(&mut self, record_ids: &[(SessionRecordId, WorkspaceId)], now: u64) {
383        for lease in self.leases.values_mut() {
384            let next_holders = record_ids.iter()
385                .filter(|(_, workspace_id)| workspace_id == &lease.workspace_id)
386                .map(|(record_id, _)| record_id.clone())
387                .collect::<Vec<_>>();
388            let previous_holders = lease.record_holders.clone();
389            let previous_state = lease.state;
390            lease.record_holders = next_holders;
391            if !matches!(
392                lease.state,
393                ManagedWorktreeLeaseState::RecoveryRequired
394                    | ManagedWorktreeLeaseState::CleanupBlocked
395                    | ManagedWorktreeLeaseState::Removed
396                    | ManagedWorktreeLeaseState::Allocating
397            ) {
398                if lease.has_holders() {
399                    lease.state = ManagedWorktreeLeaseState::InUse;
400                } else if previous_state == ManagedWorktreeLeaseState::InUse {
401                    lease.state = if lease.retention == ManagedWorktreeRetention::Retain {
402                        ManagedWorktreeLeaseState::Retained
403                    } else {
404                        ManagedWorktreeLeaseState::Ready
405                    };
406                }
407            }
408            if lease.state != previous_state || lease.record_holders != previous_holders {
409                lease.updated_at_unix_ms = now;
410            }
411        }
412    }
413}
414
415pub(crate) fn exact_owned_worktree(
416    lease: &ManagedWorktreeLeaseRecord,
417    path: &str,
418    branch: Option<&str>,
419    _head: &str,
420) -> bool {
421    paths_equal(&lease.target_root, path)
422        && branch == Some(lease.branch.as_str())
423}
424
425pub(crate) fn exact_created_worktree(
426    lease: &ManagedWorktreeLeaseRecord,
427    path: &str,
428    branch: Option<&str>,
429    head: &str,
430) -> bool {
431    exact_owned_worktree(lease, path, branch, head)
432        && lease.expected_head.as_deref().unwrap_or(lease.base_commit.as_str()) == head
433}
434
435fn hex_suffix(bytes: &[u8]) -> String {
436    let mut value = String::with_capacity(bytes.len() * 2);
437    for byte in bytes {
438        use std::fmt::Write as _;
439        let _ = write!(&mut value, "{byte:02x}");
440    }
441    value
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use std::sync::atomic::{AtomicU64, Ordering};
448
449    static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
450
451    fn temporary_root(label: &str) -> PathBuf {
452        let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
453        std::env::temp_dir().join(format!(
454            "gate4agent-managed-worktree-{label}-{}-{sequence}",
455            std::process::id(),
456        ))
457    }
458
459    fn lease(
460        id: &str,
461        workspace: &str,
462        target: &str,
463        branch: &str,
464    ) -> ManagedWorktreeLeaseRecord {
465        ManagedWorktreeLeaseRecord {
466            lease_id: ManagedWorktreeLeaseId::new(id).unwrap(),
467            source_workspace_id: WorkspaceId::new("source").unwrap(),
468            workspace_id: WorkspaceId::new(workspace).unwrap(),
469            profile_id: WorktreeProfileId::new("default").unwrap(),
470            profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
471            target_root: target.to_owned(),
472            branch: branch.to_owned(),
473            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
474            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
475            retention: ManagedWorktreeRetention::RemoveWhenReleased,
476            state: ManagedWorktreeLeaseState::Ready,
477            session_holders: Vec::new(),
478            record_holders: Vec::new(),
479            cleanup_failure: None,
480            created_at_unix_ms: 1,
481            updated_at_unix_ms: 1,
482        }
483    }
484
485    #[test]
486    fn managed_profile_recanonicalizes_root_and_requires_exact_lease_child() {
487        let parent = temporary_root("profile");
488        let source = parent.join("source");
489        let allocation = parent.join("allocation");
490        std::fs::create_dir_all(&source).unwrap();
491        std::fs::create_dir_all(&allocation).unwrap();
492        let profile = ManagedWorktreeProfile::new(
493            WorktreeProfileId::new("default").unwrap(),
494            WorktreeProfileRevision::new("v1").unwrap(),
495            &allocation,
496            "gate4agent",
497            "HEAD",
498            ManagedWorktreeRetention::RemoveWhenReleased,
499        ).unwrap();
500        profile.validate_for_workspace(source.to_str().unwrap()).unwrap();
501        let lease_id = ManagedWorktreeLeaseId::new("mw-abc").unwrap();
502        let exact = PathBuf::from(profile.allocation_root()).join(lease_id.as_str());
503        profile.validate_target_authority(&lease_id, exact.to_str().unwrap()).unwrap();
504        let traversing = PathBuf::from(profile.allocation_root())
505            .join("ignored").join("..").join(lease_id.as_str());
506        assert!(profile.validate_target_authority(&lease_id, traversing.to_str().unwrap()).is_err());
507        std::fs::remove_dir_all(parent).unwrap();
508    }
509
510    #[test]
511    fn registry_rejects_duplicate_workspace_root_and_branch_authority() {
512        let first = lease("mw-a", "managed-a", "C:/trees/a", "gate4agent/a");
513        let mut duplicate_workspace = lease("mw-b", "managed-a", "C:/trees/b", "gate4agent/b");
514        assert!(ManagedWorktreeRegistry::from_records(
515            vec![first.clone(), duplicate_workspace.clone()], Vec::new(),
516        ).is_err());
517        duplicate_workspace.workspace_id = WorkspaceId::new("managed-b").unwrap();
518        duplicate_workspace.target_root = first.target_root.clone();
519        assert!(ManagedWorktreeRegistry::from_records(
520            vec![first.clone(), duplicate_workspace.clone()], Vec::new(),
521        ).is_err());
522        duplicate_workspace.target_root = "C:/trees/b".to_owned();
523        duplicate_workspace.branch = first.branch.clone();
524        assert!(ManagedWorktreeRegistry::from_records(
525            vec![first, duplicate_workspace], Vec::new(),
526        ).is_err());
527    }
528
529    #[test]
530    fn exact_current_incarnation_holder_release_transitions_without_head_fence() {
531        let mut registry = ManagedWorktreeRegistry::from_records(
532            vec![lease("mw-a", "managed-a", "C:/trees/a", "gate4agent/a")],
533            Vec::new(),
534        ).unwrap();
535        let lease_id = ManagedWorktreeLeaseId::new("mw-a").unwrap();
536        let current = NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]);
537        registry.bind_session(
538            &lease_id,
539            ManagedWorktreeSessionHolder {
540                incarnation_id: current,
541                instance_id: AgentInstanceId(7),
542                generation: SessionGeneration(2),
543            },
544            None,
545            2,
546        ).unwrap();
547        registry.release_session(
548            &lease_id,
549            NodeIncarnationId::from_bytes([2; crate::protocol::NODE_INCARNATION_ID_BYTES]),
550            AgentInstanceId(7),
551            SessionGeneration(2),
552            3,
553        );
554        assert_eq!(registry.get(&lease_id).unwrap().snapshot().active_session_count, 1);
555        registry.release_session(&lease_id, current, AgentInstanceId(7), SessionGeneration(2), 4);
556        assert_eq!(registry.get(&lease_id).unwrap().state, ManagedWorktreeLeaseState::Ready);
557        let record_id = SessionRecordId::new("sr-a").unwrap();
558        registry.reattach_record_holders(
559            &[(record_id, WorkspaceId::new("managed-a").unwrap())],
560            5,
561        );
562        assert_eq!(registry.get(&lease_id).unwrap().state, ManagedWorktreeLeaseState::InUse);
563        assert_eq!(registry.get(&lease_id).unwrap().updated_at_unix_ms, 5);
564        registry.reattach_record_holders(
565            &[(SessionRecordId::new("sr-a").unwrap(), WorkspaceId::new("managed-a").unwrap())],
566            9,
567        );
568        assert_eq!(registry.get(&lease_id).unwrap().updated_at_unix_ms, 5);
569        let record = registry.get(&lease_id).unwrap();
570        assert!(exact_owned_worktree(record, "C:/trees/a", Some("gate4agent/a"), "new-head"));
571        assert!(!exact_created_worktree(record, "C:/trees/a", Some("gate4agent/a"), "new-head"));
572    }
573
574    #[test]
575    fn managed_git_scope_requires_exact_active_workspace_and_branch() {
576        let mut record = lease("mw-a", "managed-a", "C:/trees/a", "gate4agent/a");
577        record.session_holders.push(ManagedWorktreeSessionHolder {
578            incarnation_id: NodeIncarnationId::from_bytes([
579                1;
580                crate::protocol::NODE_INCARNATION_ID_BYTES
581            ]),
582            instance_id: AgentInstanceId(7),
583            generation: SessionGeneration(2),
584        });
585        record.record_holders.push(SessionRecordId::new("sr-a").unwrap());
586        let registry = ManagedWorktreeRegistry::from_records(vec![record], Vec::new()).unwrap();
587        let workspace_id = WorkspaceId::new("managed-a").unwrap();
588        let scope = registry
589            .git_scope(&workspace_id, Some("gate4agent/a"))
590            .unwrap();
591        assert_eq!(scope.lease_id.as_str(), "mw-a");
592        assert_eq!(scope.source_workspace_id.as_str(), "source");
593        assert_eq!(scope.branch, "gate4agent/a");
594        assert_eq!(scope.active_session_count, 1);
595        assert_eq!(scope.managed_record_count, 1);
596        assert!(registry.git_scope(&workspace_id, Some("gate4agent/b")).is_none());
597        assert!(registry
598            .git_scope(&WorkspaceId::new("managed-b").unwrap(), Some("gate4agent/a"))
599            .is_none());
600    }
601}