Skip to main content

hatchery_node/
m4a_wake.rs

1//! Letter wake for live sessions the node adopted (`/m4a/adopt`).
2//!
3//! The old standalone mailbox daemon woke a session itself: its listener held the account's
4//! push socket and pressed the turn on the provider's leader. The node owns the accounts now,
5//! so it runs that listener in-process, one thread per adopted account (mail4agent's library:
6//! open the account store, sync/push, new inbound letters). The store dedups by event and never
7//! hands over the account's own messages; each letter goes to a per-session queue whose one
8//! worker pushes it into the live session through the leader (grok: ACP `session/load` +
9//! `session/prompt`, `gate4agent_wake::grok_leader`), with the session id and cwd recorded at
10//! adoption, so at most one push runs into a session at a time.
11//!
12//! Adoptions persist in `<accounts root>/adopted.json` (no secret: provider, session id, cwd,
13//! nick, account ref, store root), so a node restart restarts the listeners. Nothing here embeds
14//! a host, key or name.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use serde::{Deserialize, Serialize};
22
23pub const ADOPTED_FILE: &str = "adopted.json";
24/// Send socket name the session's `m4a-agent send` looks for under its store root.
25const SEND_SOCK_NAME: &str = "web-client.sock";
26
27/// One adopted live session.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Adoption {
30    pub provider: String,
31    pub session: String,
32    #[serde(default)]
33    pub cwd: Option<String>,
34    pub nick: String,
35    pub account_ref: String,
36    #[serde(default)]
37    pub store_root: Option<String>,
38}
39
40pub fn load_adoptions(base: &Path) -> Vec<Adoption> {
41    std::fs::read(base.join(ADOPTED_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
42}
43
44/// Inserts or replaces the adoption of `(provider, session)`.
45pub fn save_adoption(base: &Path, a: &Adoption) -> Result<(), String> {
46    let mut all = load_adoptions(base);
47    all.retain(|x| !(x.provider == a.provider && x.session == a.session));
48    all.push(a.clone());
49    let tmp = base.join(format!("{ADOPTED_FILE}.tmp"));
50    std::fs::write(&tmp, serde_json::to_vec_pretty(&all).map_err(|e| e.to_string())?).map_err(|e| format!("adopted: {}", e.kind()))?;
51    std::fs::rename(&tmp, base.join(ADOPTED_FILE)).map_err(|e| format!("adopted: {}", e.kind()))
52}
53
54/// The provider session an attach with no session of its own belongs to: the only adoption of
55/// that provider. Several adoptions are ambiguous (`None`).
56pub fn sole_adoption(base: &Path, provider: &str) -> Option<String> {
57    let mut it = load_adoptions(base).into_iter().filter(|a| a.provider == provider);
58    let first = it.next()?;
59    it.next().is_none().then_some(first.session)
60}
61
62/// Working directory of a grok session from the provider's own index (`active_sessions.json`
63/// rows `{"session_id","cwd"}`), falling back to the session file's `cwd` field.
64pub fn grok_session_cwd(grok_home: &Path, session: &str) -> Option<String> {
65    let rows: serde_json::Value = std::fs::read(grok_home.join("active_sessions.json")).ok().and_then(|b| serde_json::from_slice(&b).ok())?;
66    let rows = rows.as_array().cloned().or_else(|| rows.get("sessions").and_then(|s| s.as_array().cloned()))?;
67    rows.iter()
68        .find(|r| r.get("session_id").and_then(|v| v.as_str()) == Some(session))
69        .and_then(|r| r.get("cwd").and_then(|v| v.as_str()))
70        .filter(|c| !c.is_empty())
71        .map(str::to_owned)
72}
73
74/// Leader endpoint of `provider` (grok only for now).
75pub fn leader_for(provider: &str) -> Option<PathBuf> {
76    match provider {
77        "grok" => {
78            let home = gate4agent_wake::grok_leader::grok_home()?;
79            Some(gate4agent_wake::grok_leader::leader_socket(&home, std::env::var_os("GROK_LEADER_SOCKET").as_deref()))
80        }
81        _ => None,
82    }
83}
84
85static GENERATIONS: Mutex<BTreeMap<String, u64>> = Mutex::new(BTreeMap::new());
86
87/// Starts (or restarts) the in-process mailbox listener of `a`.
88pub fn watch(accounts: &crate::m4a_accounts::M4aSessionAccounts, a: Adoption) {
89    let _ = accounts;
90    let Some(leader) = leader_for(&a.provider) else {
91        tracing::info!(provider = %a.provider, nick = %a.nick, "letter wake for this provider is not built in yet; letters stay in the inbox");
92        return;
93    };
94    let gen = {
95        let mut g = GENERATIONS.lock().unwrap_or_else(|p| p.into_inner());
96        let e = g.entry(a.account_ref.clone()).or_insert(0);
97        *e += 1;
98        *e
99    };
100    let _ = std::thread::Builder::new().name(format!("m4a-wake-{}", a.nick)).spawn(move || listen(a, leader, gen));
101}
102
103/// Restarts the listeners of every saved adoption (node start).
104pub fn restore(accounts: &crate::m4a_accounts::M4aSessionAccounts) {
105    for a in load_adoptions(accounts.base()) {
106        watch(accounts, a);
107    }
108}
109
110fn current(a: &Adoption, gen: u64) -> bool {
111    GENERATIONS.lock().unwrap_or_else(|p| p.into_inner()).get(&a.account_ref) == Some(&gen)
112}
113
114fn now_ms() -> i64 {
115    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
116}
117
118/// One adopted account: open its store, keep sync/push going, hand each new inbound letter to
119/// [`LeaderPush`] (the store dedups by event and never hands over its own messages).
120fn listen(a: Adoption, leader: PathBuf, gen: u64) {
121    use mail4agent_messenger_shell::{HostSession, MachineClient, ProviderKind, ProviderSession, SessionKind, WakeChain};
122    let mut backoff = Duration::from_secs(5);
123    let pusher = LeaderPush::start(a.clone(), leader);
124    while current(&a, gen) {
125        let (Some(url), Some(root), Some(cwd)) = (std::env::var(crate::m4a_accounts::PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty()), a.store_root.clone(), a.cwd.clone()) else {
126            tracing::warn!(nick = %a.nick, "m4a letter listener not started: messenger url, store root or cwd unknown");
127            return;
128        };
129        let opened = MachineClient::open(&url, Path::new(&root), vec![HostSession::new(a.nick.clone(), a.account_ref.clone())]);
130        let mut client = match opened {
131            Ok(c) => c,
132            Err(e) => {
133                tracing::warn!(nick = %a.nick, error = %e, "m4a letter listener: store did not open; retrying");
134                std::thread::sleep(backoff);
135                backoff = (backoff * 2).min(Duration::from_secs(300));
136                continue;
137            }
138        };
139        backoff = Duration::from_secs(5);
140        // The session's own `send` tool reaches this open store through the account's send socket.
141        if let Err(e) = client.listen_for_sends(&Path::new(&root).join(SEND_SOCK_NAME)) {
142            tracing::warn!(nick = %a.nick, error = %e, "m4a send socket not opened");
143        }
144        let session = ProviderSession { kind: SessionKind::local(ProviderKind::Grok), session_id: a.session.clone(), nick: a.nick.clone(), cwd: Some(PathBuf::from(&cwd)), headless: false };
145        let mut chain = WakeChain::new();
146        chain.push("node-leader", Box::new(pusher.adapter()));
147        match client.session_mut(&a.nick) {
148            Ok(store) => store.set_wake_chain(session, chain),
149            Err(e) => {
150                tracing::warn!(nick = %a.nick, error = %e, "m4a letter listener: session not in the opened store");
151                std::thread::sleep(Duration::from_secs(60));
152                continue;
153            }
154        }
155        tracing::info!(nick = %a.nick, provider = %a.provider, "m4a letter listener up");
156        while current(&a, gen) {
157            let report = client.tick(now_ms(), 15);
158            for (_, err) in &report.errors {
159                tracing::debug!(nick = %a.nick, error = %err, "m4a listener drive error");
160            }
161            if client.error_streak() > 40 {
162                tracing::warn!(nick = %a.nick, "m4a listener: homeserver keeps failing; reopening");
163                break;
164            }
165            std::thread::sleep(Duration::from_millis(500));
166        }
167    }
168    tracing::info!(nick = %a.nick, "m4a letter listener replaced or stopped");
169}
170
171/// Per-session push queue: one worker thread, so at most one push into a session at a time.
172struct LeaderPush {
173    tx: std::sync::mpsc::Sender<String>,
174}
175
176impl LeaderPush {
177    fn start(a: Adoption, leader: PathBuf) -> Self {
178        let (tx, rx) = std::sync::mpsc::channel::<String>();
179        let _ = std::thread::Builder::new().name(format!("m4a-push-{}", a.nick)).spawn(move || {
180            let Ok(rt) = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build() else { return };
181            let cwd = a.cwd.clone().unwrap_or_default();
182            for prompt in rx {
183                match rt.block_on(gate4agent_wake::grok_leader::push_into_session(&leader, &a.session, &cwd, &prompt)) {
184                    Ok(()) => tracing::info!(nick = %a.nick, "letter pushed into the live session"),
185                    Err(e) => tracing::warn!(nick = %a.nick, error = %e, "letter push into the live session failed (letter stays in the mailbox history)"),
186                }
187            }
188        });
189        Self { tx }
190    }
191
192    fn adapter(&self) -> PushAdapter {
193        PushAdapter { tx: self.tx.clone() }
194    }
195}
196
197struct PushAdapter {
198    tx: std::sync::mpsc::Sender<String>,
199}
200
201impl mail4agent_messenger_shell::WakeAdapter for PushAdapter {
202    fn kind(&self) -> mail4agent_messenger_shell::SessionKind {
203        mail4agent_messenger_shell::SessionKind::local(mail4agent_messenger_shell::ProviderKind::Grok)
204    }
205    fn probe(&self, _s: &mail4agent_messenger_shell::ProviderSession) -> Result<(), mail4agent_messenger_shell::WakeError> {
206        Ok(())
207    }
208    fn wake(&mut self, s: &mail4agent_messenger_shell::ProviderSession, l: &mail4agent_messenger_shell::WakeLetter<'_>) -> Result<mail4agent_messenger_shell::WakeOutcome, mail4agent_messenger_shell::WakeError> {
209        let v = serde_json::json!({"from": l.from_nick, "event_id": l.event_id, "room": l.room, "body": l.body, "kind": if l.reaction.is_some() {"reaction"} else {"message"}, "reply_needed": l.reaction.is_none()});
210        self.tx.send(letter_prompt(&s.nick, &v)).map_err(|_| mail4agent_messenger_shell::WakeError::Unavailable("push worker gone".into()))?;
211        Ok(mail4agent_messenger_shell::WakeOutcome::Delivered)
212    }
213}
214
215/// The prompt a woken session gets: headers, how to answer, then the letter verbatim.
216pub fn letter_prompt(nick: &str, letter: &serde_json::Value) -> String {
217    let s = |k: &str| letter.get(k).and_then(|v| v.as_str()).unwrap_or("").to_owned();
218    let mut head = format!("[m4a letter]\nkind: {}\nfrom: {}\nto: {nick}\n", if s("kind").is_empty() { "message".into() } else { s("kind") }, s("from"));
219    if !s("room").is_empty() {
220        head.push_str(&format!("room: {}\n", s("room")));
221    }
222    head.push_str(&format!("event: {}\n", s("event_id")));
223    if letter.get("reply_needed").and_then(|v| v.as_bool()).unwrap_or(true) {
224        head.push_str(&format!("reply: your m4a MCP tool `send` with to = \"{}\"\n", s("from")));
225    }
226    format!("{head}---\n{}", s("body"))
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn adoption(session: &str) -> Adoption {
234        Adoption { provider: "grok".into(), session: session.into(), cwd: Some("w".into()), nick: "grok-1".into(), account_ref: "grok-ab".into(), store_root: Some("s".into()) }
235    }
236
237    #[test]
238    fn adoptions_persist_replace_and_resolve_a_sole_session() {
239        let dir = std::env::temp_dir().join(format!("m4a-wake-{}", std::process::id()));
240        let _ = std::fs::remove_dir_all(&dir);
241        std::fs::create_dir_all(&dir).unwrap();
242        assert_eq!(sole_adoption(&dir, "grok"), None);
243        save_adoption(&dir, &adoption("s-1")).unwrap();
244        save_adoption(&dir, &adoption("s-1")).unwrap();
245        assert_eq!(load_adoptions(&dir).len(), 1);
246        assert_eq!(sole_adoption(&dir, "grok").as_deref(), Some("s-1"));
247        assert_eq!(sole_adoption(&dir, "kimi"), None);
248        save_adoption(&dir, &adoption("s-2")).unwrap();
249        assert_eq!(sole_adoption(&dir, "grok"), None, "two adoptions are ambiguous");
250        std::fs::remove_dir_all(&dir).unwrap();
251    }
252
253    #[test]
254    fn prompt_names_sender_and_reply_tool() {
255        let p = letter_prompt("grok-1", &serde_json::json!({"from":"web","event_id":"$e","room":"!r","body":"hi"}));
256        assert!(p.contains("from: web") && p.contains("to: grok-1") && p.contains("to = \"web\"") && p.ends_with("---\nhi"));
257    }
258
259    #[test]
260    fn grok_cwd_comes_from_the_index() {
261        let dir = std::env::temp_dir().join(format!("m4a-wake-idx-{}", std::process::id()));
262        std::fs::create_dir_all(&dir).unwrap();
263        std::fs::write(dir.join("active_sessions.json"), r#"[{"session_id":"a","cwd":"w"},{"session_id":"b","cwd":""}]"#).unwrap();
264        assert_eq!(grok_session_cwd(&dir, "a").as_deref(), Some("w"));
265        assert_eq!(grok_session_cwd(&dir, "b"), None);
266        std::fs::remove_dir_all(&dir).unwrap();
267    }
268}