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(¬e) {
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, ¬e.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, ¤t);
359 let Some((applied, fields)) = crate::session::slice::apply_if_changed(¤t, 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;