OXYGENHUB

Coverage / crates/spectra-core/src/presence.rs 96.6%

171 of 177 lines run, 37 of 45 functions · run #17 on main · run missed

1
//! Presence and sessions (spec §8.1, §8.2), kept in memory by the daemon
2
//! from what agents do, and recorded as events when they change, so the
3
//! ledger says when an agent went quiet.
4
5
use crate::event::NewEvent;
6
use std::collections::HashMap;
7
8
/// A request within this long makes an agent online.
9
pub const ONLINE_MS: u64 = 60_000;
10
/// Silence this long ends a session and makes an agent offline.
11
pub const IDLE_AFTER_MS: u64 = 10 * 60_000;
12
13
#[derive(Clone, Debug, Default)]
14
struct Activity {
15
    last_request_ms: Option<u64>,
16
    waiting: u32,
17
    shims: u32,
18
    status_ms: Option<u64>,
19
    blocked: bool,
20
    paused: bool,
21
    session: Option<String>,
22
    state: Option<&'static str>,
23
}
24
25
/// Every agent's activity, and the state last recorded for each.
26
#[derive(Debug, Default)]
27
pub struct Presence {
28
    agents: HashMap<String, Activity>,
29
}
30
31
impl Presence {
32
    /// A request from `agent`: returns its session id, and a
33
    /// `agent.session_started` event when this begins a new one.
3445×
    pub fn request(&mut self, agent: &str, now: u64, new_session: bool) -> (String, Option<NewEvent>) {
3545×
        let a = self.agents.entry(agent.to_string()).or_default();
3645×
        let lapsed = a.last_request_ms.is_none_or(|t| now.saturating_sub(t) >= IDLE_AFTER_MS);
3745×
        a.last_request_ms = Some(now);
3845×
        if a.session.is_some() && !lapsed && !new_session {
3930×
            return (a.session.clone().unwrap_or_default(), None);
4015×
        }
4115×
        let id = crate::ids::ulid();
4215×
        a.session = Some(id.clone());
4315×
        let e = NewEvent::system("agent.session_started", serde_json::json!({ "agent": agent, "session": id }))
4415×
            .subject(agent)
4515×
            .session(id.clone());
4615×
        (id, Some(e))
4745×
    }
486×
    pub fn wait_started(&mut self, agent: &str) {
496×
        self.agents.entry(agent.to_string()).or_default().waiting += 1;
506×
    }
516×
    pub fn wait_ended(&mut self, agent: &str) {
526×
        let a = self.agents.entry(agent.to_string()).or_default();
536×
        a.waiting = a.waiting.saturating_sub(1);
546×
    }
551×
    pub fn shim_connected(&mut self, agent: &str) {
561×
        self.agents.entry(agent.to_string()).or_default().shims += 1;
571×
    }
581×
    pub fn shim_gone(&mut self, agent: &str) {
591×
        let a = self.agents.entry(agent.to_string()).or_default();
601×
        a.shims = a.shims.saturating_sub(1);
611×
    }
62
    /// The agent set its status (the `status` tool); `None` clears it.
633×
    pub fn status(&mut self, agent: &str, at: Option<u64>) {
643×
        self.agents.entry(agent.to_string()).or_default().status_ms = at;
653×
    }
663×
    pub fn blocked(&mut self, agent: &str, blocked: bool) {
673×
        self.agents.entry(agent.to_string()).or_default().blocked = blocked;
683×
    }
692×
    pub fn paused(&mut self, agent: &str, paused: bool) {
702×
        self.agents.entry(agent.to_string()).or_default().paused = paused;
712×
    }
72
73
    /// The state `agent` is in at `now`.
7420×
    pub fn state(&self, agent: &str, now: u64) -> &'static str {
7520×
        self.agents.get(agent).map(|a| compute(a, now)).unwrap_or("offline")
7620×
    }
77
78
    /// Events for every change since the last call: `agent.presence` for a
79
    /// new state, `agent.session_ended` for a session gone quiet.
8014×
    pub fn tick(&mut self, now: u64) -> Vec<NewEvent> {
8114×
        let mut out = Vec::new();
8214×
        let mut names: Vec<&String> = self.agents.keys().collect();
8314×
        names.sort();
8414×
        let names: Vec<String> = names.into_iter().cloned().collect();
8514×
        for name in names {
864×
            let a = self.agents.get_mut(&name).expect("listed");
874×
            let s = compute(a, now);
884×
            if a.state != Some(s) {
893×
                let from = a.state.unwrap_or("offline");
903×
                a.state = Some(s);
913×
                if from != s {
923×
                    out.push(
933×
                        NewEvent::system("agent.presence", serde_json::json!({ "agent": name, "state": s, "from": from }))
943×
                            .subject(name.clone()),
953×
                    );
963×
                }
971×
            }
984×
            let quiet = a.last_request_ms.is_none_or(|t| now.saturating_sub(t) >= IDLE_AFTER_MS);
994×
            if quiet && a.waiting == 0 {
1001×
                if let Some(id) = a.session.take() {
1011×
                    out.push(
1021×
                        NewEvent::system("agent.session_ended", serde_json::json!({ "agent": name, "session": id }))
1031×
                            .subject(name.clone())
1041×
                            .session(id),
1051×
                    );
1061×
                }
1073×
            }
108
        }
10914×
        out
11014×
    }
111
}
112
113
/// Paused, then blocked, then busy, then online, then idle, then offline.
11419×
fn compute(a: &Activity, now: u64) -> &'static str {
11535×
    let within = |t: Option<u64>, ms: u64| t.is_some_and(|t| now.saturating_sub(t) < ms);
11619×
    if a.paused {
1171×
        "paused"
11818×
    } else if a.blocked {
1191×
        "blocked"
12017×
    } else if within(a.status_ms, IDLE_AFTER_MS) {
1212×
        "busy"
12215×
    } else if a.waiting > 0 || within(a.last_request_ms, ONLINE_MS) {
12310×
        "online"
1245×
    } else if a.shims > 0 || within(a.last_request_ms, IDLE_AFTER_MS) {
1253×
        "idle"
126
    } else {
1272×
        "offline"
128
    }
12919×
}
130
131
/// Apply presence, status and session events to their tables.
13242500×
pub async fn project(conn: &mut sqlx::SqliteConnection, e: &crate::event::Event) -> sqlx::Result<()> {
13342500×
    let p = e.json();
13442500×
    let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from);
135
    // A projection skips what it cannot use: a malformed payload must never
136
    // stop the ledger from taking events.
13742500×
    match e.kind.as_str() {
13842500×
        "agent.presence" if s("agent").is_some() && s("state").is_some() => {
1390×
            sqlx::query("INSERT INTO presence (agent, state, since_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET state = excluded.state, since_ms = excluded.since_ms")
1400×
                .bind(s("agent"))
1410×
                .bind(s("state"))
1420×
                .bind(e.at_ms as i64)
1430×
                .execute(&mut *conn)
1440×
                .await?;
145
        }
14638988×
        "agent.status" if e.actor.starts_with("agent:") => {
1471×
            let agent = e.actor.strip_prefix("agent:").map(String::from);
1481×
            sqlx::query(
1491×
                "INSERT INTO presence (agent, state, since_ms, status_state, status_text, status_task, status_ms) VALUES (?, 'online', ?, ?, ?, ?, ?) \
1501×
                 ON CONFLICT(agent) DO UPDATE SET status_state = excluded.status_state, status_text = excluded.status_text, status_task = excluded.status_task, status_ms = excluded.status_ms",
1511×
            )
1521×
            .bind(agent)
1531×
            .bind(e.at_ms as i64)
1541×
            .bind(s("state"))
1551×
            .bind(s("text"))
1561×
            .bind(s("task"))
1571×
            .bind(e.at_ms as i64)
1581×
            .execute(&mut *conn)
1591×
            .await?;
160
        }
16135595×
        "agent.session_started" if s("agent").is_some() && s("session").is_some() => {
16211×
            sqlx::query("INSERT OR IGNORE INTO sessions (id, agent, started_ms) VALUES (?, ?, ?)")
16311×
                .bind(s("session"))
16411×
                .bind(s("agent"))
16511×
                .bind(e.at_ms as i64)
16611×
                .execute(&mut *conn)
16711×
                .await?;
168
        }
16932328×
        "agent.session_ended" => {
1703476×
            sqlx::query("UPDATE sessions SET ended_ms = ? WHERE id = ?")
1713476×
                .bind(e.at_ms as i64)
1723476×
                .bind(s("session"))
1733476×
                .execute(&mut *conn)
1743476×
                .await?;
175
        }
17639012×
        _ => {}
177
    }
17842500×
    Ok(())
17942500×
}
180
181
#[cfg(test)]
182
mod tests {
183
    use super::*;
184
1853×
    fn kinds(v: &[NewEvent]) -> Vec<String> {
1864×
        v.iter().map(|e| format!("{} {}", e.kind, e.payload.get("state").and_then(|s| s.as_str()).unwrap_or(""))).collect()
1873×
    }
188
189
    #[test]
1901×
    fn presence_moves_through_its_states_and_records_each_change() {
1911×
        let mut p = Presence::default();
1921×
        let t = 1_000_000;
1931×
        let (s1, started) = p.request("claude", t, false);
1941×
        assert!(started.is_some());
1951×
        assert_eq!(kinds(&p.tick(t)), ["agent.presence online"]);
1961×
        assert!(p.tick(t + 1).is_empty(), "no change, no event");
1971×
        assert_eq!(p.state("claude", t + ONLINE_MS + 1), "idle");
1981×
        assert_eq!(kinds(&p.tick(t + ONLINE_MS + 1)), ["agent.presence idle"]);
1991×
        p.status("claude", Some(t + ONLINE_MS + 2));
2001×
        assert_eq!(p.state("claude", t + ONLINE_MS + 3), "busy");
2011×
        p.blocked("claude", true);
2021×
        assert_eq!(p.state("claude", t + ONLINE_MS + 3), "blocked");
2031×
        p.blocked("claude", false);
2041×
        p.status("claude", None);
2051×
        p.paused("claude", true);
2061×
        assert_eq!(p.state("claude", t + ONLINE_MS + 3), "paused");
2071×
        p.paused("claude", false);
2081×
        let late = t + IDLE_AFTER_MS + 5;
2091×
        let ev = p.tick(late);
2101×
        assert_eq!(kinds(&ev), ["agent.presence offline", "agent.session_ended "]);
2111×
        let (s2, started) = p.request("claude", late + 1, false);
2121×
        assert!(started.is_some() && s2 != s1, "a new session after ten minutes' silence");
2131×
        let (s3, none) = p.request("claude", late + 2, false);
2141×
        assert!(none.is_none() && s3 == s2);
2151×
        let (s4, again) = p.request("claude", late + 3, true);
2161×
        assert!(again.is_some() && s4 != s3, "brief(new_session: true)");
2171×
    }
218
219
    #[test]
2201×
    fn a_waiting_or_connected_agent_is_not_offline() {
2211×
        let mut p = Presence::default();
2221×
        p.request("kimi", 0, false);
2231×
        p.wait_started("kimi");
2241×
        assert_eq!(p.state("kimi", IDLE_AFTER_MS * 3), "online");
2251×
        p.wait_ended("kimi");
2261×
        p.shim_connected("kimi");
2271×
        assert_eq!(p.state("kimi", IDLE_AFTER_MS * 3), "idle");
2281×
        p.shim_gone("kimi");
2291×
        assert_eq!(p.state("kimi", IDLE_AFTER_MS * 3), "offline");
2301×
    }
231
}