Skip to main content

mj_controller/session_manager/
remote.rs

1use super::*;
2
3pub(super) fn reconcile_action(
4    actor: Option<&RelaySessionTarget>,
5    desired: Option<&RelaySessionTarget>,
6) -> ReconcileAction {
7    match (actor, desired) {
8        (None, None) => ReconcileAction::Idle,
9        (None, Some(_)) => ReconcileAction::Spawn,
10        (Some(actor), Some(desired)) if actor == desired => ReconcileAction::Keep,
11        (Some(_), Some(_) | None) => ReconcileAction::Retire,
12    }
13}
14
15pub(super) fn target_map(targets: &[RelaySessionTarget]) -> BTreeMap<String, RelaySessionTarget> {
16    targets
17        .iter()
18        .cloned()
19        .map(|target| (target.session_id.clone(), target))
20        .collect()
21}
22
23pub(super) fn remove_actor_task(
24    actors: &mut BTreeMap<String, ActorRegistration>,
25    task_id: tokio::task::Id,
26) -> Option<String> {
27    let session_id = actors.iter().find_map(|(session_id, actor)| {
28        (actor.abort.id() == task_id).then(|| session_id.clone())
29    })?;
30    actors.remove(&session_id);
31    Some(session_id)
32}
33
34pub(super) fn reconcile_actors(
35    targets: &BTreeMap<String, RelaySessionTarget>,
36    actors: &mut BTreeMap<String, ActorRegistration>,
37    tasks: &mut tokio::task::JoinSet<String>,
38    updates: &CoalescedUpdateSender,
39) {
40    // A completed or cancelled task closes its command receiver before the
41    // JoinSet completion necessarily wins the manager's select. Do not let
42    // that dead registration suppress the replacement this reconciliation is
43    // responsible for starting. Task-ID-aware completion cleanup below keeps
44    // the old completion from removing the replacement later.
45    actors.retain(|session_id, actor| {
46        let live = !actor.commands.is_closed();
47        if !live {
48            tracing::warn!(session_id, "replacing stopped session relay actor");
49        }
50        live
51    });
52
53    for (session_id, actor) in actors.iter() {
54        let retiring = matches!(
55            reconcile_action(Some(&actor.target), targets.get(session_id)),
56            ReconcileAction::Retire
57        );
58        actor.retirement.send_replace(retiring);
59    }
60
61    for (session_id, target) in targets {
62        if !matches!(
63            reconcile_action(
64                actors.get(session_id).map(|actor| &actor.target),
65                Some(target)
66            ),
67            ReconcileAction::Spawn
68        ) {
69            continue;
70        }
71        let (actor_tx, actor_rx) = mpsc::channel(32);
72        let (release_tx, release_rx) = mpsc::unbounded_channel();
73        let (retirement_tx, retirement_rx) = watch::channel(false);
74        let (view_tx, view_rx) = watch::channel(ManagedSessionView::default());
75        let mut actor_updates = updates.for_actor(session_id);
76        actor_updates.observer = updates
77            .delegation
78            .as_ref()
79            .map(|feed| feed.register(session_id));
80        let task_target = target.clone();
81        let task_id = session_id.clone();
82        let abort = tasks.spawn(async move {
83            run_session_actor(
84                task_target,
85                actor_rx,
86                release_rx,
87                retirement_rx,
88                view_tx,
89                actor_updates,
90            )
91            .await;
92            task_id
93        });
94        actors.insert(
95            session_id.clone(),
96            ActorRegistration {
97                target: target.clone(),
98                commands: actor_tx,
99                releases: release_tx,
100                retirement: retirement_tx,
101                view: view_rx,
102                abort,
103            },
104        );
105    }
106}
107
108pub(super) async fn run_remote_session_actor(
109    session_id: String,
110    mut commands: mpsc::Receiver<ActorCommand>,
111    requests: mpsc::Sender<RemoteSessionRequest>,
112) {
113    while let Some(command) = commands.recv().await {
114        let request = match command {
115            ActorCommand::Submit {
116                queued_at: _,
117                command_id,
118                command,
119                admission,
120                reply,
121            } => RemoteSessionRequest::Submit {
122                session_id: session_id.clone(),
123                command_id,
124                command,
125                admission,
126                reply,
127            },
128            ActorCommand::Sync { reply } => RemoteSessionRequest::Sync {
129                session_id: session_id.clone(),
130                reply,
131            },
132            ActorCommand::RespondElicitation {
133                elicitation_id,
134                response,
135                reply,
136            } => RemoteSessionRequest::RespondElicitation {
137                session_id: session_id.clone(),
138                elicitation_id,
139                response,
140                reply,
141            },
142            ActorCommand::StopBackgroundTask {
143                background_task_id,
144                reply,
145            } => RemoteSessionRequest::StopBackgroundTask {
146                session_id: session_id.clone(),
147                background_task_id,
148                reply,
149            },
150            ActorCommand::Reviewer {
151                role,
152                action,
153                reply,
154            } => RemoteSessionRequest::Reviewer {
155                session_id: session_id.clone(),
156                role,
157                action,
158                reply,
159            },
160            ActorCommand::InstallPromptContext { reply, .. } => {
161                // Only the daemon that owns the relay can install context, and
162                // only a session it started is ever restored into.
163                let _ = reply.send(Err(
164                    "prompt context can be installed only inside the controller daemon".into(),
165                ));
166                continue;
167            }
168            ActorCommand::Lease { reply, .. } => {
169                let _ = reply.send(Err(anyhow::anyhow!(
170                    "relay connection leases are available only inside the controller daemon"
171                )));
172                continue;
173            }
174            ActorCommand::RelayJob { job } => {
175                job.refuse(anyhow::anyhow!(
176                    "relay connection jobs run only inside the controller daemon"
177                ));
178                continue;
179            }
180        };
181        if let Err(error) = requests.send(request).await {
182            match error.0 {
183                RemoteSessionRequest::Submit { reply, .. } => {
184                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
185                }
186                RemoteSessionRequest::Sync { reply, .. }
187                | RemoteSessionRequest::RespondElicitation { reply, .. }
188                | RemoteSessionRequest::StopBackgroundTask { reply, .. } => {
189                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
190                }
191                RemoteSessionRequest::Reviewer { reply, .. } => {
192                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
193                }
194            }
195            break;
196        }
197    }
198}
199
200pub(super) fn spawn_remote_actor(
201    session_id: String,
202    view: ManagedSessionView,
203    requests: &mpsc::Sender<RemoteSessionRequest>,
204    actors: &mut BTreeMap<String, RemoteActorRegistration>,
205    updates: &CoalescedUpdateSender,
206) {
207    let (actor_tx, actor_rx) = mpsc::channel(32);
208    let (release_tx, _release_rx) = mpsc::unbounded_channel();
209    let (view_tx, view_rx) = watch::channel(view.clone());
210    let abort = tokio::spawn(run_remote_session_actor(
211        session_id.clone(),
212        actor_rx,
213        requests.clone(),
214    ))
215    .abort_handle();
216    actors.insert(
217        session_id.clone(),
218        RemoteActorRegistration {
219            commands: actor_tx,
220            releases: release_tx,
221            view: view_rx,
222            view_tx,
223            abort,
224        },
225    );
226    updates.send(SessionManagerUpdate { session_id, view });
227}
228
229/// Build the read/control facade used by a control surface whose relay actors
230/// live in another process. Target updates still decide which session handles
231/// exist, while [`RemoteSessionPublisher`] supplies their latest views.
232pub fn spawn_remote_session_manager() -> Result<RemoteSessionManagerChannels> {
233    let (targets_tx, mut targets_rx) = watch::channel(Vec::<RelaySessionTarget>::new());
234    let (commands_tx, mut commands_rx) = mpsc::channel(32);
235    let (updates_tx, updates_rx) = coalesced_update_channel();
236    let (published_tx, mut published_rx) = mpsc::unbounded_channel();
237    let (requests_tx, requests_rx) = mpsc::channel(64);
238    let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
239    let task = tokio::spawn(async move {
240        let mut actors = BTreeMap::<String, RemoteActorRegistration>::new();
241        let mut latest = BTreeMap::<String, ManagedSessionView>::new();
242        let mut desired = BTreeMap::<String, RelaySessionTarget>::new();
243        loop {
244            tokio::select! {
245                _ = &mut shutdown_rx => break,
246                changed = targets_rx.changed() => {
247                    if changed.is_err() {
248                        break;
249                    }
250                    desired = target_map(&targets_rx.borrow_and_update());
251                    actors.retain(|session_id, actor| {
252                        if desired.contains_key(session_id) {
253                            true
254                        } else {
255                            actor.abort.abort();
256                            false
257                        }
258                    });
259                    // Drop the reseed view for every session that is no longer a
260                    // live target. `latest` is only ever inserted into otherwise,
261                    // so without this it keeps a full MaterializedSession per
262                    // session ever seen — a slow memory leak the actor
263                    // reconciliation above does not cover.
264                    latest.retain(|session_id, _| desired.contains_key(session_id));
265                    for session_id in desired.keys() {
266                        if !actors.contains_key(session_id)
267                            && let Some(view) = latest.get(session_id).cloned()
268                        {
269                            spawn_remote_actor(
270                                session_id.clone(),
271                                view,
272                                &requests_tx,
273                                &mut actors,
274                                &updates_tx,
275                            );
276                        }
277                    }
278                }
279                command = commands_rx.recv() => {
280                    let Some(ManagerCommand::Session { session_id, reply }) = command else {
281                        break;
282                    };
283                    let handle = actors.get(&session_id).map(|actor| ManagedSessionHandle {
284                        session_id: session_id.clone(),
285                        commands: actor.commands.clone(),
286                        releases: actor.releases.clone(),
287                        view: actor.view.clone(),
288                    });
289                    let _ = reply.send(handle);
290                }
291                published = published_rx.recv() => {
292                    let Some(RemoteManagerUpdate::Publish { session_id, view }) = published else {
293                        break;
294                    };
295                    latest.insert(session_id.clone(), view.clone());
296                    if !desired.contains_key(&session_id) {
297                        continue;
298                    }
299                    if let Some(actor) = actors.get(&session_id) {
300                        publish_view(&session_id, view, &actor.view_tx, &updates_tx);
301                        continue;
302                    }
303                    spawn_remote_actor(
304                        session_id,
305                        view,
306                        &requests_tx,
307                        &mut actors,
308                        &updates_tx,
309                    );
310                }
311            }
312        }
313        for actor in actors.into_values() {
314            actor.abort.abort();
315        }
316    });
317    Ok(RemoteSessionManagerChannels {
318        targets: targets_tx,
319        control: SessionManagerControl {
320            commands: commands_tx,
321            session_cpu: watch::channel(SessionCpuTable::new()).1,
322        },
323        updates: updates_rx,
324        shutdown: SessionManagerShutdown {
325            signal: Some(shutdown_tx),
326            task: Some(task),
327        },
328        publisher: RemoteSessionPublisher {
329            updates: published_tx,
330        },
331        requests: RemoteSessionRequests {
332            requests: requests_rx,
333        },
334    })
335}