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 subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
182 self.inner.roster.subscribe()
183 }
184
185 fn refresh_roster(&self) {
191 let _ = self.inner.roster.send_replace(self.locals());
192 }
193
194 pub fn contains(&self, id: &SessionId) -> bool {
197 self.inner.peers.lock().unwrap().contains_key(id)
198 }
199
200 pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
203 self.inner.owners.lock().unwrap().insert(worker, owner);
204 }
205
206 pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
210 self.inner.models.lock().unwrap().insert(id, model.into());
211 self.refresh_roster();
212 }
213
214 pub fn set_state(&self, id: SessionId, state: MemberState) {
219 self.inner.states.lock().unwrap().insert(id, state);
220 self.refresh_roster();
221 }
222
223 pub fn state_of(&self, id: &SessionId) -> MemberState {
226 *self
227 .inner
228 .states
229 .lock()
230 .unwrap()
231 .get(id)
232 .unwrap_or(&MemberState::Idle)
233 }
234 pub fn model_of(&self, id: &SessionId) -> Option<String> {
237 self.inner.models.lock().unwrap().get(id).cloned()
238 }
239
240 pub fn set_effort(&self, id: SessionId, effort: Option<String>) {
243 self.inner.efforts.lock().unwrap().insert(id, effort);
244 self.refresh_roster();
245 }
246
247 pub fn effort_of(&self, id: &SessionId) -> Option<String> {
249 self.inner.efforts.lock().unwrap().get(id).cloned().flatten()
250 }
251
252 pub fn set_context_window(&self, id: SessionId, window: u64) {
254 self.inner.windows.lock().unwrap().insert(id, window);
255 self.refresh_roster();
256 }
257
258 pub fn window_of(&self, id: &SessionId) -> Option<u64> {
260 self.inner.windows.lock().unwrap().get(id).copied()
261 }
262
263 pub fn set_models(&self, models: Vec<String>) {
266 *self.inner.catalog.lock().unwrap() = models;
267 }
268
269 pub fn models(&self) -> Vec<String> {
271 self.inner.catalog.lock().unwrap().clone()
272 }
273
274 pub fn permitted(&self, from: &SessionId, to: &SessionId) -> bool {
278 let owners = self.inner.owners.lock().unwrap();
279 owners.get(from) == Some(to) || owners.get(to) == Some(from)
280 }
281
282 pub fn resolve(&self, from: &SessionId, to: &SessionId) -> Result<Backend, RouteError> {
286 let endpoint = self
287 .inner
288 .peers
289 .lock()
290 .unwrap()
291 .get(to)
292 .cloned()
293 .ok_or_else(|| RouteError::Unknown(to.clone()))?;
294 if !self.permitted(from, to) {
295 return Err(RouteError::NotPermitted {
296 from: from.clone(),
297 to: to.clone(),
298 });
299 }
300 Ok(endpoint)
301 }
302
303 pub fn deliver(
307 &self,
308 from: &SessionId,
309 to: &SessionId,
310 request: Request,
311 ) -> Result<(), RouteError> {
312 let endpoint = self.resolve(from, to)?;
313 endpoint
314 .send_from(from.clone(), request)
315 .map_err(|_| RouteError::Closed)
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::time::Duration;
323
324 use wcode_harness::actor::SessionActor;
325 use wcode_harness::agent::{Agent, AgentConfig};
326 use wcode_harness::compaction::CompactionPolicy;
327 use wcode_harness::event::{AgentEvent, LlmStreamEvent};
328 use wcode_harness::hooks::HooksSet;
329 use wcode_harness::loop_::DEFAULT_MAX_TURNS;
330 use wcode_harness::streamfn::{LlmOpts, LlmStream, StreamFn};
331
332 use futures::StreamExt as _;
333
334 fn session() -> SessionHandle {
337 session_with(Arc::new(|_ctx, _sys, _tools, _opts| {
338 Box::pin(futures::stream::empty()) as LlmStream
339 }))
340 }
341
342 fn session_with(stream_fn: StreamFn) -> SessionHandle {
345 SessionActor::spawn(Agent::new(AgentConfig {
346 system: "sys".into(),
347 tools: Vec::new(),
348 llm: LlmOpts {
349 model: "m".into(),
350 ..LlmOpts::default()
351 },
352 stream_fn,
353 hooks: HooksSet::default(),
354 session: None,
355 context: Vec::new(),
356 working_dir: std::path::PathBuf::new(),
357 max_turns: DEFAULT_MAX_TURNS,
358 parallel_tools: true,
359 compaction: CompactionPolicy::default(),
360 plan_mode: wcode_harness::hooks::PlanModeHandle::new(),
361 }))
362 }
363
364 async fn next_message(
365 rx: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
366 ) -> AgentEvent {
367 tokio::time::timeout(Duration::from_secs(2), rx.recv())
368 .await
369 .expect("an event")
370 .expect("open")
371 }
372
373 #[tokio::test]
374 async fn orchestrator_and_worker_reach_each_other() {
375 let reg = Registry::new();
376 let orch = SessionId::agent("orch");
377 let worker = SessionId::agent("w1");
378 let h_orch = session();
379 let h_worker = session();
380 reg.register(orch.clone(), h_orch.clone());
381 reg.register(worker.clone(), h_worker.clone());
382 reg.set_owner(worker.clone(), orch.clone());
383
384 let mut rx = h_worker.subscribe();
386 reg.deliver(
387 &orch,
388 &worker,
389 Request::Notify {
390 content: "do the thing".into(),
391 },
392 )
393 .unwrap();
394 match next_message(&mut rx).await {
395 AgentEvent::MessageReceived { from, content } => {
396 assert_eq!(from, orch);
397 assert_eq!(content, "do the thing");
398 }
399 other => panic!("expected MessageReceived, got {other:?}"),
400 }
401
402 let mut rx = h_orch.subscribe();
404 reg.deliver(
405 &worker,
406 &orch,
407 Request::Notify {
408 content: "done".into(),
409 },
410 )
411 .unwrap();
412 match next_message(&mut rx).await {
413 AgentEvent::MessageReceived { from, content } => {
414 assert_eq!(from, worker);
415 assert_eq!(content, "done");
416 }
417 other => panic!("expected MessageReceived, got {other:?}"),
418 }
419 }
420
421 #[tokio::test]
422 async fn lateral_message_between_workers_is_refused() {
423 let reg = Registry::new();
424 let orch = SessionId::agent("orch");
425 let w1 = SessionId::agent("w1");
426 let w2 = SessionId::agent("w2");
427 for id in [&orch, &w1, &w2] {
428 reg.register(id.clone(), session());
429 }
430 reg.set_owner(w1.clone(), orch.clone());
431 reg.set_owner(w2.clone(), orch.clone());
432
433 assert_eq!(
434 reg.deliver(
435 &w1,
436 &w2,
437 Request::Notify {
438 content: "psst".into()
439 }
440 ),
441 Err(RouteError::NotPermitted {
442 from: w1.clone(),
443 to: w2.clone()
444 })
445 );
446 assert!(!reg.permitted(&w1, &w2));
447 }
448
449 #[tokio::test]
450 async fn unknown_target_is_reported() {
451 let reg = Registry::new();
452 let orch = SessionId::agent("orch");
453 reg.register(orch.clone(), session());
454
455 let ghost = SessionId::agent("ghost");
456 assert_eq!(
457 reg.deliver(&orch, &ghost, Request::Notify { content: "?".into() }),
458 Err(RouteError::Unknown(ghost))
459 );
460 }
461
462 #[tokio::test]
463 async fn locals_lists_only_local_peers_sorted_by_id() {
464 let reg = Registry::new();
465 let b = SessionId::agent("b");
466 let a = SessionId::agent("a");
467 reg.register(b.clone(), session());
468 reg.register(a.clone(), session());
469 #[cfg(unix)]
470 reg.register_remote(
471 SessionId::agent("remote"),
472 Client::lazy(std::path::Path::new("/nonexistent-t2.sock")),
473 );
474 let ids: Vec<SessionId> = reg.locals().into_iter().map(|(id, _)| id).collect();
475 assert_eq!(ids, vec![a, b]);
476 }
477
478 #[tokio::test]
479 async fn set_model_is_read_back_per_id() {
480 let reg = Registry::new();
481 let w1 = SessionId::agent("w1");
482 let ghost = SessionId::agent("ghost");
483 assert_eq!(reg.model_of(&w1), None, "unset id has no model");
484 reg.set_model(w1.clone(), "m1");
485 assert_eq!(reg.model_of(&w1).as_deref(), Some("m1"));
486 assert_eq!(reg.model_of(&ghost), None);
487 }
488
489 async fn next_change(rx: &mut watch::Receiver<Vec<(SessionId, SessionHandle)>>) {
491 tokio::time::timeout(Duration::from_secs(2), rx.changed())
492 .await
493 .expect("a roster change")
494 .expect("open");
495 }
496
497 #[tokio::test]
498 async fn set_effort_window_catalog_are_read_back() {
499 let reg = Registry::new();
500 let w1 = SessionId::agent("w1");
501 assert_eq!(reg.effort_of(&w1), None);
502 reg.set_effort(w1.clone(), Some("high".into()));
503 assert_eq!(reg.effort_of(&w1).as_deref(), Some("high"));
504 reg.set_effort(w1.clone(), None); assert_eq!(reg.effort_of(&w1), None);
506
507 assert_eq!(reg.window_of(&w1), None);
508 reg.set_context_window(w1.clone(), 200_000);
509 assert_eq!(reg.window_of(&w1), Some(200_000));
510
511 assert_eq!(reg.models(), Vec::<String>::new());
512 reg.set_models(vec!["m1".into(), "m2".into()]);
513 assert_eq!(reg.models(), vec!["m1".to_string(), "m2".to_string()]);
514 }
515
516 #[tokio::test]
517 async fn set_model_and_set_effort_republish_the_roster() {
518 let reg = Registry::new();
519 let w1 = SessionId::agent("w1");
520 let mut rx = reg.subscribe();
521 reg.set_model(w1.clone(), "m1"); next_change(&mut rx).await;
523 reg.set_effort(w1.clone(), Some("low".into()));
524 next_change(&mut rx).await;
525 reg.set_context_window(w1.clone(), 1_000);
526 next_change(&mut rx).await;
527 }
528
529 #[tokio::test]
530 async fn set_state_is_read_back_and_republishes() {
531 let reg = Registry::new();
532 let w1 = SessionId::agent("w1");
533 assert_eq!(reg.state_of(&w1), MemberState::Idle, "unset id reads Idle");
534
535 let mut rx = reg.subscribe();
536 reg.set_state(w1.clone(), MemberState::Running);
537 assert_eq!(reg.state_of(&w1), MemberState::Running);
538 tokio::time::timeout(Duration::from_secs(2), rx.changed())
540 .await
541 .expect("a roster change")
542 .expect("open");
543 }
544
545 #[tokio::test]
546 async fn register_mirrors_the_actor_state_into_the_roster() {
547 let stream_fn: StreamFn = Arc::new(|_ctx, _sys, _tools, _opts| {
550 let head =
551 futures::stream::iter(vec![LlmStreamEvent::TextDelta("part".into())]);
552 Box::pin(head.chain(futures::stream::pending())) as LlmStream
553 });
554 let handle = session_with(stream_fn);
555 let reg = Registry::new();
556 let w1 = SessionId::agent("w1");
557 reg.register(w1.clone(), handle.clone());
558 assert_eq!(reg.state_of(&w1), MemberState::Idle, "a virgin session is idle");
559
560 handle.send(Request::Submit { text: "hi".into() }).unwrap();
561 let mut running = false;
562 for _ in 0..500 {
563 if reg.state_of(&w1) == MemberState::Running {
564 running = true;
565 break;
566 }
567 tokio::time::sleep(Duration::from_millis(10)).await;
568 }
569 assert!(running, "the registry mirrors the actor's Running");
570
571 handle.send(Request::Cancel).unwrap();
572 let mut done = false;
573 for _ in 0..500 {
574 if reg.state_of(&w1) == MemberState::Done {
575 done = true;
576 break;
577 }
578 tokio::time::sleep(Duration::from_millis(10)).await;
579 }
580 assert!(done, "the registry mirrors the actor's Done");
581 }
582
583 #[tokio::test]
584 async fn subscribe_yields_the_new_roster_after_register() {
585 let reg = Registry::new();
586 let mut rx = reg.subscribe();
587 let w1 = SessionId::agent("w1");
588 reg.register(w1.clone(), session());
589 rx.changed().await.unwrap();
590 let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
591 assert_eq!(ids, vec![w1]);
592 }
593
594 #[tokio::test]
595 async fn subscribe_seeds_a_session_registered_before_it() {
596 let reg = Registry::new();
602 let w1 = SessionId::agent("w1");
603 reg.register(w1.clone(), session());
604 let rx = reg.subscribe();
605 let ids: Vec<SessionId> = rx.borrow().iter().map(|(id, _)| id.clone()).collect();
606 assert_eq!(ids, vec![w1]);
607 }
608
609 #[tokio::test]
610 async fn an_unowned_pair_is_not_permitted() {
611 let reg = Registry::new();
612 let a = SessionId::agent("a");
613 let b = SessionId::agent("b");
614 reg.register(a.clone(), session());
615 reg.register(b.clone(), session());
616 assert_eq!(
618 reg.deliver(&a, &b, Request::Notify { content: "?".into() }),
619 Err(RouteError::NotPermitted {
620 from: a.clone(),
621 to: b.clone()
622 })
623 );
624 }
625}