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 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 pub fn register(&self, id: SessionId, handle: SessionHandle) {
99 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 #[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 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 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 pub fn subscribe(&self) -> watch::Receiver<Vec<(SessionId, SessionHandle)>> {
172 self.inner.roster.subscribe()
173 }
174
175 fn refresh_roster(&self) {
181 let _ = self.inner.roster.send_replace(self.locals());
182 }
183
184 pub fn contains(&self, id: &SessionId) -> bool {
187 self.inner.peers.lock().unwrap().contains_key(id)
188 }
189
190 pub fn set_owner(&self, worker: SessionId, owner: SessionId) {
193 self.inner.owners.lock().unwrap().insert(worker, owner);
194 }
195
196 pub fn set_model(&self, id: SessionId, model: impl Into<String>) {
200 self.inner.models.lock().unwrap().insert(id, model.into());
201 }
202
203 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 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 pub fn model_of(&self, id: &SessionId) -> Option<String> {
226 self.inner.models.lock().unwrap().get(id).cloned()
227 }
228
229 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 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 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 fn session() -> SessionHandle {
292 session_with(Arc::new(|_ctx, _sys, _tools, _opts| {
293 Box::pin(futures::stream::empty()) as LlmStream
294 }))
295 }
296
297 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 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 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 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 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 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 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}