Skip to main content

onlyne_client/runtime/runloop/
sessions.rs

1use super::config::{OUTCOME_POLL_MS, RunState};
2use super::run::settle_control;
3use crate::session::accept::AcceptPath;
4use crate::session::dispatch::{self, ClientLink};
5use anyhow::{Result, anyhow};
6use onlyne_proto::{AckArgs, ClientOp, Delivery, QueryRolesArgs, RoleInfo};
7use onlyne_session::SessionOutcome;
8use std::sync::atomic::Ordering;
9use std::time::{Duration, Instant};
10use tokio::time::sleep;
11
12/// Drain terminal facts emitted by a backend that owns its agent.
13///
14/// The backend queue is synchronous and destructive. Each fact is moved out
15/// before this task awaits the ordinary settlement path, so neither its queue
16/// lock nor the dispatch lock can survive into session teardown.
17pub(super) async fn outcome_loop(state: RunState) -> Result<()> {
18    let Some(feed) = state.dispatch.outcome_feed() else {
19        return std::future::pending::<Result<()>>().await;
20    };
21    loop {
22        while let Some(outcome) = feed.try_recv() {
23            settle_session_outcome(&state, outcome).await?;
24        }
25        sleep(Duration::from_millis(OUTCOME_POLL_MS)).await;
26    }
27}
28
29/// Feed one self-driven ending through the same fault and settlement paths an
30/// adapter report uses, and hand on whatever the ending's report asked for.
31pub(super) async fn settle_session_outcome(
32    state: &RunState,
33    outcome: SessionOutcome,
34) -> Result<()> {
35    let SessionOutcome {
36        task_id,
37        outcome,
38        head,
39        head_kind,
40        note,
41        refusals,
42        handoffs,
43    } = outcome;
44    // A self-driven backend reports in the task's own vocabulary, and the
45    // settlement travels in the wire's. `pending` is the absence of a verdict,
46    // which is nothing this loop can settle: the backend owes an ending.
47    let terminal = dispatch::task_outcome_of(outcome).ok_or_else(|| {
48        anyhow!("self-driven backend reported a non-terminal outcome for task {task_id}")
49    })?;
50    if let Some(reason) = refusals.as_deref() {
51        onlyne_session::record_fault(&state.store, &task_id, "permission", "acp", reason)?;
52    }
53    if outcome == onlyne_session::TaskState::Failed
54        && let Some(reason) = note.as_deref()
55    {
56        onlyne_session::record_fault(&state.store, &task_id, "acp", "acp", reason)?;
57    }
58    dispatch::on_out(
59        &state.dispatch,
60        &task_id,
61        terminal,
62        head,
63        head_kind.as_deref(),
64        &handoffs,
65        // The ending came from this client's own backend, which watched the agent
66        // it is reporting: the never-ran guard belongs to the plugin's door, where
67        // the claimant and the claim are the same party.
68        dispatch::SettleAuthority::ClientOwned,
69    )
70    .await
71}
72
73/// One delivery becomes a session, or an immediate refusal ack.
74///
75/// A plugin mounted before any work existed is parked in the dispatcher, so the
76/// session staged here hands straight over to it. That is the order an
77/// always-running agent takes: it attaches first and receives its assignment
78/// when a task arrives (plan §6 line 285).
79pub(super) async fn accept_delivery(state: &RunState, delivery: &Delivery) {
80    // A control command acts on the work the role already holds, so it answers
81    // before the capacity gate and before the `accept_new` gate: a role at
82    // `max_sessions` is exactly the role whose operator wants to free.
83    if delivery.envelope.kind == onlyne_proto::MsgKind::Control {
84        settle_control(state, delivery).await;
85        return;
86    }
87    // A task this role already finished is not new work. The server re-offers an
88    // unacknowledged row after a link flap or an operator repair, and a
89    // completion that was in flight when the link dropped can land after the
90    // requeue, so this row's task may already be `Done` here. Dispatching it
91    // again would stage its payload on whichever session is idle — one chain's
92    // task running inside another conversation, with a second answer aimed at
93    // the ledger row the first one settled. Acknowledge the row and run nothing.
94    if delivery.envelope.kind == onlyne_proto::MsgKind::Task
95        && let Some(task_id) = delivery.envelope.task_id()
96        && state.dispatch.task_completed_here(task_id)
97    {
98        tracing::warn!(
99            msg_id = %delivery.msg_id,
100            task = %task_id,
101            "redelivery of a finished task settled without running it"
102        );
103        state.dispatch.push_settled(AckArgs {
104            msg_id: delivery.msg_id.clone(),
105            op_id: None,
106            accepted: true,
107            reason: Some("task already completed by this role".to_string()),
108        });
109        return;
110    }
111    if !state.dispatch.has_capacity() {
112        // The row stays in flight on the server, which offers it again when a
113        // session frees (plan §5 `max_sessions`).
114        tracing::debug!(msg_id = %delivery.msg_id, "delivery waits for a free session");
115        return;
116    }
117    // A `Completion` is a terminal receipt, so it settles the row it names and
118    // starts no session (plan §3 line 152's `Completion`).
119    if delivery.envelope.kind == onlyne_proto::MsgKind::Completion {
120        state.dispatch.push_settled(AckArgs {
121            msg_id: delivery.msg_id.clone(),
122            op_id: None,
123            accepted: true,
124            reason: None,
125        });
126        return;
127    }
128    // A `Note` names no task, so it starts no session: it is the wake-up a role
129    // sends to a running agent (§3), and an agent that does not exist yet has
130    // nothing to wake. §5's `note_queue` keeps one out of the queue when its
131    // role is offline, and this is the matching half on the receiving side.
132    if delivery.envelope.kind == onlyne_proto::MsgKind::Note {
133        let injected = state.dispatch.inject_note(&delivery.envelope).await;
134        state.dispatch.push_settled(AckArgs {
135            msg_id: delivery.msg_id.clone(),
136            op_id: None,
137            accepted: injected,
138            reason: (!injected).then(|| "note has no live session to wake".to_string()),
139        });
140        return;
141    }
142    let accept_new = state.accept_new.load(Ordering::SeqCst);
143    let path = AcceptPath::new(state.dispatch.clone(), state.dispatch.role_prose());
144    match path.accept_new(delivery, accept_new) {
145        Ok(Some(session)) => {
146            if let Some(task_id) = delivery.envelope.task_id() {
147                state.dispatch.attach_msg_id(task_id, &delivery.msg_id);
148            }
149            // A plugin attached to this session takes the payload now, or the
150            // one parked for the role does; a session whose own plugin is
151            // still starting waits for its mount to hand it over.
152            if let Err(error) = state.dispatch.hand_staged(&session.task_id).await {
153                tracing::warn!(error = %error, task = %session.task_id, "staged hand-off refused");
154            }
155        }
156        // The gate is the connection's own (`watch_readiness` shuts it when the
157        // link leaves `Ready` and opens it when the redial lands), so a delivery
158        // the pull already had in hand when the link flapped arrives here with the
159        // gate shut. That answer is not this client's to give: a refusal settles
160        // the row `rejected`, which is terminal, and the row the teardown's
161        // requeue would have brought back is destroyed instead. The row stays in
162        // flight — unanswered is not a decision — and the next `hello` that does
163        // not claim it is what puts it back on the queue.
164        Ok(None) => tracing::debug!(
165            msg_id = %delivery.msg_id,
166            "the link is not taking work; the delivery stays in flight"
167        ),
168        Err(error) => {
169            tracing::warn!(error = %error, msg_id = %delivery.msg_id, "delivery refused");
170            state.dispatch.push_settled(AckArgs {
171                msg_id: delivery.msg_id.clone(),
172                op_id: None,
173                accepted: false,
174                reason: Some(error.to_string()),
175            });
176        }
177    }
178}
179
180/// Report running sessions whose Applied clock has exceeded the stall
181/// threshold. The fault is observation-only; the ledger row stays as stored.
182pub(super) async fn scan_stalls(state: &RunState) {
183    if state.stall_report_secs == 0 {
184        return;
185    }
186    let due = state
187        .dispatch
188        .stall_due(Instant::now(), state.stall_report_secs);
189    for task_id in due {
190        let Some(report) = state.dispatch.stall_report(&task_id) else {
191            continue;
192        };
193        match dispatch::send_frame(&state.dispatch, ClientOp::Report(report)).await {
194            Ok(()) => state.dispatch.mark_stalled(&task_id),
195            Err(error) => {
196                tracing::warn!(error = %error, task = %task_id, "stall fault was not sent")
197            }
198        }
199    }
200}
201
202/// Reclaim the resources of sessions this client has already put past their work,
203/// and publish each one's exit.
204///
205/// The reclaim runs on every readiness tick, ahead of the reconnect window, so a
206/// completed session whose plugin left without a goodbye is this sweep's to end:
207/// its resource is still open, its stored lifecycle already reads `Exited`, and
208/// the sweep below waits out a grace the completion itself did not ask for. What
209/// the reclaim writes is the agent's exit and the resource close, and the server
210/// mirrors only what this client reports — so each session it retired travels the
211/// report an ordinary ending travels, once the lock has been given back and the
212/// stored row is final. Without it the mirror keeps the reading the settle
213/// published, `exited` beside an agent still `running` and a resource still
214/// `attached`, which is what a peer's census of completed sessions found.
215///
216/// A published exit runs the server's `release_exited_delivery` for that task, and
217/// the task's own delivery row was answered when the completion settled it, so
218/// there is no in-flight row of that task for the release to hand back. The
219/// publish is also after the reclaim rather than around it because the row the
220/// server mirrors is the row the reclaim writes.
221pub(super) async fn scan_reclaimed_resources(state: &RunState) {
222    for session_id in state.dispatch.reclaim_exited_resources() {
223        if let Err(error) = dispatch::sync_session(&state.dispatch, &session_id).await {
224            tracing::warn!(
225                session = %session_id,
226                error = %error,
227                "a reclaimed session's exit was not published"
228            );
229        }
230    }
231}
232
233/// Retire the sessions whose plugin connection dropped and did not come back
234/// within `[client] reconnect_grace_secs`, and publish each one's exit. The tick
235/// sweeps every session the window expired on, bound to a task or not: a plugin
236/// that never came back is an agent that is gone, whether or not its work was
237/// still owed.
238///
239/// The publish is the half of the ending the sweep cannot write: the retirement
240/// feeds the session's own tuple to `Exited` and files the verdict, and the server
241/// only learns either from what this client reports. Without it the mirrored row
242/// keeps reading `working` until the server's own observer records a
243/// `stale_working` or `heartbeat_missing` fault — a reader waits for a fault, which
244/// names the silence and moves no row, to hear what this client already knew. So
245/// each session that left travels the report an ordinary ending already travels,
246/// once the lock has been given back and the stored row is final. Only the durable
247/// queue refusing the frame reaches the log: a live send that gave up is
248/// `sync_session`'s own fallback to that queue, not a lost publish.
249pub(super) async fn scan_reconnect_grace(state: &RunState) {
250    if state.reconnect_grace_secs == 0 {
251        return;
252    }
253    let retired = state
254        .dispatch
255        .retire_dropped_ghosts(Instant::now(), state.reconnect_grace_secs);
256    if retired.is_empty() {
257        return;
258    }
259    // One line per retirement, and it names the arm: two readings close this window — the
260    // connection ended and stayed away, or the connection held while nothing the client
261    // accepted arrived — and a single count with one threshold made an operator reading the
262    // log guess. The ages are the sweep's own inputs, so the line settles whether the agent
263    // left or merely stopped reporting.
264    for retired in retired {
265        tracing::info!(
266            session = %retired.session_id,
267            arm = retired.arm.word(),
268            quiet_secs = retired.quiet_secs,
269            away_secs = retired.away_secs,
270            silence_window_secs =
271                dispatch::HEARTBEAT_INTERVAL.as_secs() * dispatch::HEARTBEAT_SILENCE_MARGIN as u64,
272            grace_secs = state.reconnect_grace_secs,
273            "session retired past its window"
274        );
275        if let Err(error) = dispatch::sync_session(&state.dispatch, &retired.session_id).await {
276            tracing::warn!(
277                session = %retired.session_id,
278                error = %error,
279                "a retired session's exit was not published"
280            );
281        }
282    }
283}
284
285/// Settle the work an operator's word left open unanswered, and publish each
286/// one's exit.
287///
288/// `recycle` and `cancel` ask a session's plugin for its own ending, and the
289/// completion that answers the command is a frame of the plugin's. A plugin that
290/// never sends one — it left with the command's frame, or implements no
291/// `recycle` at all — leaves the task open, the mirrored row reading `working`,
292/// and the delivery row this client was handed in flight, and nothing in this
293/// process is left to answer any of the three: the close the command ran is what
294/// ended the session's own row already, so the sweep above finds no window left
295/// open on it and no work of it to settle.
296///
297/// What answers the word is the client's own record of it, held past
298/// `dispatch::CONTROL_SETTLE_BOUND`. The note is read under the dispatch lock and
299/// spent one at a time behind it, so a completion that arrives in between
300/// settles the task through the report it came on and this sweep writes nothing;
301/// the verdict, the refusal of the delivery row and the report behind it are
302/// [`DispatchState::settle_unanswered_control`]'s. The publish is this sweep's,
303/// for the reason the retirement above publishes: the server mirrors what this
304/// client reports, and that is what moves the row an operator is reading.
305///
306/// [`DispatchState::settle_unanswered_control`]:
307///     crate::session::dispatch::DispatchState::settle_unanswered_control
308pub(super) async fn scan_control_settles(state: &RunState) {
309    let now = Instant::now();
310    for note in state.dispatch.control_settles_due(now) {
311        // The note is spent through the one door that spends it: a completion
312        // that answered this word between the reading above and this call takes
313        // the note first, and its verdict is the one that stands.
314        if !state.dispatch.settle_unanswered_control(&note) {
315            continue;
316        }
317        tracing::info!(
318            task = %note.task_id,
319            outcome = ?note.word.outcome(),
320            waited_secs = now.saturating_duration_since(note.noted_at).as_secs(),
321            "a task was settled on an operator's word no plugin answered"
322        );
323        if let Err(error) = dispatch::sync_session(&state.dispatch, &note.task_id).await {
324            tracing::warn!(
325                task = %note.task_id,
326                error = %error,
327                "a settled task's exit was not published"
328            );
329        }
330    }
331}
332
333pub(super) async fn refresh_role_slice(link: &ClientLink, state: &RunState) -> Result<()> {
334    let role = state.dispatch.role();
335    let reply = link
336        .request(ClientOp::QueryRoles(QueryRolesArgs { role: Some(role) }))
337        .await?;
338    if !reply.ok {
339        tracing::warn!(error = ?reply.error, "role slice refresh query refused");
340        return Ok(());
341    }
342    let rows: Vec<RoleInfo> = reply
343        .data
344        .as_ref()
345        .and_then(|value| value.get("roles"))
346        .cloned()
347        .map(serde_json::from_value)
348        .transpose()?
349        .unwrap_or_default();
350    if let Some(info) = rows.first() {
351        apply_role_info(state, info);
352    }
353    Ok(())
354}
355
356pub(super) fn apply_role_info(state: &RunState, info: &RoleInfo) -> Vec<&'static str> {
357    let current = state.dispatch.role_slice();
358    let next = crate::session::slice::RoleSlice::from_role_info(info, &current);
359    let Some((applied, fields)) = crate::session::slice::apply_if_changed(&current, next) else {
360        return Vec::new();
361    };
362    state.dispatch.reconfigure(applied);
363    fields
364}
365
366/// The local accept path for the current role slice.
367pub fn accept_path(state: &RunState) -> Result<AcceptPath> {
368    Ok(AcceptPath::new(
369        state.dispatch.clone(),
370        state.dispatch.role_prose(),
371    ))
372}
373
374#[cfg(test)]
375mod tests;
376
377#[cfg(test)]
378mod scan_tests;