Skip to main content

gate4agent_node/
worktree_service.rs

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