Skip to main content

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;