Skip to main content

lean_ctx/core/session/
caller.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The calling agent's own session, for code that runs inside one request.
3//!
4//! Session continuity — the wake-up briefing, the last task, recent
5//! decisions, findings, intents, the last search query — comes only from the
6//! caller's own session or from what was explicitly handed off into it. The
7//! project's latest saved session is whichever agent saved last: reading it
8//! handed one agent's private task to another, and writing it changed another
9//! agent's session (AP01 §1/§2, ABNAHME I07/S03).
10//!
11//! Outside a request (CLI, hooks, background threads) there is no caller
12//! session: readers get `None` and writers write nothing.
13
14use std::cell::RefCell;
15
16use super::slot::bound_session;
17use super::{SessionState, SharedSession};
18
19tokio::task_local! {
20    /// The caller's session for a synchronous handler on the blocking pool,
21    /// where the request's task locals do not reach.
22    static CALLER: RefCell<Option<SharedSession>>;
23}
24
25/// Run `operation` with `session` as the caller's own session.
26pub fn scope<R>(session: Option<SharedSession>, operation: impl FnOnce() -> R) -> R {
27    CALLER.sync_scope(RefCell::new(session), operation)
28}
29
30/// The caller's session instance: the explicit scope, else the instance the
31/// running request is bound to.
32pub fn current() -> Option<SharedSession> {
33    CALLER
34        .try_with(|caller| caller.borrow().clone())
35        .ok()
36        .flatten()
37        .or_else(bound_session)
38}
39
40/// Read the caller's own session. `None` without a caller, or while the
41/// session is being written (never waits: callers hold no ordering on it).
42pub fn with<R>(read: impl FnOnce(&SessionState) -> R) -> Option<R> {
43    let session = current()?;
44    let guard = session.try_read().ok()?;
45    Some(read(&guard))
46}
47
48/// [`with`] restricted to a caller whose session belongs to `project_root`.
49pub fn with_for_project<R>(project_root: &str, read: impl FnOnce(&SessionState) -> R) -> Option<R> {
50    with(|session| belongs_to(session, project_root).then(|| read(session))).flatten()
51}
52
53/// Change the caller's own session in memory; the server's save cadence
54/// persists it. `None` without a caller or while the session is locked.
55pub fn with_mut<R>(write: impl FnOnce(&mut SessionState) -> R) -> Option<R> {
56    let session = current()?;
57    let mut guard = session.try_write().ok()?;
58    Some(write(&mut guard))
59}
60
61fn belongs_to(session: &SessionState, project_root: &str) -> bool {
62    let Some(own) = session.project_root.as_deref() else {
63        return false;
64    };
65    own == project_root
66        || crate::core::project_hash::hash_project_root(own)
67            == crate::core::project_hash::hash_project_root(project_root)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    fn shared(root: &str) -> SharedSession {
75        let mut session = SessionState::new();
76        session.project_root = Some(root.to_string());
77        std::sync::Arc::new(tokio::sync::RwLock::new(session))
78    }
79
80    /// Outside a request nothing is "own": a CLI or hook process must never
81    /// fall back to a saved session. Inside, only the caller's session of the
82    /// asked project is.
83    #[test]
84    fn only_the_scoped_caller_session_of_the_project_is_own() {
85        assert!(with(|session| session.id.clone()).is_none());
86        assert!(with_mut(|session| session.id.clone()).is_none());
87
88        let session = shared("/work/p1");
89        let expected = session.try_read().unwrap().id.clone();
90        let own = scope(Some(session.clone()), || {
91            with_for_project("/work/p1", |s| s.id.clone())
92        });
93        assert_eq!(own, Some(expected));
94        assert!(
95            scope(Some(session), || with_for_project("/work/p2", |_| ())).is_none(),
96            "a caller session of another project is not this project's own"
97        );
98    }
99}