onlyne_client/session/dispatch/settle.rs
1use super::*;
2
3use super::outbound::{store_ack, transport_envelope};
4use super::projection::{note_verdict, sync_session};
5use super::retire::{release_locked, retire_idle_locked};
6use super::state::{
7 DispatchInner, DispatchState, slot_key_named, slot_key_serving_task, slot_task,
8};
9use super::transport::{names_session, serves_session};
10use onlyne_proto::ErrorCode;
11use onlyne_proto::adapter::HandoffArgs;
12
13/// Fault kind for a completion this client refused for want of a turn. The word
14/// is what `onlyne faults` and `onlyne-client status` carry, so it names the
15/// reading that refused the frame.
16pub const SETTLE_WITHOUT_TURN: &str = "settle_without_turn";
17
18/// Who asked for one settle, which is what decides whether the never-ran guard
19/// reads it.
20///
21/// The guard exists for the door where the claim and the claimant are the same
22/// party: a plugin reports its own ending, and a session whose agent never ran
23/// can report one too. The other two doors carry the client's own act, so their
24/// evidence is already in this process — a self-driven backend that watched its
25/// agent end, or an operator's `control` command that asked for the ending.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum SettleAuthority {
28 /// A `complete` report from a plugin connection. Guarded.
29 PluginReport,
30 /// A terminal fact this client reached through its own eyes: the ending a
31 /// self-driven backend reported through `outcome_loop`. Unguarded: this
32 /// client is the witness of the work it watched.
33 ClientOwned,
34 /// The answer to a `control recycle` or `control cancel` this client issued
35 /// for the task. Unguarded: the operator asked for this ending, and the
36 /// plugin's report and the retirement this command runs race over the row,
37 /// so the row's phase at the moment the frame lands decides nothing.
38 ControlDriven,
39}
40
41/// Whether one session's own row carries a turn, and the agent-phase word it
42/// holds for the operator's reading.
43///
44/// The row is this client's record: a beat's `agent` dimension reaches it
45/// through the `(generation, seq)` gate in `apply_persist`, and the ready
46/// barrier's `feed_ready` writes `Ready` into the same column. `Running` and
47/// `Idle` are the two phases a turn puts the tuple through
48/// (`crates/onlyne-session/src/lifecycle/state.rs:11`), so one of them is the
49/// answer. `Booting`, `Ready` and `Gone` are each a session that has run nothing
50/// this client can point at: `Gone` is written by `AgentGone` and
51/// `ResourceClosed` from any live phase, which leaves the death of an agent that
52/// never started reading exactly like the death of one that worked. A task this
53/// client holds no row for answers the same way, with its own word in the reason.
54fn turn_recorded(inner: &DispatchInner, task_id: &str) -> (bool, String) {
55 let Ok(Some(row)) = inner.store.get_session(task_id) else {
56 return (false, "no session row".to_string());
57 };
58 let agent = stored_observation(&inner.store, Some(&row)).agent;
59 (
60 matches!(agent, AgentState::Running | AgentState::Idle),
61 row.agent_state,
62 )
63}
64
65/// Settle one finished task: relay what its report asked to hand on, publish the
66/// verdict, and answer the sender.
67///
68/// The relay runs first and on purpose. A role that takes the handed-on task
69/// must find the chain already pointing at it when the completion receipt
70/// arrives, and a handoff that outlives this call has no caller left to record
71/// its refusal.
72///
73/// A replayed delivery for work whose first verdict already settled is ordinary
74/// at-least-once traffic. The first verdict stands. The replay returns the task
75/// binding. The session that took the replay lets go of its slot.
76/// The first receipt, `out_head`, and handoff relay remain attached to that
77/// first settlement.
78///
79/// A plugin report with no turn behind it is refused whole the same way, ahead of
80/// every write this call makes: the drain opens over work that never ran, so the
81/// completion intent, the verdict, the `out_head` line and the delivery ack all
82/// stay unwritten and the row is left for the server's requeue. The frame is
83/// answered as an applied one — a plugin treats a failed report as a link that
84/// died, and sends the same terminal fact again — and the fault the refusal
85/// leaves behind names the reading that refused.
86pub async fn on_out(
87 state: &DispatchState,
88 task_id: &str,
89 outcome: Outcome,
90 head: Option<String>,
91 head_kind: Option<&str>,
92 handoffs: &[Handoff],
93 asked: SettleAuthority,
94) -> Result<()> {
95 if asked == SettleAuthority::PluginReport {
96 let inner = state.inner.lock();
97 let (turn, phase) = turn_recorded(&inner, task_id);
98 if !turn {
99 let reason = format!(
100 "no turn ran: the agent phase this client holds for the session reads {phase}"
101 );
102 onlyne_session::record_fault(
103 &inner.store,
104 task_id,
105 SETTLE_WITHOUT_TURN,
106 "client",
107 &reason,
108 )?;
109 tracing::warn!(
110 task = %task_id,
111 ?outcome,
112 phase = %phase,
113 "a completion arrived for a session that never ran a turn; the task stays open"
114 );
115 return Ok(());
116 }
117 }
118 let settled = {
119 let mut inner = state.inner.lock();
120 let verdict = settle(&inner.bridge, &inner.store, task_id)?;
121 // The verdict lands in the task's own record, after the delivery drain
122 // that makes it `accepted`. `settle` invents no receipt, so a session
123 // whose task is settled and whose delivery drained is the pair `project`
124 // reads as `exited`; writing the task record first would leave a verdict
125 // beside a delivery that never drained if the drain failed, and the
126 // report that would fix it has already been answered.
127 if !inner.store.settle_task(task_id, task_state_of(outcome))? {
128 tracing::warn!(
129 task = %task_id,
130 ?outcome,
131 "a second verdict arrived for a settled task; the first one stands"
132 );
133 note_verdict(&verdict, task_id);
134 // The replayed session still owns the task binding until this
135 // release, so the standing verdict travels with the client's own
136 // post-release tuple and capacity returns to the role.
137 release_locked(&mut inner, task_id, None)?;
138 None
139 } else {
140 inner
141 .store
142 .put_out_head(task_id, head.as_deref().unwrap_or(""))?;
143 // The handle and the chain this answer travels on belong to the session
144 // serving the task, not to a read-only one that came back for it.
145 let slot =
146 slot_key_serving_task(&inner, task_id).and_then(|key| inner.sessions.get_mut(&key));
147 let origin = slot.as_ref().and_then(|slot| slot.origin.clone());
148 let causality = slot.as_ref().map(|slot| slot.causality.clone());
149 let msg_id = slot.and_then(|slot| slot.msg_id.take());
150 if let Some(msg_id) = msg_id {
151 store_ack(
152 &inner,
153 AckArgs {
154 msg_id,
155 op_id: None,
156 accepted: true,
157 reason: None,
158 },
159 );
160 }
161 // A settled session gives its capacity back, so a role at
162 // `max_sessions` takes the next row instead of holding finished slots.
163 release_locked(&mut inner, task_id, None)?;
164 // Whatever a read-only connection held for this task is answered by this
165 // completion, so it leaves the buffer here and travels beside the report.
166 let held = inner.held_handoffs.remove(task_id);
167
168 Some((
169 verdict,
170 completion_envelope(
171 &inner.role,
172 origin,
173 task_id,
174 head.as_deref(),
175 causality.as_ref(),
176 ),
177 inner.role.clone(),
178 causality,
179 held,
180 ))
181 }
182 };
183 // The refused branch carries the release result out of the lock. The task
184 // account remains the first verdict. The client row is published after the
185 // replay session has returned its binding and completed its retirement.
186 let Some((verdict, receipt, role, causality, held)) = settled else {
187 return sync_session(state, task_id).await;
188 };
189 note_verdict(&verdict, task_id);
190 // Every relay is answered before the verdict travels, and none of them
191 // moves it: a refused handoff is a record on the settled task, not a
192 // different outcome for it. The merge happens on the way in, so a downstream
193 // role reads one envelope for this task, not two.
194 let routed = merged_handoffs(
195 handoffs,
196 held.as_deref(),
197 head.as_deref().unwrap_or_default(),
198 );
199 // A settle can race the retirement of the session it serves, and a task no
200 // slot answers for is still a task the relay may name: the fallback is the
201 // task itself as the root of its own family, which is the child link a
202 // missing chain would have produced for it.
203 let parent = causality.unwrap_or_else(|| Causality::root(task_id));
204 let denied = handoff::route(
205 state,
206 &role,
207 &parent,
208 head_kind,
209 head.as_deref().unwrap_or_default(),
210 &routed,
211 )
212 .await;
213 record_denials(state, task_id, &denied)?;
214 // The merged relay has left, so the read-only session that wrote its half of
215 // it is retired. The settled account above is the whole settlement: nothing
216 // here settles or releases this task a second time.
217 retire_revived(state, task_id).await;
218 // The terminal receipt leaves as its own envelope, so the origin — a role
219 // or a gateway conversation — learns the outcome (plan §3 `Completion`).
220 // It rides the intent queue, which is what makes a completion survive the
221 // disconnect rules of §6 line 289.
222 if let Some(envelope) = receipt {
223 transport_envelope(state, &envelope).await?;
224 }
225 sync_session(state, task_id).await
226}
227
228/// One relay per downstream role, carrying this completion's own lines and the
229/// ones a read-only connection held for the same task.
230///
231/// Each line keeps the marker of the session that wrote it — `[retry]` for the
232/// session that finished and `[zombie]` for the one that came back for the task
233/// and was held — so the recipient can tell the two accounts apart inside the one
234/// envelope. Line order follows the report's own order, with the held lines of the
235/// same role below them. Nothing held is the ordinary case, and it routes the
236/// report's lines without copying them.
237fn merged_handoffs<'a>(
238 own: &'a [Handoff],
239 held: Option<&'a [Handoff]>,
240 head: &str,
241) -> Cow<'a, [Handoff]> {
242 let held: &[Handoff] = match held {
243 Some(held) if !held.is_empty() => held,
244 _ => return Cow::Borrowed(own),
245 };
246 let mut order: Vec<String> = Vec::new();
247 let mut segments: HashMap<String, Vec<String>> = HashMap::new();
248 for (marker, group) in [("[retry]", own), ("[zombie]", held)] {
249 for handoff in group {
250 let line = format!("{marker} {}", handoff.text_or(head));
251 if !segments.contains_key(handoff.to_role.as_str()) {
252 order.push(handoff.to_role.clone());
253 }
254 segments
255 .entry(handoff.to_role.clone())
256 .or_default()
257 .push(line);
258 }
259 }
260 Cow::Owned(
261 order
262 .into_iter()
263 .map(|to_role| Handoff {
264 text: Some(
265 segments
266 .remove(to_role.as_str())
267 .unwrap_or_default()
268 .join("\n"),
269 ),
270 to_role,
271 })
272 .collect(),
273 )
274}
275
276/// Retire the read-only connections and slots a merged handoff has just answered.
277///
278/// A connection that came back for a session another connection serves is
279/// dropped from that session's record and its agent is told to leave, since what
280/// it had to say travelled with the relay above. A slot that lost its task to a
281/// newer session has the transport naming it dropped, its task binding released,
282/// and is retired as `Replaced`: the resource its agent was holding is this
283/// client's to close, and the newer session answers for the task. The account for
284/// the task is the settlement above.
285///
286/// A connection inside its own inbound frame is left alone. `adapter_socket`
287/// awaits the handler before it answers the frame, so a bye written here would
288/// leave ahead of that connection's own response, and the plugin's bye handler
289/// drops the socket and rejects every request awaiting an answer — a completion
290/// the ledger already holds would reach the agent as a failure it retries. The
291/// entry stays in `revived`: the connection's `detach` frame or its socket end
292/// retires it through `release_connection`, and the plugin that just completed
293/// ends its own session either way.
294async fn retire_revived(state: &DispatchState, task_id: &str) {
295 let leaving = {
296 let mut inner = state.inner.lock();
297 let mut leaving: Vec<AdapterIo> = Vec::new();
298 let mut silent: Vec<String> = Vec::new();
299 for (session_id, io, _) in inner.revived.iter() {
300 if inner.in_frame.iter().any(|busy| busy.same_connection(io)) {
301 continue;
302 }
303 let reaches = slot_key_named(&inner, session_id)
304 .and_then(|key| {
305 inner
306 .sessions
307 .get(&key)
308 .map(|slot| slot_task(slot) == task_id)
309 })
310 .unwrap_or_else(|| session_id == task_id);
311 if reaches {
312 leaving.push(io.clone());
313 silent.push(session_id.clone());
314 }
315 }
316 inner
317 .revived
318 .retain(|(session_id, _, _)| !silent.contains(session_id));
319 let silenced: Vec<String> = inner
320 .sessions
321 .iter()
322 .filter(|(_, slot)| slot.read_only && slot_task(slot) == task_id)
323 .map(|(key, _)| key.clone())
324 .collect();
325 for key in silenced {
326 let Some(slot) = inner.sessions.get(&key).cloned() else {
327 continue;
328 };
329 if slot.payload.is_some() {
330 tracing::warn!(
331 session = %key,
332 task = %task_id,
333 "a read-only session retires with a payload it was never handed"
334 );
335 }
336 let served: Vec<String> = inner
337 .transports
338 .keys()
339 .filter(|served| names_session(&key, &slot, served))
340 .cloned()
341 .collect();
342 for session_id in served {
343 inner.transports.remove(&session_id);
344 }
345 if let Some(current) = inner.sessions.get_mut(&key) {
346 current.task_id = None;
347 current.ready = false;
348 current.read_only = false;
349 current.dropped_at = None;
350 }
351 retire_idle_locked(&mut inner, &key, onlyne_session::CloseReason::Replaced);
352 }
353 leaving
354 };
355 for io in leaving {
356 let notice = AdapterMsg::Host(HostOp::Bye(onlyne_proto::ByeNotice {
357 reason: "the session that took this task answered for yours".into(),
358 }));
359 if let Err(error) = io.notify(notice).await {
360 tracing::debug!(error = %error, "the read-only connection had already left");
361 }
362 }
363}
364
365/// Write down the relays this role could not send.
366///
367/// Each refusal gets an event of its own, because that is the plane a supervisor
368/// reads to see which handoff line died. The fault queue dedups on
369/// `(task, kind, generation)`, so the first refusal of a turn is also the one
370/// the task's fault row names; the rest stay in the events.
371fn record_denials(state: &DispatchState, task_id: &str, denied: &[Denial]) -> Result<()> {
372 if denied.is_empty() {
373 return Ok(());
374 }
375 let inner = state.inner.lock();
376 for refusal in denied {
377 tracing::warn!(
378 task = %task_id,
379 to_role = %refusal.to_role,
380 error = %refusal.reason,
381 "handoff denied"
382 );
383 inner.store.append_event(
384 "handoff_denied",
385 &serde_json::json!({
386 "task_id": task_id,
387 "to_role": refusal.to_role,
388 "text": refusal.text,
389 "error": refusal.reason,
390 }),
391 )?;
392 onlyne_session::record_fault(
393 &inner.store,
394 task_id,
395 "handoff_denied",
396 "acp",
397 &format!("{}: {}", refusal.to_role, refusal.reason),
398 )?;
399 }
400 Ok(())
401}
402
403/// The receipt for one finished task, or `None` when its sender is unknown.
404///
405/// Every settled task answers its sender, the role that sent the task included:
406/// §3's `Completion` is the durable record that the work ended, and a role
407/// reading its own receipt ack is what settles the row.
408///
409/// The receipt names the task it answers and carries that task's own family
410/// figures — the family id, the hop budget, the origin, the deadline, and the
411/// labels — so a run's tasks and its completions print the same arc in
412/// `onlyne ledger`. It sits at the depth of the task it answers, and it is no
413/// link in the chain: it names no parent and replies to nothing. A task whose
414/// slot the client no longer holds, which is a row an older build opened, keeps
415/// the shape of a bare receipt.
416fn completion_envelope(
417 role: &str,
418 origin: Option<Principal>,
419 task_id: &str,
420 head: Option<&str>,
421 causality: Option<&Causality>,
422) -> Option<Envelope> {
423 let origin = origin?;
424 // A turn that left no result line still ends its task, and the sender still
425 // gets its answer: an empty body travels as `text: Some("")`, which the
426 // validator accepts, where an absent body would drop the receipt and leave
427 // the origin waiting on a task this role has already retired.
428 let body = Body::text(head.unwrap_or_default());
429 let mut causality = causality.cloned().unwrap_or_default();
430 causality.task = task_id.to_string();
431 causality.parent_task = None;
432 causality.reply_to = None;
433 // A receipt is written here, so it carries no redelivery count of its own.
434 causality.attempt = 0;
435 // `new_envelope` validates every protocol rule on the way out, so a receipt
436 // that cannot be addressed to its sender is the only one that goes unsent.
437 new_envelope(
438 MsgKind::Completion,
439 Principal::role(role),
440 origin,
441 body,
442 Some(causality),
443 )
444 .ok()
445}
446
447impl DispatchState {
448 /// Take one plugin `handoff` frame and answer what the plugin is told.
449 ///
450 /// The frame names the task the session is handing on and the role it goes
451 /// to. The child is minted here, through the builder the report-driven path
452 /// uses, so the family id and the family's figures ride along and the depth
453 /// grows by one hop. The envelope leaves on the queue the plugin `send` op
454 /// writes to.
455 ///
456 /// The answer names the child:
457 /// `{"task_id": "<uuid>", "hop": 3, "queued": true, "op_id": "<uuid>"}`
458 /// (`onlyne_proto::HandoffArgs`).
459 ///
460 /// A frame is answered only for the connection serving the task it names.
461 /// An unknown task and a foreign connection earn the same code and the same
462 /// field, and their messages say which of the two refused the frame.
463 pub fn plugin_handoff(&self, io: &AdapterIo, args: HandoffArgs) -> ResBody {
464 let (role, parent) = {
465 let inner = self.inner.lock();
466 let found = slot_key_serving_task(&inner, &args.task_id).and_then(|key| {
467 inner
468 .sessions
469 .get(&key)
470 .map(|slot| (key, slot.causality.clone()))
471 });
472 let Some((key, parent)) = found else {
473 return ResBody::err(
474 ErrorCode::Invalid,
475 format!("no session serves task {}", args.task_id),
476 Some("task_id".into()),
477 );
478 };
479 if !serves_session(&inner, &key, io) {
480 return ResBody::err(
481 ErrorCode::Invalid,
482 format!("this connection does not serve task {}", args.task_id),
483 Some("task_id".into()),
484 );
485 }
486 (inner.role.clone(), parent)
487 };
488 let (envelope, child) =
489 match handoff::relay(&role, &parent, &args.to, &args.text, args.image) {
490 Ok(built) => built,
491 Err(message) => return ResBody::err(ErrorCode::Invalid, message, None),
492 };
493 let queued = match self.plugin_send(io, &envelope) {
494 Ok(queued) => queued,
495 Err(error) => return ResBody::err(ErrorCode::Internal, error.to_string(), None),
496 };
497 // The queue path answers with the frame's `op_id`: a connection this
498 // client holds read-only serves no session, and the check above refused
499 // that connection before this line.
500 ResBody::ok(serde_json::json!({
501 "task_id": child.task,
502 "hop": child.hop,
503 "queued": true,
504 "op_id": queued["op_id"],
505 }))
506 }
507}
508
509#[cfg(test)]
510mod tests;