Skip to main content

gate4agent_wake/
hosted.rs

1//! Wake for sessions a host owns (the hatchery node): the host knows which sessions are live
2//! (it spawned them and assigned each its m4a account, nick = key = role); g4a decides what a
3//! letter does and frames the prompt; the host executes the decision on its own runtime
4//! (submit a prompt into the live PTY/ACP session, or spawn one). No I/O here, so the decision
5//! is the same for every host and testable without one.
6
7use crate::{wake_prompt, ProviderKind, ProviderSession, SessionKind, WakeLetter};
8
9/// What the host knows about the session that owns `nick`.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HostedTarget {
12    pub nick: String,
13    pub provider: ProviderKind,
14    /// A process for this session is running now and can take a prompt.
15    pub live: bool,
16    /// The host may start a new session for this nick when none is live.
17    pub spawn_allowed: bool,
18}
19
20/// The host's view of its sessions.
21pub trait HostedSessions {
22    fn lookup(&self, nick: &str) -> Option<HostedTarget>;
23}
24
25/// What the host should do with one letter.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum HostedWake {
28    /// Submit `prompt` as the next turn of the live session.
29    Submit { nick: String, prompt: String },
30    /// No live session: spawn one for `provider` with `prompt` as its first turn.
31    Spawn { nick: String, provider: ProviderKind, prompt: String },
32    /// Nothing to do here (unknown nick, or not live and spawn not allowed). The letter stays
33    /// in m4a's durable inbox; the reason is safe to log (no letter text).
34    Refuse(String),
35}
36
37/// Decides the wake for a letter addressed to `nick`.
38pub fn plan_hosted_wake(sessions: &dyn HostedSessions, nick: &str, letter: &WakeLetter<'_>) -> HostedWake {
39    let Some(target) = sessions.lookup(nick) else {
40        return HostedWake::Refuse(format!("{nick}: no session of this host"));
41    };
42    let session = ProviderSession {
43        kind: SessionKind::local(target.provider),
44        session_id: String::new(),
45        nick: target.nick.clone(),
46        cwd: None,
47        headless: !target.live,
48    };
49    let prompt = wake_prompt(&session, letter);
50    if target.live {
51        HostedWake::Submit { nick: target.nick, prompt }
52    } else if target.spawn_allowed {
53        HostedWake::Spawn { nick: target.nick, provider: target.provider, prompt }
54    } else {
55        HostedWake::Refuse(format!("{nick}: not live and spawn is off"))
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    struct Fake(Vec<HostedTarget>);
64    impl HostedSessions for Fake {
65        fn lookup(&self, nick: &str) -> Option<HostedTarget> {
66            self.0.iter().find(|t| t.nick == nick).cloned()
67        }
68    }
69    fn t(nick: &str, live: bool, spawn: bool) -> HostedTarget {
70        HostedTarget { nick: nick.into(), provider: ProviderKind::Codex, live, spawn_allowed: spawn }
71    }
72    fn letter() -> WakeLetter<'static> {
73        WakeLetter { body: "ping", from_nick: "hatcheryweb", event_id: "$e1", room: None, reaction: None }
74    }
75
76    #[test]
77    fn live_session_gets_a_framed_turn() {
78        let f = Fake(vec![t("codex-1", true, false)]);
79        match plan_hosted_wake(&f, "codex-1", &letter()) {
80            HostedWake::Submit { nick, prompt } => {
81                assert_eq!(nick, "codex-1");
82                assert!(prompt.ends_with("ping"));
83                assert!(prompt.contains("m4a-send --as codex-1"));
84            }
85            other => panic!("{other:?}"),
86        }
87    }
88
89    #[test]
90    fn offline_session_spawns_only_when_allowed_and_unknown_is_refused() {
91        let f = Fake(vec![t("a", false, true), t("b", false, false)]);
92        assert!(matches!(plan_hosted_wake(&f, "a", &letter()), HostedWake::Spawn { provider: ProviderKind::Codex, .. }));
93        assert!(matches!(plan_hosted_wake(&f, "b", &letter()), HostedWake::Refuse(_)));
94        match plan_hosted_wake(&f, "zzz", &letter()) {
95            HostedWake::Refuse(why) => assert!(!why.contains("ping")),
96            other => panic!("{other:?}"),
97        }
98    }
99}