@@ -43,6 +43,36 @@ impl CarrierClass {
4343 }
4444 }
4545}
46+ /// Catalog-observable resync coverage for one declared Resource binding.
47+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
48+ pub enum ResyncCoverage {
49+ Immediate ,
50+ Coalesced ,
51+ Silent ,
52+ Unsupported ,
53+ Inactive ,
54+ }
55+
56+ impl ResyncCoverage {
57+ pub const fn as_str ( self ) -> & ' static str {
58+ match self {
59+ Self :: Immediate => "immediate" ,
60+ Self :: Coalesced => "coalesced" ,
61+ Self :: Silent => "silent" ,
62+ Self :: Unsupported => "unsupported" ,
63+ Self :: Inactive => "inactive" ,
64+ }
65+ }
66+
67+ fn carrier_class ( self ) -> Option < CarrierClass > {
68+ match self {
69+ Self :: Immediate => Some ( CarrierClass :: Immediate ) ,
70+ Self :: Coalesced => Some ( CarrierClass :: Coalesced ) ,
71+ Self :: Silent | Self :: Unsupported | Self :: Inactive => None ,
72+ }
73+ }
74+ }
75+
4676
4777/// One watchable local carrier: binding label, absolute path, notification class.
4878#[ derive( Debug , Clone , PartialEq , Eq ) ]
@@ -73,15 +103,11 @@ pub fn watch_set_for(spec: &AgentSpec, this_host: &str) -> AgentWatchSet {
73103 class: CarrierClass :: Immediate ,
74104 } ] ;
75105 for resource in & spec. resources {
76- if resource. inactive_reason ( ) . is_some ( ) {
77- continue ;
78- }
79- let Some ( path) = resolve_local_path ( agent_dir, resource. uri ( ) ) else {
80- continue ;
81- } ;
82- let Some ( class) = classify ( agent_dir, resource. name ( ) , & path) else {
106+ let Some ( class) = resource_coverage ( agent_dir, resource) . carrier_class ( ) else {
83107 continue ;
84108 } ;
109+ let path = resolve_local_path ( agent_dir, resource. uri ( ) )
110+ . expect ( "watchable coverage must have a local path" ) ;
85111 carriers. push ( WatchableCarrier {
86112 label : resource. name ( ) . to_owned ( ) ,
87113 path,
@@ -133,6 +159,21 @@ fn resolve_local_path(agent_dir: &Path, uri: &str) -> Option<PathBuf> {
133159 let path = PathBuf :: from ( decode_percent_path ( uri) . ok ( ) ?) ;
134160 Some ( lexical_clean ( & agent_dir. join ( path) ) )
135161}
162+ /// Resolve the externally visible resync coverage for one Resource binding.
163+ pub fn resource_coverage ( agent_dir : & Path , resource : & agent_spec:: spec:: Resource ) -> ResyncCoverage {
164+ if resource. inactive_reason ( ) . is_some ( ) {
165+ return ResyncCoverage :: Inactive ;
166+ }
167+ let Some ( path) = resolve_local_path ( agent_dir, resource. uri ( ) ) else {
168+ return ResyncCoverage :: Unsupported ;
169+ } ;
170+ match classify ( agent_dir, resource. name ( ) , & path) {
171+ Some ( CarrierClass :: Immediate ) => ResyncCoverage :: Immediate ,
172+ Some ( CarrierClass :: Coalesced ) => ResyncCoverage :: Coalesced ,
173+ None => ResyncCoverage :: Silent ,
174+ }
175+ }
176+
136177
137178/// Remove `.` and `..` components lexically. This deliberately does not inspect the filesystem:
138179/// classification follows the authored path structure without resolving symlinks.
@@ -190,6 +231,7 @@ struct WatchRefresh {
190231enum Msg {
191232 WatchSet ( WatchRefresh ) ,
192233 Install ( AgentWatchSet , Sender < ( ) > ) ,
234+ Deactivate ( String , Sender < ( ) > ) ,
193235 Mutations ( Vec < PathBuf > ) ,
194236 Rescan ,
195237 /// Explicit stop: the worker's own watcher holds the last `Sender`, so `Disconnected`
@@ -267,6 +309,19 @@ impl ResyncSupervisor {
267309 let _ = ack_rx. recv ( ) ;
268310 }
269311 }
312+
313+ /// Synchronously remove a canonical seat's active subscriptions before relaunch work begins.
314+ /// Sequence floors remain retained so a later successful install cannot reuse an occurrence.
315+ pub fn deactivate ( & self , spec : & AgentSpec , this_host : & str ) {
316+ let ( ack_tx, ack_rx) = channel ( ) ;
317+ if self
318+ . tx
319+ . as_ref ( )
320+ . is_some_and ( |tx| tx. send ( Msg :: Deactivate ( spec. bus_id ( this_host) , ack_tx) ) . is_ok ( ) )
321+ {
322+ let _ = ack_rx. recv ( ) ;
323+ }
324+ }
270325}
271326
272327impl Drop for ResyncSupervisor {
@@ -422,6 +477,10 @@ fn worker_loop(root: PathBuf, this_host: String, rx: Receiver<Msg>, forward: Sen
422477 worker. install_watch_set ( set) ;
423478 let _ = ack. send ( ( ) ) ;
424479 }
480+ Ok ( Msg :: Deactivate ( bus_id, ack) ) => {
481+ worker. deactivate_watch_set ( & bus_id) ;
482+ let _ = ack. send ( ( ) ) ;
483+ }
425484 Ok ( Msg :: Mutations ( paths) ) => worker. mark_mutated ( paths) ,
426485 Ok ( Msg :: Rescan ) => worker. rescan_all ( ) ,
427486 Ok ( Msg :: Shutdown ) => break ,
@@ -620,6 +679,22 @@ impl Worker {
620679 self . finish_carrier_update ( & previous_paths) ;
621680 }
622681
682+ fn deactivate_watch_set ( & mut self , bus_id : & str ) {
683+ let previous = std:: mem:: take ( & mut self . carriers ) ;
684+ let previous_paths = self . prepare_carrier_update ( & previous) ;
685+ self . carriers = previous
686+ . into_iter ( )
687+ . filter_map ( |( path, entries) | {
688+ let retained = entries
689+ . into_iter ( )
690+ . filter ( |entry| entry. bus_id != bus_id)
691+ . collect :: < Vec < _ > > ( ) ;
692+ ( !retained. is_empty ( ) ) . then_some ( ( path, retained) )
693+ } )
694+ . collect ( ) ;
695+ self . finish_carrier_update ( & previous_paths) ;
696+ }
697+
623698 fn reconcile_dirty_deadlines ( & mut self , now : Instant ) {
624699 let dirty_classes = self
625700 . carriers
@@ -1033,18 +1108,30 @@ mod tests {
10331108 host "hetz"
10341109 command "true"
10351110 resource "goal" uri="resources/goal.md" reason="Mission."
1111+ resource "journal" uri="resources/context/journal.md" reason="Memory."
10361112 resource "issue" uri="github-issue://org/repo/41" reason="Task."
1113+ resource "old" uri="resources/old.md" reason="History." inactive-reason="No longer used."
10371114}"# ,
10381115 )
10391116 . unwrap ( ) ;
1040- let set = watch_set_for ( & discover ( tmp. path ( ) ) , "hetz" ) ;
1117+ let spec = discover ( tmp. path ( ) ) ;
1118+ let set = watch_set_for ( & spec, "hetz" ) ;
10411119 assert_eq ! ( set. bus_id, "hetz.worker" ) ;
10421120 let mut labels: Vec < & str > = set. carriers . iter ( ) . map ( |c| c. label . as_str ( ) ) . collect ( ) ;
10431121 labels. sort ( ) ;
10441122 assert_eq ! ( labels, vec![ "declaration" , "goal" ] ) ;
10451123 let goal = set. carriers . iter ( ) . find ( |c| c. label == "goal" ) . unwrap ( ) ;
10461124 assert_eq ! ( goal. class, CarrierClass :: Immediate ) ;
10471125 assert_eq ! ( goal. path, dir. join( "resources/goal.md" ) ) ;
1126+ let coverage = spec
1127+ . resources
1128+ . iter ( )
1129+ . map ( |resource| ( resource. name ( ) , resource_coverage ( & dir, resource) ) )
1130+ . collect :: < BTreeMap < _ , _ > > ( ) ;
1131+ assert_eq ! ( coverage[ "goal" ] , ResyncCoverage :: Immediate ) ;
1132+ assert_eq ! ( coverage[ "journal" ] , ResyncCoverage :: Silent ) ;
1133+ assert_eq ! ( coverage[ "issue" ] , ResyncCoverage :: Unsupported ) ;
1134+ assert_eq ! ( coverage[ "old" ] , ResyncCoverage :: Inactive ) ;
10481135 }
10491136
10501137 #[ test]
0 commit comments