Skip to main content

onlyne_client/session/dispatch/
delivery.rs

1use super::*;
2
3use super::env::{missing_capability, reject_protocol_command_in_pane, served_socket, session_env};
4use super::outbound::send_frame;
5use super::projection::{note_verdict, sync_session};
6use super::state::{
7    DispatchInner, DispatchState, SessionSlot, live_sessions, note_beat, rebase_generation,
8    render_tokens, slot_key_serving_task,
9};
10use super::transport::{is_revived_connection, note_binding_locked, record_revived_connection};
11
12pub fn dispatch(state: &DispatchState, envelope: &Envelope) -> Result<SessionRef> {
13    let causality = envelope
14        .causality
15        .as_ref()
16        .context("task envelope missing causality.task")?;
17    let task_id = causality.task.clone();
18    let mut inner = state.inner.lock();
19    // A task this role already serves rides its own slot, and the slot that
20    // still holds delivery rights is the one that serves it: staging the payload
21    // on a read-only revival would hand the work to an agent that may answer for
22    // it but may be handed nothing.
23    if let Some(session) = slot_key_serving_task(&inner, &task_id)
24        .and_then(|key| inner.sessions.get_mut(&key))
25        .map(|slot| {
26            if slot.payload.is_none() {
27                slot.payload = Some(envelope.clone());
28                slot.causality = causality.clone();
29            }
30            slot.session.clone()
31        })
32    {
33        inner.stall.note_assigned(&task_id, Instant::now());
34        return Ok(session);
35    }
36    if live_sessions(&inner) >= inner.max_sessions as usize {
37        return Err(anyhow!("max_sessions reached"));
38    }
39    let session_id = task_id.clone();
40    let command = render_tokens(&inner.command, &session_id, &task_id);
41    reject_protocol_command_in_pane(inner.backend.name(), &command)?;
42    let env = session_env(
43        &inner.role,
44        &session_id,
45        &task_id,
46        &inner.relay_required,
47        inner.relay_count,
48        &inner.topology,
49        // One tree answers both halves of this spawn: the cwd below and the
50        // socket the plugin dials, so a session whose workspace resolves to a
51        // short endpoint is handed the served path directly.
52        &served_socket(&inner.workspace),
53    );
54    let session = inner.backend.spawn(SpawnSpec {
55        cwd: inner.workspace.clone(),
56        task_id: task_id.clone(),
57        command,
58        env,
59        focus: None,
60        placement: None,
61        rename: None,
62    })?;
63    inner.bridge.track_live(session.clone());
64    // The task's own record opens with the session that serves it, out of the
65    // causality that named the task. A redelivery that found a slot already
66    // serving above never reaches this line, so the chain columns are the chain
67    // the session opened on; a re-dispatch after retirement refreshes them.
68    inner.store.open_task(causality, task_cause(causality))?;
69    // A row this task already carries is the record of the session that served it
70    // before, and the session staged here is born onto it: `rebase_born_session`
71    // moves that row to a generation of its own, and a task with no row keeps the
72    // plain seed below. Either way the row the feeds land on starts at
73    // `Booting`/`Detached`, under a watermark this session's own count can clear.
74    rebase_born_session(&inner, &task_id);
75    feed_created(&inner.bridge, &inner.store, &task_id)?;
76    feed_dispatched(&inner.bridge, &inner.store, &task_id);
77    // A plugin-mode session's liveness is the heartbeat its connection sends and
78    // nothing else, and that connection has not spoken yet: the window of
79    // `[client] reconnect_grace_secs` starts at birth, so a spawn whose plugin
80    // never dials is a ghost the sweep can see rather than a slot that holds its
81    // resource forever. The mount that attaches clears the stamp. A self-driven
82    // backend owns its agent and answers no adapter socket, so it never has a
83    // heartbeat to read and its lifecycle, not this clock, is what ends it.
84    let dropped_at = (!inner.backend.self_driven()).then(Instant::now);
85    // The liveness stamp starts with the slot, so a session whose plugin mounts
86    // and then never sends a frame is readable as silent rather than as a
87    // session nobody can judge.
88    let last_beat = Some(Instant::now());
89    inner.sessions.insert(
90        session_id,
91        SessionSlot {
92            session: session.clone(),
93            task_id: Some(task_id.clone()),
94            ready: false,
95            payload: Some(envelope.clone()),
96            msg_id: None,
97            origin: Some(envelope.from.clone()),
98            causality: causality.clone(),
99            dropped_at,
100            last_beat,
101            read_only: false,
102        },
103    );
104    inner.stall.note_assigned(&task_id, Instant::now());
105    Ok(session)
106}
107
108/// How a delivery reached this role, which is what the task record's `kind`
109/// column holds. A task with a parent above it was handed down from another
110/// session's work; one without was given to this role directly. The envelope's
111/// own message kind is not that answer: only a task-shaped delivery ever reaches
112/// a session, so the kind says nothing the chain does not.
113fn task_cause(causality: &Causality) -> &'static str {
114    if causality.parent_task.is_some() {
115        "relay"
116    } else {
117        "root"
118    }
119}
120
121/// Move the row a re-dispatched task already carries onto the generation of the
122/// session this dispatch stages.
123///
124/// This client keeps `client.db` across a restart and a session row is keyed by
125/// its task, so the session staged here is born onto whatever row that task
126/// already carries; a first dispatch is the only shape with none. That row is the
127/// record of the session that served the task before, one no slot of this process
128/// holds any more, and none of what it holds can be inherited. Its phase and its
129/// resource describe a session that no longer exists, and the feeds below would
130/// have to move them from states that refuse them: `resource_attach` from a
131/// closed resource is `UndefinedTransition`, which is how the log reads when a
132/// ghost was swept before the task came back. Its watermark is worse, because a
133/// row it stands on accepts nothing that reads older: the client's own feeds and
134/// the plugin's beats share the one counter, and the plugin is a new process
135/// whose sequence starts at its base again, below anything a session that lived a
136/// while left. Every frame the new session sends is then dropped as a stale
137/// duplicate, the turn its agent really ran never reaches the row, and the settle
138/// door refuses the completion of work that happened.
139///
140/// The new generation itself is [`rebase_generation`]'s; the body here is the
141/// born tuple of any session — the same `Observation::initial` a fresh row is
142/// seeded from, with the role's reconcile policy carried over, so the two ways a
143/// session's row comes into being cannot drift. What attests the old generation
144/// dead is this client's own bookkeeping: this call is reached only because no
145/// slot of this process serves the task, so nothing it holds speaks for that row
146/// any more.
147fn rebase_born_session(inner: &DispatchInner, task_id: &str) {
148    let verdict = rebase_generation(inner, task_id, |stored| {
149        Observation::initial(stored.isolate_after, stored.terminate_after)
150    });
151    match verdict {
152        Ok(Some(Verdict::Applied(next))) => tracing::info!(
153            task = %task_id,
154            generation = next.version.generation,
155            "the row a re-dispatched session is born onto was rebased onto a new generation"
156        ),
157        Ok(Some(verdict)) => tracing::warn!(
158            task = %task_id,
159            ?verdict,
160            "the row of a re-dispatched session was not rebased"
161        ),
162        Ok(None) => {}
163        Err(error) => tracing::warn!(
164            task = %task_id,
165            error = %error,
166            "the row of a re-dispatched session was not rebased"
167        ),
168    }
169}
170
171/// Adapter facts that make a session usable for its task.
172pub struct ReadyNotice {
173    pub task_id: String,
174    pub session_id: String,
175    pub generation: u64,
176    /// Adapter transport for plugin-driven backends. A self-driven backend owns
177    /// its agent and therefore reports ready without a socket.
178    pub io: Option<AdapterIo>,
179    pub capabilities: Vec<Capability>,
180}
181
182/// Report the session ready and hand its held payload to its agent. The `ready`
183/// row reaches the ledger before either the backend delivery or adapter frame,
184/// which is the causal order §6 requires.
185pub async fn on_ready(state: &DispatchState, notice: ReadyNotice, prose: &str) -> Result<()> {
186    let ReadyNotice {
187        task_id,
188        session_id,
189        generation,
190        io,
191        capabilities,
192    } = notice;
193    let (payload, target, session, backend, version) = {
194        let mut inner = state.inner.lock();
195        let backend = Arc::clone(&inner.backend);
196        // A ready report binds its connection to the session as much as a mount
197        // does, so it runs the same judgement §1 (b) hangs on: a connection that
198        // returns to a session a newer connection already serves takes nothing,
199        // leaves nothing marked ready, and is held for that task's completion.
200        if let Some(connection) = io.as_ref() {
201            if is_revived_connection(&inner, connection) {
202                return Ok(());
203            }
204            if !note_binding_locked(&mut inner, &session_id, connection) {
205                record_revived_connection(
206                    &mut inner,
207                    &session_id,
208                    connection.clone(),
209                    capabilities.clone(),
210                );
211                return Ok(());
212            }
213        }
214        let slot = inner
215            .sessions
216            .values_mut()
217            .find(|slot| {
218                slot.session.task_id == task_id
219                    || slot
220                        .session
221                        .backend_ref
222                        .get("id")
223                        .and_then(|value| value.as_str())
224                        == Some(session_id.as_str())
225            })
226            .ok_or_else(|| anyhow!("unknown session for {task_id}"))?;
227        if slot.read_only {
228            return Ok(());
229        }
230        // The hand-off runs once per session: a plugin that reports ready
231        // after the assignment already left finds the payload gone.
232        let Some(payload) = slot.payload.take() else {
233            return Ok(());
234        };
235        slot.origin = Some(payload.from.clone());
236        slot.ready = true;
237        let session = slot.session.clone();
238        let verdict = feed_ready(&inner.bridge, &inner.store, &task_id)?;
239        if matches!(verdict, Verdict::Applied(_)) {
240            // The ready report is a frame this session sent that the reducer
241            // took, so it is liveness like any other: the sweep reads the stamp
242            // rather than the socket, and a session whose plugin passed the
243            // barrier and then went quiet has to be readable as quiet.
244            note_beat(&mut inner, &task_id, Instant::now());
245        }
246        let version = note_verdict(&verdict, &task_id).unwrap_or(Version::new(generation, 0));
247        (payload, io, session, backend, version)
248    };
249    // The ready report reaches the server before the payload reaches the agent.
250    send_frame(
251        state,
252        ClientOp::Report(Report::Ready {
253            task_id: task_id.clone(),
254            session_id: session_id.clone(),
255            generation: version.generation,
256            seq: version.seq,
257            cluster_ref: None,
258        }),
259    )
260    .await?;
261    sync_session(state, &task_id).await?;
262    let text = payload.body.text.clone().unwrap_or_default();
263    match (backend.self_driven(), target) {
264        (true, None) => backend.deliver(&session, &task_id, &text),
265        (true, Some(_)) => Err(anyhow!(
266            "self-driven session {session_id} unexpectedly has an adapter transport"
267        )),
268        (false, Some(target)) if capabilities.contains(&Capability::Inject) => {
269            let assign = AssignArgs {
270                envelope: Box::new(payload),
271                prose: prose.to_string(),
272                task_id,
273                generation,
274                parent: None,
275            };
276            target
277                .notify(AdapterMsg::Host(HostOp::Assign(assign)))
278                .await
279                .map_err(|e| anyhow!(e))
280        }
281        (false, Some(target)) => target
282            .notify(AdapterMsg::Host(HostOp::ConfigGet(
283                onlyne_proto::ConfigGetArgs {
284                    key: format!("stdin:{text}"),
285                },
286            )))
287            .await
288            .map_err(|e| anyhow!(e)),
289        (false, None) => Err(anyhow!(
290            "adapter-backed session {session_id} reported ready without a transport"
291        )),
292    }
293}
294
295impl DispatchState {
296    /// Bind a plugin transport to one staged session and hand it the payload.
297    ///
298    /// Both hand-over paths run through here: a plugin that mounted first, and a
299    /// session staged first. The report that marks the session ready leaves
300    /// before the assignment, which is the causal order §6 line 285 fixes.
301    pub async fn hand_session(
302        &self,
303        task_id: &str,
304        io: AdapterIo,
305        capabilities: Vec<Capability>,
306    ) -> Result<()> {
307        let prose = self.role_prose();
308        on_ready(
309            self,
310            ReadyNotice {
311                task_id: task_id.to_string(),
312                session_id: task_id.to_string(),
313                generation: self.session_generation(task_id).unwrap_or(1),
314                io: Some(io),
315                capabilities,
316            },
317            &prose,
318        )
319        .await
320    }
321
322    /// Route one staged session to the thing that serves it.
323    ///
324    /// A self-driven backend owns its agent and takes the payload immediately,
325    /// without an adapter socket. Otherwise the session's own connection comes
326    /// first: a plugin the client spawned mounts with this session's id in
327    /// `ONLYNE_SESSION_ID`, and a plugin that reconnected mounts with it again,
328    /// so its assignment rides that socket alone. A plugin parked for the role
329    /// takes the next staged session, once. A plugin-driven session with neither
330    /// waits for its mount. Answers whether the payload had somewhere to go.
331    pub async fn hand_staged(&self, session_id: &str) -> Result<bool> {
332        let self_driven = self.inner.lock().backend.self_driven();
333        if self_driven {
334            let prose = self.role_prose();
335            on_ready(
336                self,
337                ReadyNotice {
338                    task_id: session_id.to_string(),
339                    session_id: session_id.to_string(),
340                    generation: self.session_generation(session_id).unwrap_or(1),
341                    io: None,
342                    capabilities: Vec::new(),
343                },
344                &prose,
345            )
346            .await?;
347            return Ok(true);
348        }
349        let transport = self
350            .session_transport(session_id)
351            .or_else(|| self.claim_parked_transport(session_id));
352        let Some((io, capabilities)) = transport else {
353            return Ok(false);
354        };
355        self.hand_session(session_id, io, capabilities).await?;
356        Ok(true)
357    }
358
359    /// Hand one note to the session already serving this role's work.
360    ///
361    /// A note carries no task, so it owns no session: §3's note is a message to
362    /// an agent that is already running, and the plan refuses one whose role is
363    /// offline (`note_queue` off). A role with no running agent has nothing to
364    /// answer it, which is what the caller reports. Answers whether an agent
365    /// took the note.
366    pub async fn inject_note(&self, envelope: &Envelope) -> bool {
367        let Some((task_id, session_id)) = self.ready_session() else {
368            return false;
369        };
370        let Some((io, capabilities)) = self.session_transport(&session_id) else {
371            return false;
372        };
373        if missing_capability(&capabilities, Capability::Inject) {
374            tracing::debug!(task = %task_id, "plugin takes no message mid-task");
375            return false;
376        }
377        let generation = self.session_generation(&task_id).unwrap_or(1);
378        let assign = AssignArgs {
379            envelope: Box::new(envelope.clone()),
380            prose: self.role_prose(),
381            task_id,
382            generation,
383            parent: None,
384        };
385        io.notify(AdapterMsg::Host(HostOp::Assign(assign)))
386            .await
387            .is_ok()
388    }
389
390    /// The session a mid-task message can join: a ready slot serving a task,
391    /// answered as (task, session key).
392    fn ready_session(&self) -> Option<(String, String)> {
393        let inner = self.inner.lock();
394        inner
395            .sessions
396            .iter()
397            .find(|(_, slot)| slot.ready && slot.task_id.is_some() && !slot.read_only)
398            .map(|(key, slot)| {
399                (
400                    slot.task_id.clone().unwrap_or_else(|| key.clone()),
401                    key.clone(),
402                )
403            })
404    }
405}