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}