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 actor_updates = updates.clone();
76        let task_target = target.clone();
77        let task_id = session_id.clone();
78        let abort = tasks.spawn(async move {
79            run_session_actor(
80                task_target,
81                actor_rx,
82                release_rx,
83                retirement_rx,
84                view_tx,
85                actor_updates,
86            )
87            .await;
88            task_id
89        });
90        actors.insert(
91            session_id.clone(),
92            ActorRegistration {
93                target: target.clone(),
94                commands: actor_tx,
95                releases: release_tx,
96                retirement: retirement_tx,
97                view: view_rx,
98                abort,
99            },
100        );
101    }
102}
103
104pub(super) async fn run_remote_session_actor(
105    session_id: String,
106    mut commands: mpsc::Receiver<ActorCommand>,
107    requests: mpsc::Sender<RemoteSessionRequest>,
108) {
109    while let Some(command) = commands.recv().await {
110        let request = match command {
111            ActorCommand::Submit {
112                queued_at: _,
113                command_id,
114                command,
115                admission,
116                reply,
117            } => RemoteSessionRequest::Submit {
118                session_id: session_id.clone(),
119                command_id,
120                command,
121                admission,
122                reply,
123            },
124            ActorCommand::Sync { reply } => RemoteSessionRequest::Sync {
125                session_id: session_id.clone(),
126                reply,
127            },
128            ActorCommand::RespondElicitation {
129                elicitation_id,
130                response,
131                reply,
132            } => RemoteSessionRequest::RespondElicitation {
133                session_id: session_id.clone(),
134                elicitation_id,
135                response,
136                reply,
137            },
138            ActorCommand::StopBackgroundTask {
139                background_task_id,
140                reply,
141            } => RemoteSessionRequest::StopBackgroundTask {
142                session_id: session_id.clone(),
143                background_task_id,
144                reply,
145            },
146            ActorCommand::Reviewer {
147                role,
148                action,
149                reply,
150            } => RemoteSessionRequest::Reviewer {
151                session_id: session_id.clone(),
152                role,
153                action,
154                reply,
155            },
156            ActorCommand::InstallPromptContext { reply, .. } => {
157                // Only the daemon that owns the relay can install context, and
158                // only a session it started is ever restored into.
159                let _ = reply.send(Err(
160                    "prompt context can be installed only inside the controller daemon".into(),
161                ));
162                continue;
163            }
164            ActorCommand::Lease { reply, .. } => {
165                let _ = reply.send(Err(anyhow::anyhow!(
166                    "relay connection leases are available only inside the controller daemon"
167                )));
168                continue;
169            }
170        };
171        if let Err(error) = requests.send(request).await {
172            match error.0 {
173                RemoteSessionRequest::Submit { reply, .. } => {
174                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
175                }
176                RemoteSessionRequest::Sync { reply, .. }
177                | RemoteSessionRequest::RespondElicitation { reply, .. }
178                | RemoteSessionRequest::StopBackgroundTask { reply, .. } => {
179                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
180                }
181                RemoteSessionRequest::Reviewer { reply, .. } => {
182                    let _ = reply.send(Err("controller daemon request bridge stopped".into()));
183                }
184            }
185            break;
186        }
187    }
188}
189
190pub(super) fn spawn_remote_actor(
191    session_id: String,
192    view: ManagedSessionView,
193    requests: &mpsc::Sender<RemoteSessionRequest>,
194    actors: &mut BTreeMap<String, RemoteActorRegistration>,
195    updates: &CoalescedUpdateSender,
196) {
197    let (actor_tx, actor_rx) = mpsc::channel(32);
198    let (release_tx, _release_rx) = mpsc::unbounded_channel();
199    let (view_tx, view_rx) = watch::channel(view.clone());
200    let abort = tokio::spawn(run_remote_session_actor(
201        session_id.clone(),
202        actor_rx,
203        requests.clone(),
204    ))
205    .abort_handle();
206    actors.insert(
207        session_id.clone(),
208        RemoteActorRegistration {
209            commands: actor_tx,
210            releases: release_tx,
211            view: view_rx,
212            view_tx,
213            abort,
214        },
215    );
216    updates.send(SessionManagerUpdate { session_id, view });
217}
218
219/// Build the read/control facade used by a control surface whose relay actors
220/// live in another process. Target updates still decide which session handles
221/// exist, while [`RemoteSessionPublisher`] supplies their latest views.
222pub fn spawn_remote_session_manager() -> Result<RemoteSessionManagerChannels> {
223    let (targets_tx, mut targets_rx) = watch::channel(Vec::<RelaySessionTarget>::new());
224    let (commands_tx, mut commands_rx) = mpsc::channel(32);
225    let (updates_tx, updates_rx) = coalesced_update_channel();
226    let (published_tx, mut published_rx) = mpsc::unbounded_channel();
227    let (requests_tx, requests_rx) = mpsc::channel(64);
228    let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
229    let task = tokio::spawn(async move {
230        let mut actors = BTreeMap::<String, RemoteActorRegistration>::new();
231        let mut latest = BTreeMap::<String, ManagedSessionView>::new();
232        let mut desired = BTreeMap::<String, RelaySessionTarget>::new();
233        loop {
234            tokio::select! {
235                _ = &mut shutdown_rx => break,
236                changed = targets_rx.changed() => {
237                    if changed.is_err() {
238                        break;
239                    }
240                    desired = target_map(&targets_rx.borrow_and_update());
241                    actors.retain(|session_id, actor| {
242                        if desired.contains_key(session_id) {
243                            true
244                        } else {
245                            actor.abort.abort();
246                            false
247                        }
248                    });
249                    // Drop the reseed view for every session that is no longer a
250                    // live target. `latest` is only ever inserted into otherwise,
251                    // so without this it keeps a full MaterializedSession per
252                    // session ever seen — a slow memory leak the actor
253                    // reconciliation above does not cover.
254                    latest.retain(|session_id, _| desired.contains_key(session_id));
255                    for session_id in desired.keys() {
256                        if !actors.contains_key(session_id)
257                            && let Some(view) = latest.get(session_id).cloned()
258                        {
259                            spawn_remote_actor(
260                                session_id.clone(),
261                                view,
262                                &requests_tx,
263                                &mut actors,
264                                &updates_tx,
265                            );
266                        }
267                    }
268                }
269                command = commands_rx.recv() => {
270                    let Some(ManagerCommand::Session { session_id, reply }) = command else {
271                        break;
272                    };
273                    let handle = actors.get(&session_id).map(|actor| ManagedSessionHandle {
274                        session_id: session_id.clone(),
275                        commands: actor.commands.clone(),
276                        releases: actor.releases.clone(),
277                        view: actor.view.clone(),
278                    });
279                    let _ = reply.send(handle);
280                }
281                published = published_rx.recv() => {
282                    let Some(RemoteManagerUpdate::Publish { session_id, view }) = published else {
283                        break;
284                    };
285                    latest.insert(session_id.clone(), view.clone());
286                    if !desired.contains_key(&session_id) {
287                        continue;
288                    }
289                    if let Some(actor) = actors.get(&session_id) {
290                        publish_view(&session_id, view, &actor.view_tx, &updates_tx);
291                        continue;
292                    }
293                    spawn_remote_actor(
294                        session_id,
295                        view,
296                        &requests_tx,
297                        &mut actors,
298                        &updates_tx,
299                    );
300                }
301            }
302        }
303        for actor in actors.into_values() {
304            actor.abort.abort();
305        }
306    });
307    Ok(RemoteSessionManagerChannels {
308        targets: targets_tx,
309        control: SessionManagerControl {
310            commands: commands_tx,
311        },
312        updates: updates_rx,
313        shutdown: SessionManagerShutdown {
314            signal: Some(shutdown_tx),
315            task: Some(task),
316        },
317        publisher: RemoteSessionPublisher {
318            updates: published_tx,
319        },
320        requests: RemoteSessionRequests {
321            requests: requests_rx,
322        },
323    })
324}