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