1use 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#[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}
86
87impl Default for Inner {
88 fn default() -> Self {
89 Self {
90 peers: Mutex::new(HashMap::new()),
91 owners: Mutex::new(HashMap::new()),
92 models: Mutex::new(HashMap::new()),
93 states: Mutex::new(HashMap::new()),
94 efforts: Mutex::new(HashMap::new()),
95 windows: Mutex::new(HashMap::new()),
96 catalog: Mutex::new(Vec::new()),
97 roster: watch::channel(Vec::new()).0,
98 }
99 }
100}
101
102impl Registry {
103 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn register(&self, id: SessionId, handle: SessionHandle) {
109 let mut state_rx = handle.state_rx();
115 self.inner
116 .peers
117 .lock()
118 .unwrap()
119 .insert(id.clone(), Backend::Local(handle));
120 let registry = self.clone();
121 tokio::spawn(async move {
122 while state_rx.changed().await.is_ok() {
123 registry.set_state(id.clone(), *state_rx.borrow_and_update());
124 }
125 });
126 self.refresh_roster();
127 }
128
129 #[cfg(unix)]
132 pub fn register_remote(&self, id: SessionId, client: Client) {
133 self.inner
134 .peers
135 .lock()
136 .unwrap()
137 .insert(id, Backend::Remote(client.clone()));
138 let registry = self.clone();
143 let mut roster = client.subscribe_roster();
144 tokio::spawn(async move {
145 while roster.changed().await.is_ok() {
146 let sessions = roster.borrow_and_update().clone();
147 for info in &sessions {
148 registry.set_state(info.id.clone(), info.state);
149 }
150 }
151 });
152 self.refresh_roster();
153 }
154
155 pub fn locals(&self) -> Vec<(SessionId, SessionHandle)> {
161 let mut locals: Vec<(SessionId, SessionHandle)> = self
162 .inner
163 .peers
164 .lock()
165 .unwrap()
166 .iter()
167 .filter_map(|(id, backend)| match backend {
168 Backend::Local(handle) => Some((id.clone(), handle.clone())),
169 #[cfg(unix)]
170 Backend::Remote(_) => None,
171 })
172 .collect();
173 locals.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
174 locals
175 }
176
177 pub fn remove(&self, id: &SessionId) {
197 self.inner.peers.lock().unwrap().remove(id);
198 self.inner.owners.lock().unwrap().remove(id);
199 self.inner.models.lock().unwrap().remove(id);
200 self.inner.states.lock().unwrap().remove(id);
201 self.inner.efforts.lock().unwrap().remove(id);
202 self.inner.windows.lock().unwrap().remove(id);
203 self.refresh_roster();
204 }
205
206 pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
211 self.inner.roster.subscribe()
212 }
213
214 fn refresh_roster(&self) {
220 let _ = self.inner.roster.send_replace(self.locals());
221 }
222
223 pub fn contains(&self, id: &SessionId) -> bool {
226 self.inner.peers.lock().unwrap().contains_key(id)
227 }
228
229 pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
232 self.inner.owners.lock().unwrap().insert(worker, owner);
233 }
234
235 pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
239 self.inner.models.lock().unwrap().insert(id, model.into());
240 self.refresh_roster();
241 }
242
243 pub fn set_state(&self, id: SessionId, state: MemberState) {
248 self.inner.states.lock().unwrap().insert(id, state);
249 self.refresh_roster();
250 }
251
252 pub fn state_of(&self, id: &SessionId) -> MemberState {
255 *self
256 .inner
257 .states
258 .lock()
259 .unwrap()
260 .get(id)
261 .unwrap_or(&MemberState::Idle)
262 }
263 pub fn model_of(&self, id: &SessionId) -> Option<String> {
266 self.inner.models.lock().unwrap().get(id).cloned()
267 }
268
269 pub fn set_effort(&self, id: SessionId, effort: Option<String>) {
272 self.inner.efforts.lock().unwrap().insert(id, effort);
273 self.refresh_roster();
274 }
275
276 pub fn effort_of(&self, id: &SessionId) -> Option<String> {
278 self.inner.efforts.lock().unwrap().get(id).cloned().flatten()
279 }
280
281 pub fn set_context_window(&self, id: SessionId, window: u64) {
283 self.inner.windows.lock().unwrap().insert(id, window);
284 self.refresh_roster();
285 }
286
287 pub fn window_of(&self, id: &SessionId) -> Option<u64> {
289 self.inner.windows.lock().unwrap().get(id).copied()
290 }
291
292 pub fn set_models(&self, models: Vec<String>) {
295 *self.inner.catalog.lock().unwrap() = models;
296 }
297
298 pub fn models(&self) -> Vec<String> {
300 self.inner.catalog.lock().unwrap().clone()
301 }
302
303 pub fn permitted(&self, from: &SessionId, to: &SessionId) -> bool {
307 let owners = self.inner.owners.lock().unwrap();
308 owners.get(from) == Some(to) || owners.get(to) == Some(from)
309 }
310
311 pub fn resolve(&self, from: &SessionId, to: &SessionId) -> Result<Backend, RouteError> {
315 let endpoint = self
316 .inner
317 .peers
318 .lock()
319 .unwrap()
320 .get(to)
321 .cloned()
322 .ok_or_else(|| RouteError::Unknown(to.clone()))?;
323 if !self.permitted(from, to) {
324 return Err(RouteError::NotPermitted {
325 from: from.clone(),
326 to: to.clone(),
327 });
328 }
329 Ok(endpoint)
330 }
331
332 pub fn deliver(
336 &self,
337 from: &SessionId,
338 to: &SessionId,
339 request: Request,
340 ) -> Result<(), RouteError> {
341 let endpoint = self.resolve(from, to)?;
342 endpoint
343 .send_from(from.clone(), request)
344 .map_err(|_| RouteError::Closed)
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use std::time::Duration;
352
353 use wcode_harness::actor::SessionActor;
354 use wcode_harness::agent::{Agent, AgentConfig};
355 use wcode_harness::compaction::CompactionPolicy;
356 use wcode_harness::event::{AgentEvent, LlmStreamEvent};
357 use wcode_harness::hooks::HooksSet;
358 use wcode_harness::loop_::DEFAULT_MAX_TURNS;
359 use wcode_harness::streamfn::{LlmOpts, LlmStream, StreamFn};
360
361 use futures::StreamExt as _;
362
363 fn session() -> SessionHandle {
366 session_with(Arc::new(|_ctx, _sys, _tools, _opts| {
367 Box::pin(futures::stream::empty()) as LlmStream
368 }))
369 }
370
371 fn session_with(stream_fn: StreamFn) -> SessionHandle {
374 SessionActor::spawn(Agent::new(AgentConfig {
375 system: "sys".into(),
376 tools: Vec::new(),
377 llm: LlmOpts {
378 model: "m".into(),
379 ..LlmOpts::default()
380 },
381 stream_fn,
382 hooks: HooksSet::default(),
383 session: None,
384 context: Vec::new(),
385 working_dir: std::path::PathBuf::new(),
386 max_turns: DEFAULT_MAX_TURNS,
387 parallel_tools: true,
388 compaction: CompactionPolicy::default(),
389 plan_mode: wcode_harness::hooks::PlanModeHandle::new(),
390 }))
391 }
392
393 async fn next_message(
394 rx: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
395 ) -> AgentEvent {
396 tokio::time::timeout(Duration::from_secs(2), rx.recv())
397 .await
398 .expect("an event")
399 .expect("open")
400 }
401
402 #[tokio::test]
403 async fn orchestrator_and_worker_reach_each_other() {
404 let reg = Registry::new();
405 let orch = SessionId::agent("orch");
406 let worker = SessionId::agent("w1");
407 let h_orch = session();
408 let h_worker = session();
409 reg.register(orch.clone(), h_orch.clone());
410 reg.register(worker.clone(), h_worker.clone());
411 reg.set_owner(worker.clone(), orch.clone());
412
413 let mut rx = h_worker.subscribe();
415 reg.deliver(
416 &orch,
417 &worker,
418 Request::Notify {
419 content: "do the thing".into(),
420 },
421 )
422 .unwrap();
423 match next_message(&mut rx).await {
424 AgentEvent::MessageReceived { from, content } => {
425 assert_eq!(from, orch);
426 assert_eq!(content, "do the thing");
427 }
428 other => panic!("expected MessageReceived, got {other:?}"),
429 }
430
431 let mut rx = h_orch.subscribe();
433 reg.deliver(
434 &worker,
435 &orch,
436 Request::Notify {
437 content: "done".into(),
438 },
439 )
440 .unwrap();
441 match next_message(&mut rx).await {
442 AgentEvent::MessageReceived { from, content } => {
443 assert_eq!(from, worker);
444 assert_eq!(content, "done");
445 }
446 other => panic!("expected MessageReceived, got {other:?}"),
447 }
448 }
449
450 #[tokio::test]
451 async fn lateral_message_between_workers_is_refused() {
452 let reg = Registry::new();
453 let orch = SessionId::agent("orch");
454 let w1 = SessionId::agent("w1");
455 let w2 = SessionId::agent("w2");
456 for id in [&orch, &w1, &w2] {
457 reg.register(id.clone(), session());
458 }
459 reg.set_owner(w1.clone(), orch.clone());
460 reg.set_owner(w2.clone(), orch.clone());
461
462 assert_eq!(
463 reg.deliver(
464 &w1,
465 &w2,
466 Request::Notify {
467 content: "psst".into()
468 }
469 ),
470 Err(RouteError::NotPermitted {
471 from: w1.clone(),
472 to: w2.clone()
473 })
474 );
475 assert!(!reg.permitted(&w1, &w2));
476 }
477
478 #[tokio::test]
479 async fn unknown_target_is_reported() {
480 let reg = Registry::new();
481 let orch = SessionId::agent("orch");
482 reg.register(orch.clone(), session());
483
484 let ghost = SessionId::agent("ghost");
485 assert_eq!(
486 reg.deliver(&orch, &ghost, Request::Notify { content: "?".into() }),
487 Err(RouteError::Unknown(ghost))
488 );
489 }
490
491 #[tokio::test]
492 async fn locals_lists_only_local_peers_sorted_by_id() {
493 let reg = Registry::new();
494 let b = SessionId::agent("b");
495 let a = SessionId::agent("a");
496 reg.register(b.clone(), session());
497 reg.register(a.clone(), session());
498 #[cfg(unix)]
499 reg.register_remote(
500 SessionId::agent("remote"),
501 Client::lazy(std::path::Path::new("/nonexistent-t2.sock")),
502 );
503 let ids: Vec<SessionId> = reg.locals().into_iter().map(|(id, _)| id).collect();
504 assert_eq!(ids, vec![a, b]);
505 }
506
507 #[tokio::test]
508 async fn set_model_is_read_back_per_id() {
509 let reg = Registry::new();
510 let w1 = SessionId::agent("w1");
511 let ghost = SessionId::agent("ghost");
512 assert_eq!(reg.model_of(&w1), None, "unset id has no model");
513 reg.set_model(w1.clone(), "m1");
514 assert_eq!(reg.model_of(&w1).as_deref(), Some("m1"));
515 assert_eq!(reg.model_of(&ghost), None);
516 }
517
518 async fn next_change(rx: &mut watch::Receiver<Vec<(SessionId, SessionHandle)>>) {
520 tokio::time::timeout(Duration::from_secs(2), rx.changed())
521 .await
522 .expect("a roster change")
523 .expect("open");
524 }
525
526 #[tokio::test]
527 async fn set_effort_window_catalog_are_read_back() {
528 let reg = Registry::new();
529 let w1 = SessionId::agent("w1");
530 assert_eq!(reg.effort_of(&w1), None);
531 reg.set_effort(w1.clone(), Some("high".into()));
532 assert_eq!(reg.effort_of(&w1).as_deref(), Some("high"));
533 reg.set_effort(w1.clone(), None); assert_eq!(reg.effort_of(&w1), None);
535
536 assert_eq!(reg.window_of(&w1), None);
537 reg.set_context_window(w1.clone(), 200_000);
538 assert_eq!(reg.window_of(&w1), Some(200_000));
539
540 assert_eq!(reg.models(), Vec::<String>::new());
541 reg.set_models(vec!["m1".into(), "m2".into()]);
542 assert_eq!(reg.models(), vec!["m1".to_string(), "m2".to_string()]);
543 }
544
545 #[tokio::test]
546 async fn set_model_and_set_effort_republish_the_roster() {
547 let reg = Registry::new();
548 let w1 = SessionId::agent("w1");
549 let mut rx = reg.subscribe();
550 reg.set_model(w1.clone(), "m1"); next_change(&mut rx).await;
552 reg.set_effort(w1.clone(), Some("low".into()));
553 next_change(&mut rx).await;
554 reg.set_context_window(w1.clone(), 1_000);
555 next_change(&mut rx).await;
556 }
557
558 #[tokio::test]
559 async fn set_state_is_read_back_and_republishes() {
560 let reg = Registry::new();
561 let w1 = SessionId::agent("w1");
562 assert_eq!(reg.state_of(&w1), MemberState::Idle, "unset id reads Idle");
563
564 let mut rx = reg.subscribe();
565 reg.set_state(w1.clone(), MemberState::Running);
566 assert_eq!(reg.state_of(&w1), MemberState::Running);
567 tokio::time::timeout(Duration::from_secs(2), rx.changed())
569 .await
570 .expect("a roster change")
571 .expect("open");
572 }
573
574 #[tokio::test]
575 async fn register_mirrors_the_actor_state_into_the_roster() {
576 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
579 let head =
580 futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
581 Box::pin(head.chain(futures::stream::pending())) as LlmStream
582 });
583 let handle = session_with(stream_fn);
584 let reg = Registry::new();
585 let w1 = SessionId::agent("w1");
586 reg.register(w1.clone(), handle.clone());
587 assert_eq!(reg.state_of(&w1), MemberState::Idle, "a virgin session is idle");
588
589 handle.send(Request::Submit { text: "hi".into() }).unwrap();
590 let mut running = false;
591 for _ in 0..500 {
592 if reg.state_of(&w1) == MemberState::Running {
593 running = true;
594 break;
595 }
596 tokio::time::sleep(Duration::from_millis(10)).await;
597 }
598 assert!(running, "the registry mirrors the actor's Running");
599
600 handle.send(Request::Cancel).unwrap();
601 let mut done = false;
602 for _ in 0..500 {
603 if reg.state_of(&w1) == MemberState::Done {
604 done = true;
605 break;
606 }
607 tokio::time::sleep(Duration::from_millis(10)).await;
608 }
609 assert!(done, "the registry mirrors the actor's Done");
610 }
611
612 #[tokio::test]
613 async fn subscribe_yields_the_new_roster_after_register() {
614 let reg = Registry::new();
615 let mut rx = reg.subscribe();
616 let w1 = SessionId::agent("w1");
617 reg.register(w1.clone(), session());
618 rx.changed().await.unwrap();
619 let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
620 assert_eq!(ids, vec![w1]);
621 }
622
623 #[tokio::test]
624 async fn subscribe_seeds_a_session_registered_before_it() {
625 let reg = Registry::new();
631 let w1 = SessionId::agent("w1");
632 reg.register(w1.clone(), session());
633 let rx = reg.subscribe();
634 let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
635 assert_eq!(ids, vec![w1]);
636 }
637
638 #[tokio::test]
639 async fn an_unowned_pair_is_not_permitted() {
640 let reg = Registry::new();
641 let a = SessionId::agent("a");
642 let b = SessionId::agent("b");
643 reg.register(a.clone(), session());
644 reg.register(b.clone(), session());
645 assert_eq!(
647 reg.deliver(&a, &b, Request::Notify { content: "?".into() }),
648 Err(RouteError::NotPermitted {
649 from: a.clone(),
650 to: b.clone()
651 })
652 );
653 }
654 #[tokio::test]
655 async fn remove_drops_peers_states_models_and_republishes() {
656 let reg = Registry::new();
657 let orch = SessionId::agent("orch");
658 let w1 = SessionId::agent("w1");
659 reg.register(w1.clone(), session());
660 reg.set_owner(w1.clone(), orch.clone());
661 reg.set_model(w1.clone(), "m1");
662 reg.set_effort(w1.clone(), Some("high".into()));
663 reg.set_context_window(w1.clone(), 1_000);
664 reg.set_state(w1.clone(), MemberState::Running);
665 assert!(reg.contains(&w1));
666
667 let mut rx = reg.subscribe();
668 reg.remove(&w1);
669
670 assert!(!reg.contains(&w1), "peers entry dropped");
672 assert_eq!(reg.state_of(&w1), MemberState::Idle, "state cleared");
673 assert_eq!(reg.model_of(&w1), None, "model cleared");
674 assert_eq!(reg.effort_of(&w1), None, "effort cleared");
675 assert_eq!(reg.window_of(&w1), None, "window cleared");
676 assert!(
678 matches!(reg.resolve(&orch, &w1), Err(RouteError::Unknown(id)) if id == w1),
679 "an unknown route after remove"
680 );
681 assert!(reg.locals().is_empty(), "the local roster excludes it");
682 next_change(&mut rx).await;
684 assert!(rx.borrow().is_empty(), "the shrunk roster was published");
685 }
686
687 #[tokio::test]
688 async fn remove_of_an_unknown_id_is_a_no_op() {
689 let reg = Registry::new();
690 let ghost = SessionId::agent("ghost");
691 reg.remove(&ghost);
693 assert!(!reg.contains(&ghost));
694 }
695}