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 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 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 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 #[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 #[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 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); 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"); 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 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 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 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 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 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 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 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 reg.remove(&ghost);
756 assert!(!reg.contains(&ghost));
757 }
758}