Skip to main content

onlyne_client/session/dispatch/
retire.rs

1use super::*;
2
3use super::outbound::store_ack;
4use super::projection::stored_task_state;
5use super::state::{
6    DispatchInner, DispatchState, has_attached_transport, session_exited, slot_key_serving_task,
7};
8use super::transport::{held_read_only, names_session};
9
10/// Reason a session that stopped answering is closed with.
11///
12/// The wire word the ledger and `onlyne sessions` read for a death this client
13/// judged: the reconnect sweep stamps it on the refusal ack that buries the
14/// session's delivery, and an operator's own
15/// `repair fail --reason session_dead` writes the same word.
16pub const SESSION_DEAD: &str = "session_dead";
17
18/// Retire one session. The stored tuple decides whether a live resource
19/// remains to close, and the caller's reason reaches the backend unchanged, so an
20/// operator cancel stops reporting itself as a completion.
21pub fn on_recycled(
22    state: &DispatchState,
23    task_id: &str,
24    reason: onlyne_session::CloseReason,
25) -> Result<()> {
26    let mut inner = state.inner.lock();
27    release_locked(&mut inner, task_id, Some(reason))
28}
29
30/// Why the task of one session ended, as the retirement reason the task table
31/// records.
32///
33/// The task's own row is the only place a verdict lives: the session tuple says
34/// nothing about how its work ended, and an open task — `pending`, or no row at
35/// all, which is the same reading — has no reason to retire anything.
36pub(super) fn stored_close_reason(
37    inner: &DispatchInner,
38    task_id: &str,
39) -> Option<onlyne_session::CloseReason> {
40    match stored_task_state(inner, task_id) {
41        TaskState::Pending => None,
42        TaskState::Done => Some(onlyne_session::CloseReason::Completed),
43        TaskState::Failed => Some(onlyne_session::CloseReason::Fault),
44        TaskState::Cancelled => Some(onlyne_session::CloseReason::Cancelled),
45    }
46}
47
48/// The reason one session the reconnect grace retires is closed with, read from
49/// what this client holds rather than from a settle that never came.
50///
51/// The id is the session's own (`slot.session.task_id`), never the task binding
52/// beside it: the sweep is what feeds that session's row and closes the resource
53/// its agent was holding, and the two ids part company exactly where a slot's
54/// binding is not the session it was born for. A session still owing work closes
55/// as that task's own record reads: a `done` task is a `Completed`, a
56/// `cancelled` one is a `Cancelled`, and a `failed` task — like one that never
57/// settled at all — is a `Fault`, because the work was still owed when the agent
58/// left.
59fn grace_close_reason(inner: &DispatchInner, task_id: &str) -> onlyne_session::CloseReason {
60    match stored_task_state(inner, task_id) {
61        TaskState::Done => onlyne_session::CloseReason::Completed,
62        TaskState::Pending | TaskState::Failed => onlyne_session::CloseReason::Fault,
63        TaskState::Cancelled => onlyne_session::CloseReason::Cancelled,
64    }
65}
66
67/// Whether one slot is due for the reconnect grace to take it.
68///
69/// The window as it always was: the connection that would have sent this
70/// session's next heartbeat has ended, and the window runs from the moment it
71/// left. A slot a live connection serves is not this arm's to end, because an
72/// attached transport is the one thing that says the agent is still reachable.
73fn dropped_past_window(
74    inner: &DispatchInner,
75    key: &str,
76    slot: &SessionSlot,
77    now: Instant,
78    window: Duration,
79) -> bool {
80    slot.dropped_at.is_some_and(|dropped| {
81        !has_attached_transport(inner, key, slot)
82            && now
83                .checked_duration_since(dropped)
84                .is_some_and(|away| away >= window)
85    })
86}
87
88/// Whether one session's own agent has gone quiet on a socket that is still up.
89///
90/// The window's other door, and it exists precisely because an attached
91/// transport — the one thing the arm above trusts — can lie. A socket that is
92/// still up proves the connection survived; it says nothing about the agent
93/// behind it, and a plugin whose event loop is blocked holds its socket and
94/// stops beating. Nothing the client reads before this could see that: no socket
95/// ends, so no drop clock ever starts, and the session keeps its slot, its
96/// projected row and its host resource for as long as the client runs.
97///
98/// So the reading is taken off the frames themselves. A session whose task is
99/// still bound and unsettled and whose last accepted frame is older than the
100/// protocol's heartbeat interval by [`HEARTBEAT_SILENCE_MARGIN`] has no agent
101/// behind its socket.
102///
103/// Why no task bound is excluded: the plugin stops its heartbeat loop with the
104/// last task it was given, so a task-free session that has gone quiet is an
105/// agent waiting for work by design — the ordinary shape between deliveries.
106/// Sweeping it would retire the very connection the next payload is staged onto.
107/// The unsettled check beside the binding says the same thing about work that
108/// already landed: a settled task has nothing left for its session to answer,
109/// and `release_locked` has already given the binding back.
110///
111/// Why the drop clock's arm never reads this stamp: the two are mutually
112/// exclusive by construction. A re-mount clears `dropped_at`, so a session that
113/// came back is judged by its beat alone, and a session whose connection went
114/// away is judged by the clock alone and never by a stamp its agent can no
115/// longer refresh.
116fn silent_past_window(inner: &DispatchInner, key: &str, slot: &SessionSlot, now: Instant) -> bool {
117    if slot.dropped_at.is_some() || !has_attached_transport(inner, key, slot) {
118        return false;
119    }
120    let Some(task_id) = slot.task_id.as_deref() else {
121        return false;
122    };
123    if stored_task_state(inner, task_id) != TaskState::Pending {
124        return false;
125    }
126    let quiet = HEARTBEAT_INTERVAL * HEARTBEAT_SILENCE_MARGIN;
127    slot.last_beat.is_some_and(|beat| {
128        now.checked_duration_since(beat)
129            .is_some_and(|away| away >= quiet)
130    })
131}
132
133/// Stop holding a session's connection as its transport.
134///
135/// The act a socket ending performs, run here on the client's own verdict
136/// instead: a session judged dead by its silence still holds its socket, and the
137/// connection is not going to end on its own while the agent behind it is
138/// blocked. The binding goes now, so the retirement below runs as the ordinary
139/// one, and a frame from that connection afterwards is refused the way every
140/// frame from a connection no session answers for is — it is served no state,
141/// which is the same door a stale reporter already comes to.
142fn unbind_transports(inner: &mut DispatchInner, key: &str, slot: &SessionSlot) {
143    inner
144        .transports
145        .retain(|served, _| !names_session(key, slot, served));
146}
147
148/// Retire one task-free session after its transport set becomes empty.
149///
150/// The idle slot releases its backend resource because the agent able to run
151/// another task in it has left. An attached transport keeps the resource because
152/// that agent remains reachable. The dispatch lock serializes the final transport check, reference
153/// refresh, lifecycle projection, backend close, and slot removal with adapter
154/// binding.
155pub(super) fn retire_idle_locked(
156    inner: &mut DispatchInner,
157    key: &str,
158    reason: onlyne_session::CloseReason,
159) -> bool {
160    let Some(slot) = inner.sessions.get(key) else {
161        return false;
162    };
163    if slot.task_id.is_some() || has_attached_transport(inner, key, slot) {
164        return false;
165    }
166
167    let original = slot.session.clone();
168    let task_id = original.task_id.clone();
169    let resource = inner
170        .store
171        .get_session(&task_id)
172        .ok()
173        .flatten()
174        .map(|row| row.resource_state)
175        .unwrap_or_else(|| "detached".to_string());
176    if resource != "detached" && resource != "closed" {
177        let session = match inner.backend.attach(&original) {
178            Ok(refreshed) => {
179                if refreshed != original {
180                    inner.bridge.track_live(refreshed.clone());
181                    if let Some(slot) = inner.sessions.get_mut(key) {
182                        slot.session = refreshed.clone();
183                    }
184                }
185                refreshed
186            }
187            Err(_) => original,
188        };
189        tracing::info!(
190            task = %task_id,
191            backend = %session.backend,
192            resource = %session.backend_ref,
193            ?reason,
194            "retiring idle session resource"
195        );
196        if let Err(error) = feed_resource_closed(&inner.bridge, &inner.store, &task_id) {
197            tracing::warn!(
198                task = %task_id,
199                backend = %session.backend,
200                resource = %session.backend_ref,
201                error = %error,
202                "session resource close projection failed"
203            );
204        }
205        if let Err(error) = inner.backend.close(&session, reason, false) {
206            tracing::warn!(
207                task = %task_id,
208                backend = %session.backend,
209                resource = %session.backend_ref,
210                error = %error,
211                "session resource retirement failed"
212            );
213        }
214    }
215    if reason == onlyne_session::CloseReason::Completed {
216        if let Err(error) = feed_agent_gone(&inner.bridge, &inner.store, &task_id) {
217            tracing::warn!(
218                task = %task_id,
219                error = %error,
220                "agent-gone projection failed for a completed session"
221            );
222        }
223    }
224    inner.bridge.untrack_live(&task_id);
225    inner.sessions.remove(key);
226    true
227}
228
229/// Give one session's task slot back. Settled tasks enter idle retirement, and
230/// explicit reasons drive the control-close path.
231pub(super) fn release_locked(
232    inner: &mut DispatchInner,
233    task_id: &str,
234    reason: Option<onlyne_session::CloseReason>,
235) -> Result<()> {
236    let resource = inner
237        .store
238        .get_session(task_id)?
239        .map(|row| row.resource_state)
240        .unwrap_or_else(|| "detached".to_string());
241    if let Some((key, slot)) = slot_key_serving_task(inner, task_id)
242        .and_then(|key| inner.sessions.get(&key).map(|slot| (key, slot.clone())))
243    {
244        if let Some(reason) = reason {
245            if resource != "detached" && resource != "closed" {
246                feed_resource_closed(&inner.bridge, &inner.store, task_id)?;
247                inner.backend.close(&slot.session, reason, false)?;
248            }
249            // The agent goes with the resource: this path closes a session whose work an
250            // operator ended or whose backend faulted, and the slot below leaves the map in
251            // the same breath. Without this feed the tuple keeps the agent phase its last
252            // beat reported, and `project` answers `working` for a `cancelled` or `failed`
253            // task whenever the agent is not `Gone` — so the mirrored row read `working` for
254            // a session the client had already closed, which is what `onlyne sessions` and
255            // the board showed beside a ledger row that had settled. The reconnect sweep
256            // feeds the same event for the same ending.
257            if let Err(error) = feed_agent_gone(&inner.bridge, &inner.store, task_id) {
258                tracing::warn!(
259                    task = %task_id,
260                    error = %error,
261                    "agent-gone projection failed for a closed session"
262                );
263            }
264            inner.bridge.untrack_live(task_id);
265            // The delivery row this session is still holding is answered here,
266            // while the slot that holds its handle is still in the map. The close
267            // takes the slot, and the handle goes with it: a row this client
268            // never answered is a row the server still reads as owed, so the
269            // pull that would have taken it passes and the release of a session
270            // the server judges gone hands it back to the queue — which
271            // dispatches the task a second time and runs it again, as the live
272            // run showed for a task whose work the close had already ended.
273            //
274            // The handle is taken, so the row is answered once: the control
275            // settle watchdog and the reconnect sweep refuse the same handle
276            // the same way, and whichever of the three runs first spends it and
277            // the others find nothing left to answer.
278            let held = inner
279                .sessions
280                .get_mut(&key)
281                .and_then(|slot| slot.msg_id.take());
282            if let Some(msg_id) = held {
283                store_ack(
284                    inner,
285                    AckArgs {
286                        msg_id,
287                        op_id: None,
288                        accepted: false,
289                        reason: Some(close_refusal(reason).to_string()),
290                    },
291                );
292            }
293            inner.sessions.remove(&key);
294        } else {
295            if let Some(session) = inner.sessions.get_mut(&key) {
296                session.task_id = None;
297                session.ready = false;
298            }
299            retire_idle_locked(inner, &key, onlyne_session::CloseReason::Completed);
300        }
301    }
302    inner.stall.forget(task_id);
303    if reason.is_some() && resource == "detached" {
304        inner
305            .store
306            .note_alert(format!("session recycled {task_id}"));
307    }
308    Ok(())
309}
310
311/// The operator's word a control close stands for, as the refusal that answers
312/// the row its session was still holding.
313///
314/// The words are the ones the settle fallback already writes for the same
315/// commands ([`ControlWord::refusal`]), so a row reads the same thing whichever
316/// door refused it, and each names the command that was given rather than the
317/// verdict that command left behind.
318fn close_refusal(reason: onlyne_session::CloseReason) -> &'static str {
319    match reason {
320        // The close an operator's `cancel` runs: the `ControlOp::Cancel` arm of
321        // `on_control`, which is also how the server asks for `repair close` and
322        // `repair fail` to reach this client.
323        onlyne_session::CloseReason::Cancelled => ControlWord::Cancel.refusal(),
324        // The close an operator's `recycle` runs: the `ControlOp::Recycle` arm
325        // of `on_control`.
326        onlyne_session::CloseReason::Operator => ControlWord::Recycle.refusal(),
327        // No control command reaches this branch with another reason: a
328        // `completed`, `fault` or `replaced` close retires an idle slot, and a
329        // `shutdown` close runs `close_all`, neither of which comes through
330        // here. The word is the server's own for a close that named no command
331        // — `repair close` without a `--reason` writes it on the task's row.
332        _ => "operator close",
333    }
334}
335
336/// Close every live session's resource with `reason` and forget the slots.
337///
338/// This is the shutdown path: a stopped client must not leave resources behind
339/// that only it can address, and each backend's own record of the resource —
340/// the Orca tab map included — ends with the session. `budget` bounds the whole
341/// sweep, because an operator's SIGTERM must not turn into a hang while a slow
342/// backend CLI exits; whatever the budget cuts off is reported and dropped
343/// anyway.
344pub fn close_all(state: &DispatchState, reason: onlyne_session::CloseReason, budget: Duration) {
345    let started = Instant::now();
346    let mut inner = state.inner.lock();
347    let sessions: Vec<(String, SessionRef)> = inner
348        .sessions
349        .iter()
350        .map(|(key, slot)| (key.clone(), slot.session.clone()))
351        .collect();
352    for (key, session) in sessions {
353        if started.elapsed() > budget {
354            tracing::warn!(
355                task = %session.task_id,
356                "shutdown close budget reached; the resource is left behind"
357            );
358        } else if let Err(error) = inner.backend.close(&session, reason, false) {
359            tracing::warn!(
360                task = %session.task_id,
361                error = %error,
362                "session close failed during shutdown"
363            );
364        }
365        inner.bridge.untrack_live(&session.task_id);
366        inner.sessions.remove(&key);
367    }
368}
369
370/// Which way a session's window closed.
371///
372/// Two arms reach the same verdict through different facts, and an operator reading one
373/// aggregate log line cannot tell them apart: one means the connection ended and the agent
374/// stayed away, the other means the connection is still up while nothing the client accepts
375/// arrives over it. The words exist so the log can name which reading retired a session.
376#[derive(Clone, Copy, Debug, PartialEq, Eq)]
377pub enum RetirementArm {
378    /// The plugin connection ended and `[client] reconnect_grace_secs` expired.
379    Dropped,
380    /// The connection stayed up while the session went quiet past the heartbeat window.
381    Silent,
382}
383
384impl RetirementArm {
385    pub fn word(self) -> &'static str {
386        match self {
387            Self::Dropped => "reconnect_grace",
388            Self::Silent => "heartbeat_silence",
389        }
390    }
391}
392
393/// One session the sweep retired, with what it read on the way.
394pub struct Retired {
395    /// The retired session's own id, which a client-held session shares with its task.
396    pub session_id: String,
397    /// The arm that decided it.
398    pub arm: RetirementArm,
399    /// Seconds since this session's last accepted frame.
400    pub quiet_secs: u64,
401    /// Seconds since its connection ended, when one did.
402    pub away_secs: Option<u64>,
403}
404
405/// How long a session has gone without a frame this client accepted.
406fn quiet_secs(slot: &SessionSlot, now: Instant) -> u64 {
407    slot.last_beat
408        .map(|beat| now.saturating_duration_since(beat).as_secs())
409        .unwrap_or(u64::MAX)
410}
411
412/// How long a session's connection has been gone, for a session still waiting on one.
413fn away_secs(slot: &SessionSlot, now: Instant) -> Option<u64> {
414    slot.dropped_at
415        .map(|left| now.saturating_duration_since(left).as_secs())
416}
417
418impl DispatchState {
419    /// Retire tracked resources whose stored lifecycle has reached `Exited`.
420    ///
421    /// The periodic readiness tick calls this after completed work becomes an
422    /// idle slot. Task-free sessions with an attached transport stay bound to
423    /// their host resource, and task-free sessions whose agent has left release it.
424    ///
425    /// The session ids come back because the retirement wrote each one of those
426    /// rows and the server mirrors only what this client reports: the resource
427    /// close and, for a completed session, the agent's exit both moved the row
428    /// this tick found, and a publish cannot run under this lock. The caller is
429    /// handed what to publish, the same answer [`DispatchState::retire_dropped_ghosts`]
430    /// gives the sweep above.
431    pub fn reclaim_exited_resources(&self) -> Vec<String> {
432        let mut inner = self.inner.lock();
433        let candidates: Vec<(String, onlyne_session::CloseReason)> = inner
434            .sessions
435            .iter()
436            .filter(|(key, slot)| {
437                slot.task_id.is_none()
438                    && session_exited(&inner, &slot.session.task_id)
439                    && !has_attached_transport(&inner, key, slot)
440            })
441            .filter_map(|(key, slot)| {
442                stored_close_reason(&inner, &slot.session.task_id)
443                    .map(|reason| (key.clone(), reason))
444            })
445            .collect();
446        let mut retired: Vec<String> = Vec::new();
447        for (key, reason) in candidates {
448            // The id that travels is the session's own, the one whose row the
449            // retirement below is about to write.
450            let Some(task_id) = inner
451                .sessions
452                .get(&key)
453                .map(|slot| slot.session.task_id.clone())
454            else {
455                continue;
456            };
457            if retire_idle_locked(&mut inner, &key, reason) {
458                retired.push(task_id);
459            }
460        }
461        retired
462    }
463
464    /// Retire the sessions whose plugin connection dropped and never came back, or
465    /// whose connection stayed up while they went quiet, and answer which ones left
466    /// and why.
467    ///
468    /// A connection that ends without a `detach` frame leaves its session tracked
469    /// so an agent that restarts inside `[client] reconnect_grace_secs` finds the
470    /// resource it was using. That promise has to expire: a process that is
471    /// simply gone would otherwise hold a slot, a projected `idle` row, and a live
472    /// host resource forever, and on a role with `max_sessions = 1` it stops every
473    /// later delivery. The window answers for the agent itself, so a session still
474    /// bound to a task goes with it: the plugin connection that would have
475    /// reported the ending is the one that dropped. The agent-gone feed is what
476    /// says the process left — the session's own tuple reaches `Exited` through
477    /// `AgentState::Gone` rather than through a task result — and the reason the
478    /// backend is handed is the one `grace_close_reason` reads off what the slot
479    /// still owes.
480    ///
481    /// What the slot owed is settled too: the task a bound session was serving
482    /// ends `failed` here, because the agent that would have reported its ending
483    /// is the one that left. A task with no verdict stays open for the server to
484    /// re-offer and for `open_tasks` to keep reading, and no later caller exists
485    /// to write one.
486    ///
487    /// A slot this client holds read-only is not this sweep's to end, agent gone
488    /// or not: the session id it would feed is the task id, so the ghost's death
489    /// would take the live session's mirror and its delivery row down with it.
490    /// That retirement belongs to `retire_revived`, which runs when the
491    /// completion that answers the held connection merges.
492    ///
493    /// The window has a second way to open, and it is the one a socket cannot
494    /// report: a plugin whose event loop is blocked keeps its connection and
495    /// stops beating, so no socket ends and no clock this sweep could read
496    /// before moved. What such a session leaves behind is a stamp going stale
497    /// while its task stays bound and unsettled, and that is the reading this
498    /// sweep takes now. It is the same window and the same verdict — one clock,
499    /// one retirement, no second threshold beside `[client]
500    /// reconnect_grace_secs` and no fault row of the kind `stall_report_secs`
501    /// records and leaves behind.
502    ///
503    /// The sessions' own ids come back rather than a count, because a retirement
504    /// still owes the server the session's own ending: it is the only writer left
505    /// for that task, and a mirror nobody tells keeps that session's last reading —
506    /// `working`, for one that had beaten — until the server's own observer records
507    /// a fault about it. The publish is
508    /// [`sync_session`](crate::session::dispatch::sync_session)'s, which is the
509    /// report an ordinary ending travels on, and it cannot run under this lock —
510    /// so the caller is handed what to publish instead of a second writer being
511    /// invented here.
512    pub fn retire_dropped_ghosts(&self, now: Instant, grace_secs: u64) -> Vec<Retired> {
513        if grace_secs == 0 {
514            return Vec::new();
515        }
516        let window = Duration::from_secs(grace_secs);
517        let mut inner = self.inner.lock();
518        // The arm is decided from the pair of readings here, while both still describe the
519        // slot: which fact closed the window is what the operator has to be able to tell
520        // apart once the retirement itself is a line in the log.
521        let due: Vec<(String, RetirementArm)> = inner
522            .sessions
523            .iter()
524            .filter_map(|(key, slot)| {
525                let arm = if dropped_past_window(&inner, key, slot, now, window) {
526                    RetirementArm::Dropped
527                } else if silent_past_window(&inner, key, slot, now) {
528                    RetirementArm::Silent
529                } else {
530                    return None;
531                };
532                Some((key.clone(), arm))
533            })
534            .collect();
535        let mut retired: Vec<Retired> = Vec::new();
536        for (key, arm) in due {
537            let Some(slot) = inner.sessions.get(&key).cloned() else {
538                continue;
539            };
540            // A slot this client holds read-only is not this sweep's to end, for
541            // the reason the doc above gives. The demotion alone does not decide
542            // it: the held connection that owns the slot can go without the task
543            // ever completing, and a slot nothing owns any more is what this
544            // window is for.
545            if held_read_only(&inner, &key, &slot) {
546                continue;
547            }
548            // A session judged dead on its silence is the one case where the
549            // connection is still there: the socket has not ended and will not
550            // while the agent behind it is blocked, so the client's own verdict
551            // has to take the binding the way the death of the socket would have.
552            // The retirement below refuses a slot an attached transport still
553            // serves, and that refusal is what this unbinding answers: the
554            // verdict has already been reached here, so the binding goes rather
555            // than the death of the socket that would normally take it.
556            if slot.dropped_at.is_none() {
557                unbind_transports(&mut inner, &key, &slot);
558            }
559            let task_id = slot.session.task_id.clone();
560            // Both the feed and the reason name the session's own task, so the id
561            // that travels is the one the row and the resource are keyed by.
562            let reason = grace_close_reason(&inner, &task_id);
563            // The work this session still owed ends here, and this sweep is the
564            // only writer left to say so: the plugin connection that would have
565            // reported the ending is the one that dropped. A task nobody answers
566            // stays `settled_at IS NULL` forever, so `open_tasks` keeps reading
567            // it and the server keeps re-offering a delivery no client can take.
568            // The binding is what the slot owed — a slot past its window with no
569            // task bound owes nothing — and `failed` is the verdict the close
570            // reason above already carries for it. A row an earlier verdict
571            // settled keeps that one: `settle_task` updates only where
572            // `settled_at IS NULL` and answers `false`.
573            //
574            // The write runs before the agent-gone feed and before the binding
575            // hand-back, both of which end this slot's turn through the sweep:
576            // the id is captured here, and the verdict is on disk before the
577            // only handle on it goes away.
578            if let Some(owed) = slot.task_id.clone() {
579                // A verdict written here answers the task for good, so a
580                // `control` command that is still waiting for its plugin's report
581                // has nothing left to authorise: the note goes with the verdict
582                // that outranks it.
583                inner.control_settles.retain(|noted| noted.task_id != owed);
584                if let Err(error) = inner.store.settle_task(&owed, TaskState::Failed) {
585                    tracing::warn!(
586                        task = %owed,
587                        error = %error,
588                        "the task of a retired ghost was not settled"
589                    );
590                }
591                // The delivery handle this session was holding is spent as a
592                // refusal that names the death, and it is the ledger half of the
593                // verdict above. A row left `in_flight` is handed to a pull no
594                // longer — `pull` passes by a row whose ticket is armed, and a
595                // role-level pull's ticket carries no session id for the release
596                // path to match — so nothing would answer for this task until the
597                // link dropped, and an operator reading `onlyne ledger` would see
598                // a session that has been buried as one still holding its
599                // delivery. The reason is this client's own word for a death
600                // (`SESSION_DEAD`), and a refusal is terminal: the work comes back
601                // through `repair retry`, not by itself.
602                let handle = inner
603                    .sessions
604                    .get_mut(&key)
605                    .and_then(|slot| slot.msg_id.take());
606                if let Some(msg_id) = handle {
607                    store_ack(
608                        &inner,
609                        AckArgs {
610                            msg_id,
611                            op_id: None,
612                            accepted: false,
613                            reason: Some(SESSION_DEAD.to_string()),
614                        },
615                    );
616                }
617            }
618            if let Err(error) = feed_agent_gone(&inner.bridge, &inner.store, &task_id) {
619                tracing::warn!(
620                    task = %task_id,
621                    error = %error,
622                    "agent-gone projection failed for a retired ghost"
623                );
624            }
625            // The agent left, so the session owes no task any more: the binding
626            // goes back before the idle retirement takes the slot.
627            if let Some(slot) = inner.sessions.get_mut(&key) {
628                slot.task_id = None;
629            }
630            if retire_idle_locked(&mut inner, &key, reason) {
631                // The id that travels is the session's own, the one whose row was
632                // just fed agent-gone and resource-closed: that row is what the
633                // server mirrors, and its ending is what the caller publishes. The
634                // ages are read off the slot as it stood before the retirement.
635                retired.push(Retired {
636                    session_id: task_id,
637                    arm,
638                    quiet_secs: quiet_secs(&slot, now),
639                    away_secs: away_secs(&slot, now),
640                });
641            }
642        }
643        retired
644    }
645}
646
647#[cfg(test)]
648mod tests;