Skip to main content

wcode_protocol/
server.rs

1//! Serve sessions over a socket: accept connections, bridge frames ↔ handles.
2//!
3//! Each connection gets a writer draining an outbound queue, one **fan** task per
4//! served session (forwarding that session's events, each frame stamped with its
5//! origin session), and the read loop answering inbound requests. A request is
6//! demuxed to the handle named by its `Frame::session`; a single-session server
7//! also accepts any id (the legacy default, so a `--socket` client's placeholder
8//! still reaches it). Requests are answered with the actor's reply (correlated by
9//! the envelope's `reply_to`), so a client's `ask` resolves even while streamed
10//! events flow on the same connection.
11//!
12//! The roster is **live**: beside an explicit root (served under its own session
13//! id, distinct from the registry's `agent:orchestrator` alias), the server
14//! follows a [`watch::Receiver`] of the extra sessions. A session registered
15//! after the server started — a worker a running orchestrator spawns — is fanned
16//! to every connection, and the grown roster is **pushed** as an unsolicited
17//! [`AgentEvent::Sessions`] frame, so a client learns new ids without polling.
18
19use std::collections::HashSet;
20use std::io;
21use std::path::Path;
22
23use tokio::io::{AsyncWrite, BufReader, BufWriter};
24use tokio::net::{UnixListener, UnixStream};
25use tokio::sync::{broadcast, mpsc, watch};
26use wcode_harness::actor::SessionHandle;
27use wcode_harness::event::AgentEvent;
28use wcode_harness::protocol::{Frame, PROTOCOL_VERSION, Request, SessionId, SessionInfo};
29
30use crate::Registry;
31use crate::frame::{read_frame, write_frame};
32
33/// The live roster of *extra* sessions served beside the root — a session is
34/// `(SessionId, SessionHandle)`. Empty means "root only"; the value is refreshed
35/// by a [`crate::Registry`] as sessions are registered.
36pub type Roster = watch::Receiver<Vec<(SessionId, SessionHandle)>>;
37
38/// A served session: the id every outbound frame carrying it is stamped with,
39/// and the handle the server reads events from / writes requests to.
40pub type Root = (SessionId, SessionHandle);
41
42/// The arguments of a [`wcode_harness::protocol::Request::Define`], passed to a
43/// [`DefineHandler`]. Mirrors the CLI's worker spec.
44#[derive(Clone, Debug, Default)]
45pub struct DefineArgs {
46    /// The worker's address; auto-assigned when absent.
47    pub name: Option<String>,
48    /// Model id override; `None` inherits the server's model.
49    pub model: Option<String>,
50    /// Role text appended to the worker's system prompt.
51    pub role: Option<String>,
52    /// Tool allow-list; `None` = the full default set.
53    pub tools: Option<Vec<String>>,
54    /// Provider base URL override; `None` inherits the server's.
55    pub base_url: Option<String>,
56    /// Provider API key override; `None` inherits the server's.
57    pub api_key: Option<String>,
58    /// Enforce read-only for the defined worker (D1/D2).
59    pub read_only: bool,
60    /// Reasoning-effort override (D3); `None` inherits, synonyms clear.
61    pub effort: Option<String>,
62}
63
64/// Builds a worker from [`DefineArgs`] and returns its address. A socket server
65/// uses it to answer [`wcode_harness::protocol::Request::Define`]; the
66/// composition root injects one, since this crate cannot name a session factory.
67pub type DefineHandler =
68    std::sync::Arc<dyn Fn(DefineArgs) -> Result<SessionId, String> + Send + Sync>;
69
70/// Bind `path` and serve the root session plus `roster` until the listener
71/// errors.
72///
73/// `root` is served first and labelled by its own `SessionId` (the agent's
74/// session id — *not* the registry's `agent:orchestrator` alias). Every session
75/// named by `roster` is served too, now and as it grows. A client addresses a
76/// session by `frame.session`; a server whose *current* served set is exactly
77/// one session also accepts any id, preserving the single-session flow (the
78/// `--socket` client's placeholder `"remote"` still reaches it). `define`, when
79/// installed, answers `Request::Define` — the composition root's worker factory.
80///
81/// `registry` is consulted (read-only) for each served session's model, so the
82/// roster this server pushes names models the server actually knows.
83pub async fn serve(
84    registry: Registry,
85    roster: Roster,
86    root: Root,
87    define: Option<DefineHandler>,
88    listener: UnixListener,
89) -> io::Result<()> {
90    loop {
91        let (stream, _addr) = listener.accept().await?;
92        tokio::spawn(connection(
93            registry.clone(),
94            roster.clone(),
95            root.clone(),
96            define.clone(),
97            stream,
98        ));
99    }
100}
101
102/// Bind `path`, then [`serve`].
103pub async fn serve_at(
104    registry: Registry,
105    roster: Roster,
106    root: Root,
107    define: Option<DefineHandler>,
108    path: &Path,
109) -> io::Result<()> {
110    let listener = crate::socket::bind(path).await?;
111    serve(registry, roster, root, define, listener).await
112}
113
114async fn connection(
115    registry: Registry,
116    mut roster: Roster,
117    root: Root,
118    define: Option<DefineHandler>,
119    stream: UnixStream,
120) {
121    let (read, write) = stream.into_split();
122    let (out, out_rx) = mpsc::unbounded_channel::<Frame<AgentEvent>>();
123    tokio::spawn(write_loop(write, out_rx));
124
125    let (root_id, root_handle) = root;
126    // Every id already fanned to this connection, so a session is fanned once.
127    let mut seen: HashSet<SessionId> = HashSet::new();
128
129    // Fan the root, then every session already in the roster. `borrow_and_update`
130    // marks the current value seen, so the watch task below only reacts to
131    // changes made *after* this snapshot — no id is fanned twice and none is
132    // missed.
133    seen.insert(root_id.clone());
134    fan(&out, &root_id, &root_handle);
135    let initial = roster.borrow_and_update().clone();
136    for (id, handle) in &initial {
137        if seen.insert(id.clone()) {
138            fan(&out, id, handle);
139        }
140    }
141    // Seed the connection with the roster it starts with, so a client learns the
142    // served ids on connect — and a session registered in the window before this
143    // task first ran (already folded into the snapshot above) is not lost to the
144    // watch's "seen" mark.
145    let _ = out.send(Frame {
146        v: PROTOCOL_VERSION,
147        id: 0,
148        reply_to: None,
149        session: root_id.clone(),
150        sender: None,
151        body: AgentEvent::Sessions {
152            sessions: roster_infos(&registry, &root_id, &initial),
153        },
154    });
155
156    // The read loop needs the live roster too, for routing and `ListSessions`.
157    // A clone is a read-only view (its own seen-pointer is irrelevant); the
158    // watch task owns the original, whose seen-pointer stays at the snapshot.
159    let read_roster = roster.clone();
160
161    // Follow the roster: fan each newly-seen session once and push the grown
162    // roster to this connection, so a client learns late ids without polling.
163    {
164        let out = out.clone();
165        let root_id = root_id.clone();
166        let registry = registry.clone();
167        tokio::spawn(async move {
168            loop {
169                if roster.changed().await.is_err() {
170                    break;
171                }
172                let current = roster.borrow_and_update().clone();
173                // Push on ANY roster change, not just growth: a state-only
174                // transition (a worker goes Running) leaves `locals()` identical,
175                // so a `grew` test would miss it — but `set_state` bumped the
176                // watch, so this task ran. The payload is tiny (one `SessionInfo`
177                // per member). `fan()` is still gated on `seen.insert`, so no
178                // event is fanned twice.
179                for (id, handle) in &current {
180                    if seen.insert(id.clone()) {
181                        fan(&out, id, handle);
182                    }
183                }
184                let sessions = roster_infos(&registry, &root_id, &current);
185                let _ = out.send(Frame {
186                    v: PROTOCOL_VERSION,
187                    id: 0,
188                    reply_to: None,
189                    session: root_id.clone(),
190                    sender: None,
191                    body: AgentEvent::Sessions { sessions },
192                });
193            }
194        });
195    }
196
197    // Answer each request (in its own task, so a long run never blocks reads).
198    let mut read = BufReader::new(read);
199    while let Ok(Some(frame)) = read_frame::<_, Request>(&mut read).await {
200        let Frame {
201            id, session, sender, body, ..
202        } = frame;
203
204        // The served set: the root first, then the live roster (deduplicated).
205        let live: Vec<(SessionId, SessionHandle)> = read_roster
206            .borrow()
207            .iter()
208            .filter(|(sid, _)| sid != &root_id)
209            .cloned()
210            .collect();
211
212        // `ListSessions` names no session: the server answers it directly with
213        // the roster (root first), before any demux.
214        if matches!(body, Request::ListSessions) {
215            let sessions = roster_infos(&registry, &root_id, &live);
216            let _ = out.send(Frame {
217                v: PROTOCOL_VERSION,
218                id,
219                reply_to: Some(id),
220                session,
221                sender: None,
222                body: AgentEvent::Sessions { sessions },
223            });
224            continue;
225        }
226
227        // `Define` names no session either: the server answers it directly, via
228        // the injected handler (or a clean error when none is installed). The
229        // handler registers into the same registry the roster watches, so the
230        // new worker is pushed to every client with no extra work.
231        if let Request::Define {
232            name,
233            model,
234            role,
235            tools,
236            base_url,
237            api_key,
238            read_only,
239            effort,
240        } = &body
241        {
242            let reply = match &define {
243                Some(handler) => match handler(DefineArgs {
244                    name: name.clone(),
245                    model: model.clone(),
246                    role: role.clone(),
247                    tools: tools.clone(),
248                    base_url: base_url.clone(),
249                    api_key: api_key.clone(),
250                    read_only: *read_only,
251                    effort: effort.clone(),
252                }) {
253                    Ok(id) => AgentEvent::Spawned { worker: id },
254                    Err(message) => AgentEvent::Error { message },
255                },
256                None => AgentEvent::Error {
257                    message: "agent definition is not enabled on this server (serve --agents)"
258                        .into(),
259                },
260            };
261            let _ = out.send(Frame {
262                v: PROTOCOL_VERSION,
263                id,
264                reply_to: Some(id),
265                session,
266                sender: None,
267                body: reply,
268            });
269            continue;
270        }
271
272        // Demux on `frame.session`: the root; a roster session of that name; the
273        // sole session of a single-session server (any id — the legacy default);
274        // or, on a multi-session server, a correlated error echoing the request.
275        let handle = if session == root_id {
276            Some(root_handle.clone())
277        } else if let Some((_, handle)) = live.iter().find(|(sid, _)| sid == &session) {
278            Some(handle.clone())
279        } else if live.is_empty() {
280            Some(root_handle.clone())
281        } else {
282            None
283        };
284        let Some(handle) = handle else {
285            let message = format!("unknown session {session}");
286            let _ = out.send(Frame {
287                v: PROTOCOL_VERSION,
288                id,
289                reply_to: Some(id),
290                session,
291                sender: None,
292                body: AgentEvent::Error { message },
293            });
294            continue;
295        };
296
297        let out = out.clone();
298        tokio::spawn(async move {
299            // A peer's frame carries its address in `from`; feed it to the
300            // target so `before_inbound` and the sender tag see it (A2A).
301            let reply = match sender {
302                Some(from) => handle.ask_from(from, body).await,
303                None => handle.ask(body).await,
304            };
305            if let Ok(reply) = reply {
306                let frame = Frame {
307                    v: PROTOCOL_VERSION,
308                    id,
309                    reply_to: Some(id),
310                    session,
311                    sender: None,
312                    body: reply,
313                };
314                let _ = out.send(frame);
315            }
316        });
317    }
318}
319
320/// The served sessions in order: the root first, then each roster session (minus
321/// the root, if a roster ever names it) — each joined with the model the registry
322/// knows for it (`None` when unset). The push payload of `AgentEvent::Sessions`.
323fn roster_infos(
324    registry: &Registry,
325    root_id: &SessionId,
326    roster: &[(SessionId, SessionHandle)],
327) -> Vec<SessionInfo> {
328    let mut ids = vec![root_id.clone()];
329    for (id, _) in roster {
330        if id != root_id {
331            ids.push(id.clone());
332        }
333    }
334    ids.into_iter()
335        .map(|id| {
336            let model = registry.model_of(&id);
337            let state = registry.state_of(&id);
338            SessionInfo { id, model, state }
339        })
340        .collect()
341}
342
343/// Forward one session's events to a connection, each frame stamped with its
344/// origin session — the routing key the client demuxes on. Ends when the
345/// subscriber closes or the connection's queue is gone.
346fn fan(out: &mpsc::UnboundedSender<Frame<AgentEvent>>, session: &SessionId, handle: &SessionHandle) {
347    let mut events = handle.subscribe();
348    let out = out.clone();
349    let session = session.clone();
350    tokio::spawn(async move {
351        loop {
352            match events.recv().await {
353                Ok(event) => {
354                    let frame = Frame {
355                        v: PROTOCOL_VERSION,
356                        id: 0,
357                        reply_to: None,
358                        session: session.clone(),
359                        sender: None,
360                        body: event,
361                    };
362                    if out.send(frame).is_err() {
363                        break;
364                    }
365                }
366                Err(broadcast::error::RecvError::Lagged(_)) => continue,
367                Err(broadcast::error::RecvError::Closed) => break,
368            }
369        }
370    });
371}
372
373async fn write_loop<W>(write: W, mut rx: mpsc::UnboundedReceiver<Frame<AgentEvent>>)
374where
375    W: AsyncWrite + Unpin,
376{
377    let mut write = BufWriter::new(write);
378    while let Some(frame) = rx.recv().await {
379        if write_frame(&mut write, &frame).await.is_err() {
380            break;
381        }
382    }
383}