Skip to main content

wcode_protocol/
registry.rs

1//! In-process address book + router — the A2A bus (§8 #3, §10.1).
2//!
3//! Two pieces: an **address book** (`SessionId` → `Backend`: an in-process
4//! handle or a remote `Client`) and an **ownership** map (the `report_back_to`
5//! **ownership** map (the `report_back_to` edge: worker → orchestrator). Routing
6//! a message checks the **permitted set** — an orchestrator reaches its workers,
7//! a worker its orchestrator, and nothing else (v1 star, §10.1) — then delivers
8//! through the target's handle carrying the sender, so the recipient can
9//! attribute it (`AgentEvent::MessageReceived { from }`).
10//!
11//! Addressing lives *here*, one layer above the kernel: a [`Request`] is
12//! target-side and names no peer; the router turns a `to` address into a mailbox
13//! given the sender's scope. The future **group** mode widens only the permitted
14//! set (§10.1) — nothing else here changes.
15
16use std::collections::HashMap;
17use std::sync::{Arc, Mutex};
18
19use tokio::sync::watch;
20use wcode_harness::actor::SessionHandle;
21use wcode_harness::protocol::{MemberState, Request, SessionId};
22
23use crate::backend::Backend;
24#[cfg(unix)]
25use crate::client::Client;
26
27/// Why a message could not be routed (§10.1 enforcement).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RouteError {
30    /// No peer is registered at that address.
31    Unknown(SessionId),
32    /// The sender may not address that peer — outside the permitted set.
33    NotPermitted { from: SessionId, to: SessionId },
34    /// The target's session has shut down (its actor ended).
35    Closed,
36}
37
38impl std::fmt::Display for RouteError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            RouteError::Unknown(id) => write!(f, "no peer at {id}"),
42            RouteError::NotPermitted { from, to } => {
43                write!(f, "{from} may not address {to}")
44            }
45            RouteError::Closed => f.write_str("the target session is closed"),
46        }
47    }
48}
49
50impl std::error::Error for RouteError {}
51
52/// An in-process address book and router over session mailboxes.
53///
54/// Cloneable and shared: every clone refers to the same registry. There is no
55/// automatic deregistration — a peer that shuts down stays listed, and a delivery
56/// to it fails with [`RouteError::Closed`] (a sweep can come later; §10.1 keeps
57/// this minimal).
58#[derive(Clone, Default)]
59pub struct Registry {
60    inner: Arc<Inner>,
61}
62
63struct Inner {
64    peers: Mutex<HashMap<SessionId, Backend>>,
65    owners: Mutex<HashMap<SessionId, SessionId>>,
66    /// Each session's effective model id (see [`Registry::set_model`]), so a
67    /// socket server can name a member's model in the roster it pushes.
68    models: Mutex<HashMap<SessionId, String>>,
69    /// Server-side liveness per session, mirrored from the actor's `watch` cell
70    /// so the roster push (and the `Sessions` reply) can name it — the twin of
71    /// `models`. Cheap: one entry per served session. `Idle` for a session the
72    /// registry never watched, or an older peer (a `state`-less `SessionInfo`).
73    states: Mutex<HashMap<SessionId, MemberState>>,
74    /// Each session's effective effort (see [`Registry::set_effort`]).
75    efforts: Mutex<HashMap<SessionId, Option<String>>>,
76    /// Each session's context window in tokens (see [`Registry::set_context_window`]).
77    windows: Mutex<HashMap<SessionId, u64>>,
78    /// The provider's advertised catalog — ONE value for the whole registry
79    /// (connection), not per session (see [`Registry::set_models`]).
80    catalog: Mutex<Vec<String>>,
81    /// The live **local** roster (see [`Registry::locals`]), refreshed on every
82    /// `register`/`register_remote` so a socket server can fan a session that
83    /// appears after it started. Read via [`Registry::subscribe`].
84    roster: watch::Sender<Vec<(SessionId, SessionHandle)>>,
85}
86
87impl Default for Inner {
88    fn default() -> Self {
89        Self {
90            peers: Mutex::new(HashMap::new()),
91            owners: Mutex::new(HashMap::new()),
92            models: Mutex::new(HashMap::new()),
93            states: Mutex::new(HashMap::new()),
94            efforts: Mutex::new(HashMap::new()),
95            windows: Mutex::new(HashMap::new()),
96            catalog: Mutex::new(Vec::new()),
97            roster: watch::channel(Vec::new()).0,
98        }
99    }
100}
101
102impl Registry {
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Register a peer's mailbox at `id`; re-registering replaces it.
108    pub fn register(&self, id: SessionId, handle: SessionHandle) {
109        // Mirror the session's run-state into the roster: the registry keeps
110        // NO back-reference into the kernel (layering) — it watches the
111        // handle's `state_rx()` and re-publishes on every transition, so the
112        // socket server pushes the change without polling. One task per served
113        // session (a handful); it ends when the session drops.
114        let mut state_rx = handle.state_rx();
115        self.inner
116            .peers
117            .lock()
118            .unwrap()
119            .insert(id.clone(), Backend::Local(handle));
120        let registry = self.clone();
121        tokio::spawn(async move {
122            while state_rx.changed().await.is_ok() {
123                registry.set_state(id.clone(), *state_rx.borrow_and_update());
124            }
125        });
126        self.refresh_roster();
127    }
128
129    /// Register a peer reachable **across a socket** — a served session this
130    /// process can message, and which can reach back if it knows us (§8, S4-4).
131    #[cfg(unix)]
132    pub fn register_remote(&self, id: SessionId, client: Client) {
133        self.inner
134            .peers
135            .lock()
136            .unwrap()
137            .insert(id, Backend::Remote(client.clone()));
138        // Remote liveness rides the peer's own roster push: a served peer
139        // stamps each `SessionInfo` with a `state`, so mirroring the remote
140        // client's roster into `states` makes `state_of` reflect a remote
141        // member with no back-channel of our own.
142        let registry = self.clone();
143        let mut roster = client.subscribe_roster();
144        tokio::spawn(async move {
145            while roster.changed().await.is_ok() {
146                let sessions = roster.borrow_and_update().clone();
147                for info in &sessions {
148                    registry.set_state(info.id.clone(), info.state);
149                }
150            }
151        });
152        self.refresh_roster();
153    }
154
155    /// The **local** peers — the in-process handles this registry holds, sorted
156    /// by address for a deterministic roster. A socket server serves exactly
157    /// these ([`Self::register`]ed sessions: the root and its team); a peer
158    /// reached across a socket ([`Self::register_remote`]) is not a local handle
159    /// and is omitted, so it is never re-served.
160    pub fn locals(&self) -> Vec<(SessionId, SessionHandle)> {
161        let mut locals: Vec<(SessionId, SessionHandle)> = self
162            .inner
163            .peers
164            .lock()
165            .unwrap()
166            .iter()
167            .filter_map(|(id, backend)| match backend {
168                Backend::Local(handle) => Some((id.clone(), handle.clone())),
169                #[cfg(unix)]
170                Backend::Remote(_) => None,
171            })
172            .collect();
173        locals.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
174        locals
175    }
176
177    /// Remove `id` from the address book and every per-session map, then
178    /// re-publish the roster.
179    ///
180    /// Drops the `id` entry from `peers` (the address book), `owners`, `models`,
181    /// `states`, `efforts`, and `windows`, then calls [`Self::refresh_roster`]
182    /// so every [`Self::subscribe`]r — a socket server, the TUI — pushes the
183    /// SHRUNK roster.
184    ///
185    /// # Notes
186    /// - **The per-session state watcher** `register` spawned (`tokio::spawn` of
187    ///   a `while state_rx.changed() …` loop) ends only when the session drops.
188    ///   It can re-insert a `states` entry after this returns, but NEVER a
189    ///   `peers` entry — so [`Self::locals`] (which reads `peers`) stays clean
190    ///   and [`Self::contains`]/[`Self::resolve`] report the id gone at once.
191    ///   Documented, not fixed (layering: no back-ref).
192    /// - Removing an unknown `id` is a harmless no-op (`refresh_roster` still runs).
193    ///
194    /// Not idempotent-erroring and not socket-routed: this is the ROOT-LOCAL
195    /// retire path (no `Request` ends a served peer's session).
196    pub fn remove(&self, id: &SessionId) {
197        self.inner.peers.lock().unwrap().remove(id);
198        self.inner.owners.lock().unwrap().remove(id);
199        self.inner.models.lock().unwrap().remove(id);
200        self.inner.states.lock().unwrap().remove(id);
201        self.inner.efforts.lock().unwrap().remove(id);
202        self.inner.windows.lock().unwrap().remove(id);
203        self.refresh_roster();
204    }
205
206    /// A live view of [`Self::locals`]: a receiver **seeded** with the current
207    /// local roster and updated whenever it changes (a session is
208    /// `register`ed). A socket server serves it, so a session that appears
209    /// *after* the server started is still reachable — see [`crate::serve`].
210    pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
211        self.inner.roster.subscribe()
212    }
213
214    /// Recompute the local roster and publish it to every [`Self::subscribe`]r.
215    /// `send_replace` stores the value even when nobody is watching yet, so a
216    /// session registered *before* the first subscriber — a `[team]` member that
217    /// starts ahead of the socket server — still seeds a later [`Self::subscribe`].
218    /// (`watch::Sender::send` would drop it: it is a no-op with no receivers.)
219    fn refresh_roster(&self) {
220        let _ = self.inner.roster.send_replace(self.locals());
221    }
222
223    /// Whether a peer is already registered at `id`. A plain existence probe —
224    /// unlike [`Self::resolve`], it needs no `from` and enforces no permitted set.
225    pub fn contains(&self, id: &SessionId) -> bool {
226        self.inner.peers.lock().unwrap().contains_key(id)
227    }
228
229    /// Record the `report_back_to` edge: `worker` reports to `owner` (its
230    /// orchestrator). This is the whole ownership tree — a star in v1.
231    pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
232        self.inner.owners.lock().unwrap().insert(worker, owner);
233    }
234
235    /// Record a session's effective model id, so the roster this registry feeds
236    /// (a socket server's `Sessions` push) can name it. Mirrors [`Self::set_owner`]
237    /// — a plain metadata write, no effect on routing; it RE-PUBLISHES the roster.
238    pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
239        self.inner.models.lock().unwrap().insert(id, model.into());
240        self.refresh_roster();
241    }
242
243    /// Mirror a session's run-state. Called by the per-session watcher
244    /// `register` spawns — NOT by the actor, so the registry keeps NO
245    /// back-reference into the kernel (layering). Re-publishes the roster, so
246    /// the socket server pushes the change without polling.
247    pub fn set_state(&self, id: SessionId, state: MemberState) {
248        self.inner.states.lock().unwrap().insert(id, state);
249        self.refresh_roster();
250    }
251
252    /// The state last mirrored for `id`; `Idle` when unknown (a remote peer the
253    /// registry never watched, or an older server).
254    pub fn state_of(&self, id: &SessionId) -> MemberState {
255        *self
256            .inner
257            .states
258            .lock()
259            .unwrap()
260            .get(id)
261            .unwrap_or(&MemberState::Idle)
262    }
263    /// The model recorded for `id`, if any ([`Self::set_model`]). `None` for a
264    /// peer registered without one (e.g. a remote reached across a socket).
265    pub fn model_of(&self, id: &SessionId) -> Option<String> {
266        self.inner.models.lock().unwrap().get(id).cloned()
267    }
268
269    /// Record a session's effective effort. A metadata write, no routing effect —
270    /// but it RE-PUBLISHES the roster, so a socket push reflects it.
271    pub fn set_effort(&self, id: SessionId, effort: Option<String>) {
272        self.inner.efforts.lock().unwrap().insert(id, effort);
273        self.refresh_roster();
274    }
275
276    /// The effort last recorded for `id`; `None` when unset OR cleared.
277    pub fn effort_of(&self, id: &SessionId) -> Option<String> {
278        self.inner.efforts.lock().unwrap().get(id).cloned().flatten()
279    }
280
281    /// Record a session's context window in tokens. RE-PUBLISHES the roster.
282    pub fn set_context_window(&self, id: SessionId, window: u64) {
283        self.inner.windows.lock().unwrap().insert(id, window);
284        self.refresh_roster();
285    }
286
287    /// The window last recorded for `id`; `None` when unknown.
288    pub fn window_of(&self, id: &SessionId) -> Option<u64> {
289        self.inner.windows.lock().unwrap().get(id).copied()
290    }
291
292    /// Record the connection's model catalog. Does NOT re-publish — the catalog
293    /// names no session, so it is not roster data.
294    pub fn set_models(&self, models: Vec<String>) {
295        *self.inner.catalog.lock().unwrap() = models;
296    }
297
298    /// The recorded catalog (empty when unset — `ListModels` then answers `[]`).
299    pub fn models(&self) -> Vec<String> {
300        self.inner.catalog.lock().unwrap().clone()
301    }
302
303    /// Whether `from` may address `to` (§10.1): the pair must be joined by an
304    /// ownership edge in either direction — an orchestrator and one of its
305    /// workers. There are no lateral (worker → worker) edges.
306    pub fn permitted(&self, from: &SessionId, to: &SessionId) -> bool {
307        let owners = self.inner.owners.lock().unwrap();
308        owners.get(from) == Some(to) || owners.get(to) == Some(from)
309    }
310
311    /// Resolve `to` for `from`, enforcing the permitted set. Fails
312    /// [`RouteError::Unknown`] if no peer is registered, else
313    /// [`RouteError::NotPermitted`] if the sender is out of scope.
314    pub fn resolve(&self, from: &SessionId, to: &SessionId) -> Result<Backend, RouteError> {
315        let endpoint = self
316            .inner
317            .peers
318            .lock()
319            .unwrap()
320            .get(to)
321            .cloned()
322            .ok_or_else(|| RouteError::Unknown(to.clone()))?;
323        if !self.permitted(from, to) {
324            return Err(RouteError::NotPermitted {
325                from: from.clone(),
326                to: to.clone(),
327            });
328        }
329        Ok(endpoint)
330    }
331
332    /// Deliver a target-side `request` to `to`, attributed to `from`. The
333    /// recipient sees it as if `from` had sent it directly
334    /// ([`SessionHandle::send_from`]).
335    pub fn deliver(
336        &self,
337        from: &SessionId,
338        to: &SessionId,
339        request: Request,
340    ) -> Result<(), RouteError> {
341        let endpoint = self.resolve(from, to)?;
342        endpoint
343            .send_from(from.clone(), request)
344            .map_err(|_| RouteError::Closed)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use std::time::Duration;
352
353    use wcode_harness::actor::SessionActor;
354    use wcode_harness::agent::{Agent, AgentConfig};
355    use wcode_harness::compaction::CompactionPolicy;
356    use wcode_harness::event::{AgentEvent, LlmStreamEvent};
357    use wcode_harness::hooks::HooksSet;
358    use wcode_harness::loop_::DEFAULT_MAX_TURNS;
359    use wcode_harness::streamfn::{LlmOpts, LlmStream, StreamFn};
360
361    use futures::StreamExt as _;
362
363    /// A live session whose model never streams — enough for routing tests (the
364    /// messages we send are `Notify`, which run no turn).
365    fn session() -> SessionHandle {
366        session_with(Arc::new(|_ctx, _sys, _tools, _opts| {
367            Box::pin(futures::stream::empty()) as LlmStream
368        }))
369    }
370
371    /// A session over a caller-supplied stream — for tests that drive a real
372    /// turn (a hang, a stream error), not just routing.
373    fn session_with(stream_fn: StreamFn) -> SessionHandle {
374        SessionActor::spawn(Agent::new(AgentConfig {
375            system: "sys".into(),
376            tools: Vec::new(),
377            llm: LlmOpts {
378                model: "m".into(),
379                ..LlmOpts::default()
380            },
381            stream_fn,
382            hooks: HooksSet::default(),
383            session: None,
384            context: Vec::new(),
385            working_dir: std::path::PathBuf::new(),
386            max_turns: DEFAULT_MAX_TURNS,
387            parallel_tools: true,
388            compaction: CompactionPolicy::default(),
389            plan_mode: wcode_harness::hooks::PlanModeHandle::new(),
390        }))
391    }
392
393    async fn next_message(
394        rx: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
395    ) -> AgentEvent {
396        tokio::time::timeout(Duration::from_secs(2), rx.recv())
397            .await
398            .expect("an event")
399            .expect("open")
400    }
401
402    #[tokio::test]
403    async fn orchestrator_and_worker_reach_each_other() {
404        let reg = Registry::new();
405        let orch = SessionId::agent("orch");
406        let worker = SessionId::agent("w1");
407        let h_orch = session();
408        let h_worker = session();
409        reg.register(orch.clone(), h_orch.clone());
410        reg.register(worker.clone(), h_worker.clone());
411        reg.set_owner(worker.clone(), orch.clone());
412
413        // Orchestrator → worker: the worker receives it, attributed to the orch.
414        let mut rx = h_worker.subscribe();
415        reg.deliver(
416            &orch,
417            &worker,
418            Request::Notify {
419                content: "do the thing".into(),
420            },
421        )
422        .unwrap();
423        match next_message(&mut rx).await {
424            AgentEvent::MessageReceived { from, content } => {
425                assert_eq!(from, orch);
426                assert_eq!(content, "do the thing");
427            }
428            other => panic!("expected MessageReceived, got {other:?}"),
429        }
430
431        // Worker → orchestrator (report back): permitted.
432        let mut rx = h_orch.subscribe();
433        reg.deliver(
434            &worker,
435            &orch,
436            Request::Notify {
437                content: "done".into(),
438            },
439        )
440        .unwrap();
441        match next_message(&mut rx).await {
442            AgentEvent::MessageReceived { from, content } => {
443                assert_eq!(from, worker);
444                assert_eq!(content, "done");
445            }
446            other => panic!("expected MessageReceived, got {other:?}"),
447        }
448    }
449
450    #[tokio::test]
451    async fn lateral_message_between_workers_is_refused() {
452        let reg = Registry::new();
453        let orch = SessionId::agent("orch");
454        let w1 = SessionId::agent("w1");
455        let w2 = SessionId::agent("w2");
456        for id in [&orch, &w1, &w2] {
457            reg.register(id.clone(), session());
458        }
459        reg.set_owner(w1.clone(), orch.clone());
460        reg.set_owner(w2.clone(), orch.clone());
461
462        assert_eq!(
463            reg.deliver(
464                &w1,
465                &w2,
466                Request::Notify {
467                    content: "psst".into()
468                }
469            ),
470            Err(RouteError::NotPermitted {
471                from: w1.clone(),
472                to: w2.clone()
473            })
474        );
475        assert!(!reg.permitted(&w1, &w2));
476    }
477
478    #[tokio::test]
479    async fn unknown_target_is_reported() {
480        let reg = Registry::new();
481        let orch = SessionId::agent("orch");
482        reg.register(orch.clone(), session());
483
484        let ghost = SessionId::agent("ghost");
485        assert_eq!(
486            reg.deliver(&orch, &ghost, Request::Notify { content: "?".into() }),
487            Err(RouteError::Unknown(ghost))
488        );
489    }
490
491    #[tokio::test]
492    async fn locals_lists_only_local_peers_sorted_by_id() {
493        let reg = Registry::new();
494        let b = SessionId::agent("b");
495        let a = SessionId::agent("a");
496        reg.register(b.clone(), session());
497        reg.register(a.clone(), session());
498        #[cfg(unix)]
499        reg.register_remote(
500            SessionId::agent("remote"),
501            Client::lazy(std::path::Path::new("/nonexistent-t2.sock")),
502        );
503        let ids: Vec<SessionId> = reg.locals().into_iter().map(|(id, _)| id).collect();
504        assert_eq!(ids, vec![a, b]);
505    }
506
507    #[tokio::test]
508    async fn set_model_is_read_back_per_id() {
509        let reg = Registry::new();
510        let w1 = SessionId::agent("w1");
511        let ghost = SessionId::agent("ghost");
512        assert_eq!(reg.model_of(&w1), None, "unset id has no model");
513        reg.set_model(w1.clone(), "m1");
514        assert_eq!(reg.model_of(&w1).as_deref(), Some("m1"));
515        assert_eq!(reg.model_of(&ghost), None);
516    }
517
518    /// Await the next roster change, failing the test if it never comes.
519    async fn next_change(rx: &mut watch::Receiver<Vec<(SessionId, SessionHandle)>>) {
520        tokio::time::timeout(Duration::from_secs(2), rx.changed())
521            .await
522            .expect("a roster change")
523            .expect("open");
524    }
525
526    #[tokio::test]
527    async fn set_effort_window_catalog_are_read_back() {
528        let reg = Registry::new();
529        let w1 = SessionId::agent("w1");
530        assert_eq!(reg.effort_of(&w1), None);
531        reg.set_effort(w1.clone(), Some("high".into()));
532        assert_eq!(reg.effort_of(&w1).as_deref(), Some("high"));
533        reg.set_effort(w1.clone(), None); // cleared == None
534        assert_eq!(reg.effort_of(&w1), None);
535
536        assert_eq!(reg.window_of(&w1), None);
537        reg.set_context_window(w1.clone(), 200_000);
538        assert_eq!(reg.window_of(&w1), Some(200_000));
539
540        assert_eq!(reg.models(), Vec::<String>::new());
541        reg.set_models(vec!["m1".into(), "m2".into()]);
542        assert_eq!(reg.models(), vec!["m1".to_string(), "m2".to_string()]);
543    }
544
545    #[tokio::test]
546    async fn set_model_and_set_effort_republish_the_roster() {
547        let reg = Registry::new();
548        let w1 = SessionId::agent("w1");
549        let mut rx = reg.subscribe();
550        reg.set_model(w1.clone(), "m1"); // NEW: re-publishes
551        next_change(&mut rx).await;
552        reg.set_effort(w1.clone(), Some("low".into()));
553        next_change(&mut rx).await;
554        reg.set_context_window(w1.clone(), 1_000);
555        next_change(&mut rx).await;
556    }
557
558    #[tokio::test]
559    async fn set_state_is_read_back_and_republishes() {
560        let reg = Registry::new();
561        let w1 = SessionId::agent("w1");
562        assert_eq!(reg.state_of(&w1), MemberState::Idle, "unset id reads Idle");
563
564        let mut rx = reg.subscribe();
565        reg.set_state(w1.clone(), MemberState::Running);
566        assert_eq!(reg.state_of(&w1), MemberState::Running);
567        // `set_state` re-publishes, so a subscriber (the server's push) wakes.
568        tokio::time::timeout(Duration::from_secs(2), rx.changed())
569            .await
570            .expect("a roster change")
571            .expect("open");
572    }
573
574    #[tokio::test]
575    async fn register_mirrors_the_actor_state_into_the_roster() {
576        // A stream that emits one delta then hangs: Running is observable until
577        // a cancel ends the run.
578        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
579            let head =
580                futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
581            Box::pin(head.chain(futures::stream::pending())) as LlmStream
582        });
583        let handle = session_with(stream_fn);
584        let reg = Registry::new();
585        let w1 = SessionId::agent("w1");
586        reg.register(w1.clone(), handle.clone());
587        assert_eq!(reg.state_of(&w1), MemberState::Idle, "a virgin session is idle");
588
589        handle.send(Request::Submit { text: "hi".into() }).unwrap();
590        let mut running = false;
591        for _ in 0..500 {
592            if reg.state_of(&w1) == MemberState::Running {
593                running = true;
594                break;
595            }
596            tokio::time::sleep(Duration::from_millis(10)).await;
597        }
598        assert!(running, "the registry mirrors the actor's Running");
599
600        handle.send(Request::Cancel).unwrap();
601        let mut done = false;
602        for _ in 0..500 {
603            if reg.state_of(&w1) == MemberState::Done {
604                done = true;
605                break;
606            }
607            tokio::time::sleep(Duration::from_millis(10)).await;
608        }
609        assert!(done, "the registry mirrors the actor's Done");
610    }
611
612    #[tokio::test]
613    async fn subscribe_yields_the_new_roster_after_register() {
614        let reg = Registry::new();
615        let mut rx = reg.subscribe();
616        let w1 = SessionId::agent("w1");
617        reg.register(w1.clone(), session());
618        rx.changed().await.unwrap();
619        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
620        assert_eq!(ids, vec![w1]);
621    }
622
623    #[tokio::test]
624    async fn subscribe_seeds_a_session_registered_before_it() {
625        // A session registered with **no** subscriber in existence must still be
626        // visible to a `subscribe()` that starts later — the `[team]` startup
627        // members, registered before the socket server's first `subscribe()`.
628        // (`watch::Sender::send` drops a value with no receivers; `send_replace`
629        // keeps it, which is what `refresh_roster` relies on.)
630        let reg = Registry::new();
631        let w1 = SessionId::agent("w1");
632        reg.register(w1.clone(), session());
633        let rx = reg.subscribe();
634        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
635        assert_eq!(ids, vec![w1]);
636    }
637
638    #[tokio::test]
639    async fn an_unowned_pair_is_not_permitted() {
640        let reg = Registry::new();
641        let a = SessionId::agent("a");
642        let b = SessionId::agent("b");
643        reg.register(a.clone(), session());
644        reg.register(b.clone(), session());
645        // No ownership edge recorded → they may not address each other.
646        assert_eq!(
647            reg.deliver(&a, &b, Request::Notify { content: "?".into() }),
648            Err(RouteError::NotPermitted {
649                from: a.clone(),
650                to: b.clone()
651            })
652        );
653    }
654    #[tokio::test]
655    async fn remove_drops_peers_states_models_and_republishes() {
656        let reg = Registry::new();
657        let orch = SessionId::agent("orch");
658        let w1 = SessionId::agent("w1");
659        reg.register(w1.clone(), session());
660        reg.set_owner(w1.clone(), orch.clone());
661        reg.set_model(w1.clone(), "m1");
662        reg.set_effort(w1.clone(), Some("high".into()));
663        reg.set_context_window(w1.clone(), 1_000);
664        reg.set_state(w1.clone(), MemberState::Running);
665        assert!(reg.contains(&w1));
666
667        let mut rx = reg.subscribe();
668        reg.remove(&w1);
669
670        // Every per-session map is cleared...
671        assert!(!reg.contains(&w1), "peers entry dropped");
672        assert_eq!(reg.state_of(&w1), MemberState::Idle, "state cleared");
673        assert_eq!(reg.model_of(&w1), None, "model cleared");
674        assert_eq!(reg.effort_of(&w1), None, "effort cleared");
675        assert_eq!(reg.window_of(&w1), None, "window cleared");
676        // ...and routing reports it gone (unknown, not merely unpermitted).
677        assert!(
678            matches!(reg.resolve(&orch, &w1), Err(RouteError::Unknown(id)) if id == w1),
679            "an unknown route after remove"
680        );
681        assert!(reg.locals().is_empty(), "the local roster excludes it");
682        // A subscriber (the socket server, the TUI) WOKE with the smaller list.
683        next_change(&mut rx).await;
684        assert!(rx.borrow().is_empty(), "the shrunk roster was published");
685    }
686
687    #[tokio::test]
688    async fn remove_of_an_unknown_id_is_a_no_op() {
689        let reg = Registry::new();
690        let ghost = SessionId::agent("ghost");
691        // No panic and no error; it simply re-publishes the (unchanged) roster.
692        reg.remove(&ghost);
693        assert!(!reg.contains(&ghost));
694    }
695}