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            // W031 test double: the kernel default, like every non-CLI site.
426            title_after_turns: wcode_harness::title::DEFAULT_TITLE_AFTER_TURNS,
427        }))
428    }
429
430    async fn next_message(
431        rx: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
432    ) -> AgentEvent {
433        tokio::time::timeout(Duration::from_secs(2), rx.recv())
434            .await
435            .expect("an event")
436            .expect("open")
437    }
438
439    #[tokio::test]
440    async fn orchestrator_and_worker_reach_each_other() {
441        let reg = Registry::new();
442        let orch = SessionId::agent("orch");
443        let worker = SessionId::agent("w1");
444        let h_orch = session();
445        let h_worker = session();
446        reg.register(orch.clone(), h_orch.clone());
447        reg.register(worker.clone(), h_worker.clone());
448        reg.set_owner(worker.clone(), orch.clone());
449
450        // Orchestrator → worker: the worker receives it, attributed to the orch.
451        let mut rx = h_worker.subscribe();
452        reg.deliver(
453            &orch,
454            &worker,
455            Request::Notify {
456                content: "do the thing".into(),
457            },
458        )
459        .unwrap();
460        match next_message(&mut rx).await {
461            AgentEvent::MessageReceived { from, content } => {
462                assert_eq!(from, orch);
463                assert_eq!(content, "do the thing");
464            }
465            other => panic!("expected MessageReceived, got {other:?}"),
466        }
467
468        // Worker → orchestrator (report back): permitted.
469        let mut rx = h_orch.subscribe();
470        reg.deliver(
471            &worker,
472            &orch,
473            Request::Notify {
474                content: "done".into(),
475            },
476        )
477        .unwrap();
478        match next_message(&mut rx).await {
479            AgentEvent::MessageReceived { from, content } => {
480                assert_eq!(from, worker);
481                assert_eq!(content, "done");
482            }
483            other => panic!("expected MessageReceived, got {other:?}"),
484        }
485    }
486
487    /// `allow_pair(w1, w2)` → `w1` may `deliver` to `w2`, and the reverse — the
488    /// workflow-scoped adjacency (A1 / D030).
489    #[tokio::test]
490    async fn an_adjacent_pair_may_message() {
491        let reg = Registry::new();
492        let w1 = SessionId::agent("w1");
493        let w2 = SessionId::agent("w2");
494        for id in [&w1, &w2] {
495            reg.register(id.clone(), session());
496        }
497        reg.allow_pair(&w1, &w2);
498        assert!(reg.permitted(&w1, &w2));
499        assert!(reg.permitted(&w2, &w1), "both directions stored");
500        reg.deliver(
501            &w1,
502            &w2,
503            Request::Notify {
504                content: "psst".into(),
505            },
506        )
507        .unwrap();
508    }
509
510    /// Without `allow_pair`, the star bound survives (this REPLACES
511    /// `lateral_message_between_workers_is_refused`): w1/w2 both owned by `orch`,
512    /// neither paired → `NotPermitted`.
513    #[tokio::test]
514    async fn a_non_adjacent_pair_is_still_refused() {
515        let reg = Registry::new();
516        let orch = SessionId::agent("orch");
517        let w1 = SessionId::agent("w1");
518        let w2 = SessionId::agent("w2");
519        for id in [&orch, &w1, &w2] {
520            reg.register(id.clone(), session());
521        }
522        reg.set_owner(w1.clone(), orch.clone());
523        reg.set_owner(w2.clone(), orch.clone());
524
525        assert_eq!(
526            reg.deliver(
527                &w1,
528                &w2,
529                Request::Notify {
530                    content: "psst".into()
531                }
532            ),
533            Err(RouteError::NotPermitted {
534                from: w1.clone(),
535                to: w2.clone()
536            })
537        );
538        assert!(!reg.permitted(&w1, &w2));
539    }
540
541    #[tokio::test]
542    async fn unknown_target_is_reported() {
543        let reg = Registry::new();
544        let orch = SessionId::agent("orch");
545        reg.register(orch.clone(), session());
546
547        let ghost = SessionId::agent("ghost");
548        assert_eq!(
549            reg.deliver(&orch, &ghost, Request::Notify { content: "?".into() }),
550            Err(RouteError::Unknown(ghost))
551        );
552    }
553
554    #[tokio::test]
555    async fn locals_lists_only_local_peers_sorted_by_id() {
556        let reg = Registry::new();
557        let b = SessionId::agent("b");
558        let a = SessionId::agent("a");
559        reg.register(b.clone(), session());
560        reg.register(a.clone(), session());
561        #[cfg(unix)]
562        reg.register_remote(
563            SessionId::agent("remote"),
564            Client::lazy(std::path::Path::new("/nonexistent-t2.sock")),
565        );
566        let ids: Vec<SessionId> = reg.locals().into_iter().map(|(id, _)| id).collect();
567        assert_eq!(ids, vec![a, b]);
568    }
569
570    #[tokio::test]
571    async fn set_model_is_read_back_per_id() {
572        let reg = Registry::new();
573        let w1 = SessionId::agent("w1");
574        let ghost = SessionId::agent("ghost");
575        assert_eq!(reg.model_of(&w1), None, "unset id has no model");
576        reg.set_model(w1.clone(), "m1");
577        assert_eq!(reg.model_of(&w1).as_deref(), Some("m1"));
578        assert_eq!(reg.model_of(&ghost), None);
579    }
580
581    /// Await the next roster change, failing the test if it never comes.
582    async fn next_change(rx: &mut watch::Receiver<Vec<(SessionId, SessionHandle)>>) {
583        tokio::time::timeout(Duration::from_secs(2), rx.changed())
584            .await
585            .expect("a roster change")
586            .expect("open");
587    }
588
589    #[tokio::test]
590    async fn set_effort_window_catalog_are_read_back() {
591        let reg = Registry::new();
592        let w1 = SessionId::agent("w1");
593        assert_eq!(reg.effort_of(&w1), None);
594        reg.set_effort(w1.clone(), Some("high".into()));
595        assert_eq!(reg.effort_of(&w1).as_deref(), Some("high"));
596        reg.set_effort(w1.clone(), None); // cleared == None
597        assert_eq!(reg.effort_of(&w1), None);
598
599        assert_eq!(reg.window_of(&w1), None);
600        reg.set_context_window(w1.clone(), 200_000);
601        assert_eq!(reg.window_of(&w1), Some(200_000));
602
603        assert_eq!(reg.models(), Vec::<String>::new());
604        reg.set_models(vec!["m1".into(), "m2".into()]);
605        assert_eq!(reg.models(), vec!["m1".to_string(), "m2".to_string()]);
606    }
607
608    #[tokio::test]
609    async fn set_model_and_set_effort_republish_the_roster() {
610        let reg = Registry::new();
611        let w1 = SessionId::agent("w1");
612        let mut rx = reg.subscribe();
613        reg.set_model(w1.clone(), "m1"); // NEW: re-publishes
614        next_change(&mut rx).await;
615        reg.set_effort(w1.clone(), Some("low".into()));
616        next_change(&mut rx).await;
617        reg.set_context_window(w1.clone(), 1_000);
618        next_change(&mut rx).await;
619    }
620
621    #[tokio::test]
622    async fn set_state_is_read_back_and_republishes() {
623        let reg = Registry::new();
624        let w1 = SessionId::agent("w1");
625        assert_eq!(reg.state_of(&w1), MemberState::Idle, "unset id reads Idle");
626
627        let mut rx = reg.subscribe();
628        reg.set_state(w1.clone(), MemberState::Running);
629        assert_eq!(reg.state_of(&w1), MemberState::Running);
630        // `set_state` re-publishes, so a subscriber (the server's push) wakes.
631        tokio::time::timeout(Duration::from_secs(2), rx.changed())
632            .await
633            .expect("a roster change")
634            .expect("open");
635    }
636
637    #[tokio::test]
638    async fn register_mirrors_the_actor_state_into_the_roster() {
639        // A stream that emits one delta then hangs: Running is observable until
640        // a cancel ends the run.
641        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
642            let head =
643                futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
644            Box::pin(head.chain(futures::stream::pending())) as LlmStream
645        });
646        let handle = session_with(stream_fn);
647        let reg = Registry::new();
648        let w1 = SessionId::agent("w1");
649        reg.register(w1.clone(), handle.clone());
650        assert_eq!(reg.state_of(&w1), MemberState::Idle, "a virgin session is idle");
651
652        handle.send(Request::Submit { text: "hi".into() }).unwrap();
653        let mut running = false;
654        for _ in 0..500 {
655            if reg.state_of(&w1) == MemberState::Running {
656                running = true;
657                break;
658            }
659            tokio::time::sleep(Duration::from_millis(10)).await;
660        }
661        assert!(running, "the registry mirrors the actor's Running");
662
663        handle.send(Request::Cancel).unwrap();
664        let mut done = false;
665        for _ in 0..500 {
666            if reg.state_of(&w1) == MemberState::Done {
667                done = true;
668                break;
669            }
670            tokio::time::sleep(Duration::from_millis(10)).await;
671        }
672        assert!(done, "the registry mirrors the actor's Done");
673    }
674
675    #[tokio::test]
676    async fn subscribe_yields_the_new_roster_after_register() {
677        let reg = Registry::new();
678        let mut rx = reg.subscribe();
679        let w1 = SessionId::agent("w1");
680        reg.register(w1.clone(), session());
681        rx.changed().await.unwrap();
682        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
683        assert_eq!(ids, vec![w1]);
684    }
685
686    #[tokio::test]
687    async fn subscribe_seeds_a_session_registered_before_it() {
688        // A session registered with **no** subscriber in existence must still be
689        // visible to a `subscribe()` that starts later — the `[team]` startup
690        // members, registered before the socket server's first `subscribe()`.
691        // (`watch::Sender::send` drops a value with no receivers; `send_replace`
692        // keeps it, which is what `refresh_roster` relies on.)
693        let reg = Registry::new();
694        let w1 = SessionId::agent("w1");
695        reg.register(w1.clone(), session());
696        let rx = reg.subscribe();
697        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
698        assert_eq!(ids, vec![w1]);
699    }
700
701    #[tokio::test]
702    async fn an_unowned_pair_is_not_permitted() {
703        let reg = Registry::new();
704        let a = SessionId::agent("a");
705        let b = SessionId::agent("b");
706        reg.register(a.clone(), session());
707        reg.register(b.clone(), session());
708        // No ownership edge recorded → they may not address each other.
709        assert_eq!(
710            reg.deliver(&a, &b, Request::Notify { content: "?".into() }),
711            Err(RouteError::NotPermitted {
712                from: a.clone(),
713                to: b.clone()
714            })
715        );
716    }
717    #[tokio::test]
718    async fn remove_drops_peers_states_models_and_republishes() {
719        let reg = Registry::new();
720        let orch = SessionId::agent("orch");
721        let w1 = SessionId::agent("w1");
722        reg.register(w1.clone(), session());
723        reg.set_owner(w1.clone(), orch.clone());
724        reg.set_model(w1.clone(), "m1");
725        reg.set_effort(w1.clone(), Some("high".into()));
726        reg.set_context_window(w1.clone(), 1_000);
727        reg.set_state(w1.clone(), MemberState::Running);
728        assert!(reg.contains(&w1));
729
730        let mut rx = reg.subscribe();
731        reg.remove(&w1);
732
733        // Every per-session map is cleared...
734        assert!(!reg.contains(&w1), "peers entry dropped");
735        assert_eq!(reg.state_of(&w1), MemberState::Idle, "state cleared");
736        assert_eq!(reg.model_of(&w1), None, "model cleared");
737        assert_eq!(reg.effort_of(&w1), None, "effort cleared");
738        assert_eq!(reg.window_of(&w1), None, "window cleared");
739        // ...and routing reports it gone (unknown, not merely unpermitted).
740        assert!(
741            matches!(reg.resolve(&orch, &w1), Err(RouteError::Unknown(id)) if id == w1),
742            "an unknown route after remove"
743        );
744        assert!(reg.locals().is_empty(), "the local roster excludes it");
745        // A subscriber (the socket server, the TUI) WOKE with the smaller list.
746        next_change(&mut rx).await;
747        assert!(rx.borrow().is_empty(), "the shrunk roster was published");
748    }
749
750    #[tokio::test]
751    async fn remove_of_an_unknown_id_is_a_no_op() {
752        let reg = Registry::new();
753        let ghost = SessionId::agent("ghost");
754        // No panic and no error; it simply re-publishes the (unchanged) roster.
755        reg.remove(&ghost);
756        assert!(!reg.contains(&ghost));
757    }
758}