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, on a letter, pressed the turn on the provider's leader. The node owns the
5//! accounts now, so it runs that listener itself, one per adopted account:
6//!
7//! - child: `m4a-agent node-client` with the account's own store (no capability: the listener
8//!   only receives and serves the account's local send socket), supervised and restarted by
9//!   the node;
10//! - on a letter, m4a runs its letter hook (`M4A_LETTER_HOOK`), which is this binary in hook
11//!   mode ([`hook_main`]): it reads the letter from stdin, starts a detached pusher and exits at
12//!   once (m4a gives a hook 10 s);
13//! - the pusher sends the framed letter into the live session through the provider's leader
14//!   (grok: ACP `session/load` + `session/prompt`, `gate4agent_wake::grok_leader`), with the
15//!   provider session id and working directory recorded at adoption.
16//!
17//! Adoptions persist in `<accounts root>/adopted.json` (no secret: provider, session id, cwd,
18//! nick, account ref, store root), so a node restart restarts the listeners. Nothing here embeds
19//! a host, key or name.
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::process::{Child, Command, Stdio};
24use std::sync::Mutex;
25use std::time::{Duration, Instant};
26
27use serde::{Deserialize, Serialize};
28
29pub const ADOPTED_FILE: &str = "adopted.json";
30const HOOK_ENV: &str = "HATCHERY_LETTER_HOOK";
31const PUSH_ENV: &str = "HATCHERY_LETTER_PUSH";
32const WAKE_PROVIDER_ENV: &str = "HATCHERY_WAKE_PROVIDER";
33const WAKE_SESSION_ENV: &str = "HATCHERY_WAKE_SESSION";
34const WAKE_CWD_ENV: &str = "HATCHERY_WAKE_CWD";
35const WAKE_LEADER_ENV: &str = "HATCHERY_WAKE_LEADER";
36const WAKE_NICK_ENV: &str = "HATCHERY_WAKE_NICK";
37/// Send socket name the session's `m4a-agent send` looks for under its store root.
38const SEND_SOCK_NAME: &str = "web-client.sock";
39
40/// One adopted live session.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Adoption {
43    pub provider: String,
44    pub session: String,
45    #[serde(default)]
46    pub cwd: Option<String>,
47    pub nick: String,
48    pub account_ref: String,
49    #[serde(default)]
50    pub store_root: Option<String>,
51}
52
53pub fn load_adoptions(base: &Path) -> Vec<Adoption> {
54    std::fs::read(base.join(ADOPTED_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
55}
56
57/// Inserts or replaces the adoption of `(provider, session)`.
58pub fn save_adoption(base: &Path, a: &Adoption) -> Result<(), String> {
59    let mut all = load_adoptions(base);
60    all.retain(|x| !(x.provider == a.provider && x.session == a.session));
61    all.push(a.clone());
62    let tmp = base.join(format!("{ADOPTED_FILE}.tmp"));
63    std::fs::write(&tmp, serde_json::to_vec_pretty(&all).map_err(|e| e.to_string())?).map_err(|e| format!("adopted: {}", e.kind()))?;
64    std::fs::rename(&tmp, base.join(ADOPTED_FILE)).map_err(|e| format!("adopted: {}", e.kind()))
65}
66
67/// The provider session an attach with no session of its own belongs to: the only adoption of
68/// that provider. Several adoptions are ambiguous (`None`).
69pub fn sole_adoption(base: &Path, provider: &str) -> Option<String> {
70    let mut it = load_adoptions(base).into_iter().filter(|a| a.provider == provider);
71    let first = it.next()?;
72    it.next().is_none().then_some(first.session)
73}
74
75/// Working directory of a grok session from the provider's own index (`active_sessions.json`
76/// rows `{"session_id","cwd"}`), falling back to the session file's `cwd` field.
77pub fn grok_session_cwd(grok_home: &Path, session: &str) -> Option<String> {
78    let rows: serde_json::Value = std::fs::read(grok_home.join("active_sessions.json")).ok().and_then(|b| serde_json::from_slice(&b).ok())?;
79    let rows = rows.as_array().cloned().or_else(|| rows.get("sessions").and_then(|s| s.as_array().cloned()))?;
80    rows.iter()
81        .find(|r| r.get("session_id").and_then(|v| v.as_str()) == Some(session))
82        .and_then(|r| r.get("cwd").and_then(|v| v.as_str()))
83        .filter(|c| !c.is_empty())
84        .map(str::to_owned)
85}
86
87/// Leader endpoint of `provider` (grok only for now).
88pub fn leader_for(provider: &str) -> Option<PathBuf> {
89    match provider {
90        "grok" => {
91            let home = gate4agent_wake::grok_leader::grok_home()?;
92            Some(gate4agent_wake::grok_leader::leader_socket(&home, std::env::var_os("GROK_LEADER_SOCKET").as_deref()))
93        }
94        _ => None,
95    }
96}
97
98/// Environment of the listener child for one adoption.
99pub fn listener_env(a: &Adoption, product_url: &str, leader: &Path, hook: &Path) -> Result<Vec<(String, String)>, String> {
100    let store_root = a.store_root.as_deref().ok_or("adoption has no store root")?;
101    let cwd = a.cwd.as_deref().ok_or("adoption has no working directory")?;
102    let send_sock = Path::new(store_root).join(SEND_SOCK_NAME);
103    let leader = leader.to_string_lossy().into_owned();
104    Ok(vec![
105        ("M4A_PRODUCT_URL".into(), product_url.to_owned()),
106        ("M4A_SESSION_ID".into(), a.account_ref.clone()),
107        ("M4A_STORE_ROOT".into(), store_root.to_owned()),
108        ("M4A_BOT_NAME".into(), a.nick.clone()),
109        ("M4A_NICK".into(), a.nick.clone()),
110        ("M4A_LEADER_SOCK".into(), leader.clone()),
111        ("M4A_LEADER_CWD".into(), cwd.to_owned()),
112        ("M4A_SEND_SOCK".into(), send_sock.to_string_lossy().into_owned()),
113        ("M4A_LETTER_HOOK".into(), hook.to_string_lossy().into_owned()),
114        (HOOK_ENV.into(), "1".into()),
115        (WAKE_PROVIDER_ENV.into(), a.provider.clone()),
116        (WAKE_SESSION_ENV.into(), a.session.clone()),
117        (WAKE_CWD_ENV.into(), cwd.to_owned()),
118        (WAKE_LEADER_ENV.into(), leader),
119        (WAKE_NICK_ENV.into(), a.nick.clone()),
120    ])
121}
122
123struct Slot {
124    adoption: Adoption,
125    child: Option<Child>,
126    next_start: Instant,
127    backoff: Duration,
128}
129
130static SLOTS: Mutex<BTreeMap<String, Slot>> = Mutex::new(BTreeMap::new());
131static STARTED: std::sync::Once = std::sync::Once::new();
132
133/// Starts (or restarts) the listener of `a`, and the supervisor thread on first use.
134pub fn watch(accounts: &crate::m4a_accounts::M4aSessionAccounts, a: Adoption) {
135    if leader_for(&a.provider).is_none() {
136        tracing::info!(provider = %a.provider, nick = %a.nick, "letter wake for this provider is not built in yet; letters stay in the inbox");
137        return;
138    }
139    {
140        let mut slots = SLOTS.lock().unwrap_or_else(|p| p.into_inner());
141        if let Some(mut old) = slots.remove(&a.account_ref) {
142            stop(&mut old);
143        }
144        slots.insert(a.account_ref.clone(), Slot { adoption: a, child: None, next_start: Instant::now(), backoff: Duration::from_secs(5) });
145    }
146    let (bin, base) = (accounts.bin().to_path_buf(), accounts.base().to_path_buf());
147    STARTED.call_once(move || {
148        std::thread::Builder::new()
149            .name("m4a-wake".into())
150            .spawn(move || loop {
151                supervise(&bin, &base);
152                std::thread::sleep(Duration::from_secs(2));
153            })
154            .ok();
155    });
156}
157
158/// Restarts the listeners of every saved adoption (node start).
159pub fn restore(accounts: &crate::m4a_accounts::M4aSessionAccounts) {
160    for a in load_adoptions(accounts.base()) {
161        watch(accounts, a);
162    }
163}
164
165fn stop(slot: &mut Slot) {
166    if let Some(mut c) = slot.child.take() {
167        let _ = c.kill();
168        let _ = c.wait();
169    }
170}
171
172fn supervise(bin: &Path, base: &Path) {
173    let product_url = std::env::var(crate::m4a_accounts::PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty());
174    let hook = std::env::current_exe().ok();
175    let mut slots = SLOTS.lock().unwrap_or_else(|p| p.into_inner());
176    for slot in slots.values_mut() {
177        if let Some(c) = slot.child.as_mut() {
178            match c.try_wait() {
179                Ok(None) => continue,
180                Ok(Some(status)) => {
181                    tracing::warn!(nick = %slot.adoption.nick, code = ?status.code(), "m4a letter listener exited; restarting");
182                    slot.child = None;
183                    slot.next_start = Instant::now() + slot.backoff;
184                    slot.backoff = (slot.backoff * 2).min(Duration::from_secs(300));
185                    continue;
186                }
187                Err(_) => continue,
188            }
189        }
190        if Instant::now() < slot.next_start {
191            continue;
192        }
193        let (Some(url), Some(hook), Some(leader)) = (product_url.as_deref(), hook.as_deref(), leader_for(&slot.adoption.provider)) else {
194            slot.next_start = Instant::now() + Duration::from_secs(300);
195            tracing::warn!(nick = %slot.adoption.nick, "m4a letter listener not started: messenger url, node binary or leader unknown");
196            continue;
197        };
198        let env = match listener_env(&slot.adoption, url, &leader, hook) {
199            Ok(env) => env,
200            Err(why) => {
201                tracing::warn!(nick = %slot.adoption.nick, %why, "m4a letter listener not started");
202                slot.next_start = Instant::now() + Duration::from_secs(300);
203                continue;
204            }
205        };
206        let logs = base.join("logs");
207        let _ = std::fs::create_dir_all(&logs);
208        let log = std::fs::OpenOptions::new().create(true).append(true).open(logs.join(format!("{}.listen.log", slot.adoption.nick)));
209        let mut cmd = Command::new(bin);
210        cmd.arg("node-client").stdin(Stdio::null());
211        for k in ["M4A_SESSION_CAP", "M4A_PRODUCT_INVITE", "M4A_ROUTINE_URL", "M4A_ROUTINE_BEARER", "M4A_HOMESERVER_URL"] {
212            cmd.env_remove(k);
213        }
214        cmd.envs(env);
215        match log.and_then(|f| Ok((f.try_clone()?, f))) {
216            Ok((out, err)) => {
217                cmd.stdout(out).stderr(err);
218            }
219            Err(_) => {
220                cmd.stdout(Stdio::null()).stderr(Stdio::null());
221            }
222        }
223        match cmd.spawn() {
224            Ok(child) => {
225                tracing::info!(nick = %slot.adoption.nick, provider = %slot.adoption.provider, pid = child.id(), "m4a letter listener started");
226                slot.child = Some(child);
227                slot.backoff = Duration::from_secs(5);
228            }
229            Err(e) => {
230                tracing::warn!(nick = %slot.adoption.nick, error = %e.kind(), "m4a letter listener spawn failed");
231                slot.next_start = Instant::now() + slot.backoff;
232                slot.backoff = (slot.backoff * 2).min(Duration::from_secs(300));
233            }
234        }
235    }
236}
237
238/// The prompt a woken session gets: headers, how to answer, then the letter verbatim.
239pub fn letter_prompt(nick: &str, letter: &serde_json::Value) -> String {
240    let s = |k: &str| letter.get(k).and_then(|v| v.as_str()).unwrap_or("").to_owned();
241    let mut head = format!("[m4a letter]\nkind: {}\nfrom: {}\nto: {nick}\n", if s("kind").is_empty() { "message".into() } else { s("kind") }, s("from"));
242    if !s("room").is_empty() {
243        head.push_str(&format!("room: {}\n", s("room")));
244    }
245    head.push_str(&format!("event: {}\n", s("event_id")));
246    if letter.get("reply_needed").and_then(|v| v.as_bool()).unwrap_or(true) {
247        head.push_str(&format!("reply: your m4a MCP tool `send` with to = \"{}\"\n", s("from")));
248    }
249    format!("{head}---\n{}", s("body"))
250}
251
252/// Hook / pusher entry. `None`: this process is the ordinary node. `Some(code)`: exit with it.
253pub fn hook_main() -> Option<i32> {
254    if std::env::var(PUSH_ENV).is_ok_and(|v| v == "1") {
255        return Some(push_main());
256    }
257    if !std::env::var(HOOK_ENV).is_ok_and(|v| v == "1") {
258        return None;
259    }
260    use std::io::{Read, Write};
261    let mut letter = Vec::new();
262    if std::io::stdin().take(1 << 20).read_to_end(&mut letter).is_err() {
263        return Some(2);
264    }
265    let Ok(v) = serde_json::from_slice::<serde_json::Value>(&letter) else { return Some(2) };
266    let nick = std::env::var(WAKE_NICK_ENV).ok().or_else(|| v.get("nick").and_then(|n| n.as_str()).map(str::to_owned)).unwrap_or_default();
267    let prompt = letter_prompt(&nick, &v);
268    let Ok(exe) = std::env::current_exe() else { return Some(2) };
269    // Detached pusher: the turn can take minutes; the hook must answer within seconds.
270    let mut cmd = Command::new(exe);
271    cmd.env_remove(HOOK_ENV).env(PUSH_ENV, "1").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null());
272    #[cfg(windows)]
273    {
274        use std::os::windows::process::CommandExt;
275        const DETACHED_PROCESS: u32 = 0x0000_0008;
276        const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
277        cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
278    }
279    let Ok(mut child) = cmd.spawn() else { return Some(3) };
280    if let Some(mut stdin) = child.stdin.take() {
281        if stdin.write_all(prompt.as_bytes()).is_err() {
282            return Some(3);
283        }
284    }
285    Some(0)
286}
287
288fn push_main() -> i32 {
289    use std::io::Read;
290    let mut prompt = String::new();
291    if std::io::stdin().take(1 << 20).read_to_string(&mut prompt).is_err() || prompt.trim().is_empty() {
292        return 2;
293    }
294    let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
295    let (Some(provider), Some(session), Some(cwd), Some(leader)) = (get(WAKE_PROVIDER_ENV), get(WAKE_SESSION_ENV), get(WAKE_CWD_ENV), get(WAKE_LEADER_ENV)) else {
296        return 2;
297    };
298    if provider != "grok" {
299        return 4;
300    }
301    let Ok(rt) = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build() else { return 5 };
302    match rt.block_on(gate4agent_wake::grok_leader::push_into_session(Path::new(&leader), &session, &cwd, &prompt)) {
303        Ok(()) => 0,
304        Err(_) => 1,
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn adoption(session: &str) -> Adoption {
313        Adoption { provider: "grok".into(), session: session.into(), cwd: Some("/w".into()), nick: "grok-1".into(), account_ref: "grok-ab".into(), store_root: Some("/s/grok-ab".into()) }
314    }
315
316    #[test]
317    fn adoptions_persist_replace_and_resolve_a_sole_session() {
318        let dir = std::env::temp_dir().join(format!("m4a-wake-{}", std::process::id()));
319        let _ = std::fs::remove_dir_all(&dir);
320        std::fs::create_dir_all(&dir).unwrap();
321        assert_eq!(sole_adoption(&dir, "grok"), None);
322        save_adoption(&dir, &adoption("s-1")).unwrap();
323        save_adoption(&dir, &adoption("s-1")).unwrap();
324        assert_eq!(load_adoptions(&dir).len(), 1);
325        assert_eq!(sole_adoption(&dir, "grok").as_deref(), Some("s-1"));
326        assert_eq!(sole_adoption(&dir, "kimi"), None);
327        save_adoption(&dir, &adoption("s-2")).unwrap();
328        assert_eq!(sole_adoption(&dir, "grok"), None, "two adoptions are ambiguous");
329        std::fs::remove_dir_all(&dir).unwrap();
330    }
331
332    #[test]
333    fn listener_env_carries_no_secret_and_points_the_hook_at_the_session() {
334        let env = listener_env(&adoption("s-1"), "https://example.invalid", Path::new("/g/leader.sock"), Path::new("/bin/node")).unwrap();
335        let m: BTreeMap<_, _> = env.into_iter().collect();
336        assert!(!m.contains_key("M4A_SESSION_CAP"));
337        assert_eq!(m["M4A_SESSION_ID"], "grok-ab");
338        assert_eq!(m[WAKE_SESSION_ENV], "s-1");
339        assert_eq!(m[WAKE_CWD_ENV], "/w");
340        assert_eq!(m["M4A_LETTER_HOOK"], "/bin/node");
341        assert!(m["M4A_SEND_SOCK"].ends_with(SEND_SOCK_NAME));
342        let mut no_cwd = adoption("s-1");
343        no_cwd.cwd = None;
344        assert!(listener_env(&no_cwd, "u", Path::new("l"), Path::new("h")).is_err());
345    }
346
347    #[test]
348    fn prompt_names_sender_and_reply_tool() {
349        let p = letter_prompt("grok-1", &serde_json::json!({"from":"web","event_id":"$e","room":"!r","body":"hi"}));
350        assert!(p.contains("from: web") && p.contains("to: grok-1") && p.contains("to = \"web\"") && p.ends_with("---\nhi"));
351    }
352
353    #[test]
354    fn grok_cwd_comes_from_the_index() {
355        let dir = std::env::temp_dir().join(format!("m4a-wake-idx-{}", std::process::id()));
356        std::fs::create_dir_all(&dir).unwrap();
357        std::fs::write(dir.join("active_sessions.json"), r#"[{"session_id":"a","cwd":"C:\\w"},{"session_id":"b","cwd":""}]"#).unwrap();
358        assert_eq!(grok_session_cwd(&dir, "a").as_deref(), Some("C:\\w"));
359        assert_eq!(grok_session_cwd(&dir, "b"), None);
360        std::fs::remove_dir_all(&dir).unwrap();
361    }
362}