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    /// The live **local** roster (see [`Registry::locals`]), refreshed on every
75    /// `register`/`register_remote` so a socket server can fan a session that
76    /// appears after it started. Read via [`Registry::subscribe`].
77    roster: watch::Sender<Vec<(SessionId, SessionHandle)>>,
78}
79
80impl Default for Inner {
81    fn default() -> Self {
82        Self {
83            peers: Mutex::new(HashMap::new()),
84            owners: Mutex::new(HashMap::new()),
85            models: Mutex::new(HashMap::new()),
86            states: Mutex::new(HashMap::new()),
87            roster: watch::channel(Vec::new()).0,
88        }
89    }
90}
91
92impl Registry {
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Register a peer's mailbox at `id`; re-registering replaces it.
98    pub fn register(&self, id: SessionId, handle: SessionHandle) {
99        // Mirror the session's run-state into the roster: the registry keeps
100        // NO back-reference into the kernel (layering) — it watches the
101        // handle's `state_rx()` and re-publishes on every transition, so the
102        // socket server pushes the change without polling. One task per served
103        // session (a handful); it ends when the session drops.
104        let mut state_rx = handle.state_rx();
105        self.inner
106            .peers
107            .lock()
108            .unwrap()
109            .insert(id.clone(), Backend::Local(handle));
110        let registry = self.clone();
111        tokio::spawn(async move {
112            while state_rx.changed().await.is_ok() {
113                registry.set_state(id.clone(), *state_rx.borrow_and_update());
114            }
115        });
116        self.refresh_roster();
117    }
118
119    /// Register a peer reachable **across a socket** — a served session this
120    /// process can message, and which can reach back if it knows us (§8, S4-4).
121    #[cfg(unix)]
122    pub fn register_remote(&self, id: SessionId, client: Client) {
123        self.inner
124            .peers
125            .lock()
126            .unwrap()
127            .insert(id, Backend::Remote(client.clone()));
128        // Remote liveness rides the peer's own roster push: a served peer
129        // stamps each `SessionInfo` with a `state`, so mirroring the remote
130        // client's roster into `states` makes `state_of` reflect a remote
131        // member with no back-channel of our own.
132        let registry = self.clone();
133        let mut roster = client.subscribe_roster();
134        tokio::spawn(async move {
135            while roster.changed().await.is_ok() {
136                let sessions = roster.borrow_and_update().clone();
137                for info in &sessions {
138                    registry.set_state(info.id.clone(), info.state);
139                }
140            }
141        });
142        self.refresh_roster();
143    }
144
145    /// The **local** peers — the in-process handles this registry holds, sorted
146    /// by address for a deterministic roster. A socket server serves exactly
147    /// these ([`Self::register`]ed sessions: the root and its team); a peer
148    /// reached across a socket ([`Self::register_remote`]) is not a local handle
149    /// and is omitted, so it is never re-served.
150    pub fn locals(&self) -> Vec<(SessionId, SessionHandle)> {
151        let mut locals: Vec<(SessionId, SessionHandle)> = self
152            .inner
153            .peers
154            .lock()
155            .unwrap()
156            .iter()
157            .filter_map(|(id, backend)| match backend {
158                Backend::Local(handle) => Some((id.clone(), handle.clone())),
159                #[cfg(unix)]
160                Backend::Remote(_) => None,
161            })
162            .collect();
163        locals.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
164        locals
165    }
166
167    /// A live view of [`Self::locals`]: a receiver **seeded** with the current
168    /// local roster and updated whenever it changes (a session is
169    /// `register`ed). A socket server serves it, so a session that appears
170    /// *after* the server started is still reachable — see [`crate::serve`].
171    pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
172        self.inner.roster.subscribe()
173    }
174
175    /// Recompute the local roster and publish it to every [`Self::subscribe`]r.
176    /// `send_replace` stores the value even when nobody is watching yet, so a
177    /// session registered *before* the first subscriber — a `[team]` member that
178    /// starts ahead of the socket server — still seeds a later [`Self::subscribe`].
179    /// (`watch::Sender::send` would drop it: it is a no-op with no receivers.)
180    fn refresh_roster(&self) {
181        let _ = self.inner.roster.send_replace(self.locals());
182    }
183
184    /// Whether a peer is already registered at `id`. A plain existence probe —
185    /// unlike [`Self::resolve`], it needs no `from` and enforces no permitted set.
186    pub fn contains(&self, id: &SessionId) -> bool {
187        self.inner.peers.lock().unwrap().contains_key(id)
188    }
189
190    /// Record the `report_back_to` edge: `worker` reports to `owner` (its
191    /// orchestrator). This is the whole ownership tree — a star in v1.
192    pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
193        self.inner.owners.lock().unwrap().insert(worker, owner);
194    }
195
196    /// Record a session's effective model id, so the roster this registry feeds
197    /// (a socket server's `Sessions` push) can name it. Mirrors [`Self::set_owner`]
198    /// — a plain metadata write, no effect on routing.
199    pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
200        self.inner.models.lock().unwrap().insert(id, model.into());
201    }
202
203    /// Mirror a session's run-state. Called by the per-session watcher
204    /// `register` spawns — NOT by the actor, so the registry keeps NO
205    /// back-reference into the kernel (layering). Re-publishes the roster, so
206    /// the socket server pushes the change without polling.
207    pub fn set_state(&self, id: SessionId, state: MemberState) {
208        self.inner.states.lock().unwrap().insert(id, state);
209        self.refresh_roster();
210    }
211
212    /// The state last mirrored for `id`; `Idle` when unknown (a remote peer the
213    /// registry never watched, or an older server).
214    pub fn state_of(&self, id: &SessionId) -> MemberState {
215        *self
216            .inner
217            .states
218            .lock()
219            .unwrap()
220            .get(id)
221            .unwrap_or(&MemberState::Idle)
222    }
223    /// The model recorded for `id`, if any ([`Self::set_model`]). `None` for a
224    /// peer registered without one (e.g. a remote reached across a socket).
225    pub fn model_of(&self, id: &SessionId) -> Option<String> {
226        self.inner.models.lock().unwrap().get(id).cloned()
227    }
228
229    /// Whether `from` may address `to` (§10.1): the pair must be joined by an
230    /// ownership edge in either direction — an orchestrator and one of its
231    /// workers. There are no lateral (worker → worker) edges.
232    pub fn permitted(&self, from: &SessionId, to: &SessionId) -> bool {
233        let owners = self.inner.owners.lock().unwrap();
234        owners.get(from) == Some(to) || owners.get(to) == Some(from)
235    }
236
237    /// Resolve `to` for `from`, enforcing the permitted set. Fails
238    /// [`RouteError::Unknown`] if no peer is registered, else
239    /// [`RouteError::NotPermitted`] if the sender is out of scope.
240    pub fn resolve(&self, from: &SessionId, to: &SessionId) -> Result<Backend, RouteError> {
241        let endpoint = self
242            .inner
243            .peers
244            .lock()
245            .unwrap()
246            .get(to)
247            .cloned()
248            .ok_or_else(|| RouteError::Unknown(to.clone()))?;
249        if !self.permitted(from, to) {
250            return Err(RouteError::NotPermitted {
251                from: from.clone(),
252                to: to.clone(),
253            });
254        }
255        Ok(endpoint)
256    }
257
258    /// Deliver a target-side `request` to `to`, attributed to `from`. The
259    /// recipient sees it as if `from` had sent it directly
260    /// ([`SessionHandle::send_from`]).
261    pub fn deliver(
262        &self,
263        from: &SessionId,
264        to: &SessionId,
265        request: Request,
266    ) -> Result<(), RouteError> {
267        let endpoint = self.resolve(from, to)?;
268        endpoint
269            .send_from(from.clone(), request)
270            .map_err(|_| RouteError::Closed)
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::time::Duration;
278
279    use wcode_harness::actor::SessionActor;
280    use wcode_harness::agent::{Agent, AgentConfig};
281    use wcode_harness::compaction::CompactionPolicy;
282    use wcode_harness::event::{AgentEvent, LlmStreamEvent};
283    use wcode_harness::hooks::HooksSet;
284    use wcode_harness::loop_::DEFAULT_MAX_TURNS;
285    use wcode_harness::streamfn::{LlmOpts, LlmStream, StreamFn};
286
287    use futures::StreamExt as _;
288
289    /// A live session whose model never streams — enough for routing tests (the
290    /// messages we send are `Notify`, which run no turn).
291    fn session() -> SessionHandle {
292        session_with(Arc::new(|_ctx, _sys, _tools, _opts| {
293            Box::pin(futures::stream::empty()) as LlmStream
294        }))
295    }
296
297    /// A session over a caller-supplied stream — for tests that drive a real
298    /// turn (a hang, a stream error), not just routing.
299    fn session_with(stream_fn: StreamFn) -> SessionHandle {
300        SessionActor::spawn(Agent::new(AgentConfig {
301            system: "sys".into(),
302            tools: Vec::new(),
303            llm: LlmOpts {
304                model: "m".into(),
305                ..LlmOpts::default()
306            },
307            stream_fn,
308            hooks: HooksSet::default(),
309            session: None,
310            context: Vec::new(),
311            working_dir: std::path::PathBuf::new(),
312            max_turns: DEFAULT_MAX_TURNS,
313            parallel_tools: true,
314            compaction: CompactionPolicy::default(),
315            plan_mode: wcode_harness::hooks::PlanModeHandle::new(),
316        }))
317    }
318
319    async fn next_message(
320        rx: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
321    ) -> AgentEvent {
322        tokio::time::timeout(Duration::from_secs(2), rx.recv())
323            .await
324            .expect("an event")
325            .expect("open")
326    }
327
328    #[tokio::test]
329    async fn orchestrator_and_worker_reach_each_other() {
330        let reg = Registry::new();
331        let orch = SessionId::agent("orch");
332        let worker = SessionId::agent("w1");
333        let h_orch = session();
334        let h_worker = session();
335        reg.register(orch.clone(), h_orch.clone());
336        reg.register(worker.clone(), h_worker.clone());
337        reg.set_owner(worker.clone(), orch.clone());
338
339        // Orchestrator → worker: the worker receives it, attributed to the orch.
340        let mut rx = h_worker.subscribe();
341        reg.deliver(
342            &orch,
343            &worker,
344            Request::Notify {
345                content: "do the thing".into(),
346            },
347        )
348        .unwrap();
349        match next_message(&mut rx).await {
350            AgentEvent::MessageReceived { from, content } => {
351                assert_eq!(from, orch);
352                assert_eq!(content, "do the thing");
353            }
354            other => panic!("expected MessageReceived, got {other:?}"),
355        }
356
357        // Worker → orchestrator (report back): permitted.
358        let mut rx = h_orch.subscribe();
359        reg.deliver(
360            &worker,
361            &orch,
362            Request::Notify {
363                content: "done".into(),
364            },
365        )
366        .unwrap();
367        match next_message(&mut rx).await {
368            AgentEvent::MessageReceived { from, content } => {
369                assert_eq!(from, worker);
370                assert_eq!(content, "done");
371            }
372            other => panic!("expected MessageReceived, got {other:?}"),
373        }
374    }
375
376    #[tokio::test]
377    async fn lateral_message_between_workers_is_refused() {
378        let reg = Registry::new();
379        let orch = SessionId::agent("orch");
380        let w1 = SessionId::agent("w1");
381        let w2 = SessionId::agent("w2");
382        for id in [&orch, &w1, &w2] {
383            reg.register(id.clone(), session());
384        }
385        reg.set_owner(w1.clone(), orch.clone());
386        reg.set_owner(w2.clone(), orch.clone());
387
388        assert_eq!(
389            reg.deliver(
390                &w1,
391                &w2,
392                Request::Notify {
393                    content: "psst".into()
394                }
395            ),
396            Err(RouteError::NotPermitted {
397                from: w1.clone(),
398                to: w2.clone()
399            })
400        );
401        assert!(!reg.permitted(&w1, &w2));
402    }
403
404    #[tokio::test]
405    async fn unknown_target_is_reported() {
406        let reg = Registry::new();
407        let orch = SessionId::agent("orch");
408        reg.register(orch.clone(), session());
409
410        let ghost = SessionId::agent("ghost");
411        assert_eq!(
412            reg.deliver(&orch, &ghost, Request::Notify { content: "?".into() }),
413            Err(RouteError::Unknown(ghost))
414        );
415    }
416
417    #[tokio::test]
418    async fn locals_lists_only_local_peers_sorted_by_id() {
419        let reg = Registry::new();
420        let b = SessionId::agent("b");
421        let a = SessionId::agent("a");
422        reg.register(b.clone(), session());
423        reg.register(a.clone(), session());
424        #[cfg(unix)]
425        reg.register_remote(
426            SessionId::agent("remote"),
427            Client::lazy(std::path::Path::new("/nonexistent-t2.sock")),
428        );
429        let ids: Vec<SessionId> = reg.locals().into_iter().map(|(id, _)| id).collect();
430        assert_eq!(ids, vec![a, b]);
431    }
432
433    #[tokio::test]
434    async fn set_model_is_read_back_per_id() {
435        let reg = Registry::new();
436        let w1 = SessionId::agent("w1");
437        let ghost = SessionId::agent("ghost");
438        assert_eq!(reg.model_of(&w1), None, "unset id has no model");
439        reg.set_model(w1.clone(), "m1");
440        assert_eq!(reg.model_of(&w1).as_deref(), Some("m1"));
441        assert_eq!(reg.model_of(&ghost), None);
442    }
443
444    #[tokio::test]
445    async fn set_state_is_read_back_and_republishes() {
446        let reg = Registry::new();
447        let w1 = SessionId::agent("w1");
448        assert_eq!(reg.state_of(&w1), MemberState::Idle, "unset id reads Idle");
449
450        let mut rx = reg.subscribe();
451        reg.set_state(w1.clone(), MemberState::Running);
452        assert_eq!(reg.state_of(&w1), MemberState::Running);
453        // `set_state` re-publishes, so a subscriber (the server's push) wakes.
454        tokio::time::timeout(Duration::from_secs(2), rx.changed())
455            .await
456            .expect("a roster change")
457            .expect("open");
458    }
459
460    #[tokio::test]
461    async fn register_mirrors_the_actor_state_into_the_roster() {
462        // A stream that emits one delta then hangs: Running is observable until
463        // a cancel ends the run.
464        let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
465            let head =
466                futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
467            Box::pin(head.chain(futures::stream::pending())) as LlmStream
468        });
469        let handle = session_with(stream_fn);
470        let reg = Registry::new();
471        let w1 = SessionId::agent("w1");
472        reg.register(w1.clone(), handle.clone());
473        assert_eq!(reg.state_of(&w1), MemberState::Idle, "a virgin session is idle");
474
475        handle.send(Request::Submit { text: "hi".into() }).unwrap();
476        let mut running = false;
477        for _ in 0..500 {
478            if reg.state_of(&w1) == MemberState::Running {
479                running = true;
480                break;
481            }
482            tokio::time::sleep(Duration::from_millis(10)).await;
483        }
484        assert!(running, "the registry mirrors the actor's Running");
485
486        handle.send(Request::Cancel).unwrap();
487        let mut done = false;
488        for _ in 0..500 {
489            if reg.state_of(&w1) == MemberState::Done {
490                done = true;
491                break;
492            }
493            tokio::time::sleep(Duration::from_millis(10)).await;
494        }
495        assert!(done, "the registry mirrors the actor's Done");
496    }
497
498    #[tokio::test]
499    async fn subscribe_yields_the_new_roster_after_register() {
500        let reg = Registry::new();
501        let mut rx = reg.subscribe();
502        let w1 = SessionId::agent("w1");
503        reg.register(w1.clone(), session());
504        rx.changed().await.unwrap();
505        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
506        assert_eq!(ids, vec![w1]);
507    }
508
509    #[tokio::test]
510    async fn subscribe_seeds_a_session_registered_before_it() {
511        // A session registered with **no** subscriber in existence must still be
512        // visible to a `subscribe()` that starts later — the `[team]` startup
513        // members, registered before the socket server's first `subscribe()`.
514        // (`watch::Sender::send` drops a value with no receivers; `send_replace`
515        // keeps it, which is what `refresh_roster` relies on.)
516        let reg = Registry::new();
517        let w1 = SessionId::agent("w1");
518        reg.register(w1.clone(), session());
519        let rx = reg.subscribe();
520        let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
521        assert_eq!(ids, vec![w1]);
522    }
523
524    #[tokio::test]
525    async fn an_unowned_pair_is_not_permitted() {
526        let reg = Registry::new();
527        let a = SessionId::agent("a");
528        let b = SessionId::agent("b");
529        reg.register(a.clone(), session());
530        reg.register(b.clone(), session());
531        // No ownership edge recorded → they may not address each other.
532        assert_eq!(
533            reg.deliver(&a, &b, Request::Notify { content: "?".into() }),
534            Err(RouteError::NotPermitted {
535                from: a.clone(),
536                to: b.clone()
537            })
538        );
539    }
540}