1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RouteError {
30 Unknown(SessionId),
32 NotPermitted { from: SessionId, to: SessionId },
34 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#[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 models: Mutex<HashMap<SessionId, String>>,
69 states: Mutex<HashMap<SessionId, MemberState>>,
74 efforts: Mutex<HashMap<SessionId, Option<String>>>,
76 windows: Mutex<HashMap<SessionId, u64>>,
78 catalog: Mutex<Vec<String>>,
81 roster: watch::Sender<Vec<(SessionId, SessionHandle)>>,
85 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 pub fn register(&self, id: SessionId, handle: SessionHandle) {
115 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 #[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 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 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 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 pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
217 self.inner.roster.subscribe()
218 }
219
220 fn refresh_roster(&self) {
226 let _ = self.inner.roster.send_replace(self.locals());
227 }
228
229 pub fn contains(&self, id: &SessionId) -> bool {
232 self.inner.peers.lock().unwrap().contains_key(id)
233 }
234
235 pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
238 self.inner.owners.lock().unwrap().insert(worker, owner);
239 }
240
241 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 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 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 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 pub fn model_of(&self, id: &SessionId) -> Option<String> {
286 self.inner.models.lock().unwrap().get(id).cloned()
287 }
288
289 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 pub fn effort_of(&self, id: &SessionId) -> Option<String> {
298 self.inner.efforts.lock().unwrap().get(id).cloned().flatten()
299 }
300
301 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 pub fn window_of(&self, id: &SessionId) -> Option<u64> {
309 self.inner.windows.lock().unwrap().get(id).copied()
310 }
311
312 pub fn set_models(&self, models: Vec<String>) {
315 *self.inner.catalog.lock().unwrap() = models;
316 }
317
318 pub fn models(&self) -> Vec<String> {
320 self.inner.catalog.lock().unwrap().clone()
321 }
322
323 pub fn permitted(&self, from: &SessionId, to: &SessionId) -> bool {
330 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 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 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 fn session() -> SessionHandle {
401 session_with(Arc::new(|_ctx, _sys, _tools, _opts| {
402 Box::pin(futures::stream::empty()) as LlmStream
403 }))
404 }
405
406 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 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 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 #[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 #[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 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); 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"); 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 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 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 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 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 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 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 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 reg.remove(&ghost);
754 assert!(!reg.contains(&ghost));
755 }
756}