Skip to main content

hatchery_node/server/
node_wake.rs

1//! Letter -> node -> g4a wake -> session turn.
2//!
3//! m4a only hands a letter over (its `M4A_LETTER_HOOK`); the node knows which of its sessions
4//! owns the addressed nick (it assigned that account at spawn) and asks g4a
5//! (`gate4agent_wake::hosted`) what to do. A live session gets the framed prompt as its next
6//! turn; the node's policy ([`crate::wake_pipeline`]) gates everything else: typing into a raw
7//! terminal needs an explicit terminal grant, a nick bound to a routine gets one routine POST via
8//! g4a, and anything else is refused (m4a keeps the letter in its durable inbox).
9//! Callers present the node's wake token (a 0600 file next to the accounts root), so another
10//! local user cannot type into a session through the door.
11
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use gate4agent_wake::hosted::{plan_hosted_wake, HostedSessions, HostedTarget, HostedWake};
16use gate4agent_wake::{ProviderKind, WakeLetter};
17
18use crate::wake_pipeline::{deliver_routine, launch_spawn_profile, route, Route, RoutineLetter, SessionShape};
19
20use super::*;
21
22/// One letter for a session of this node.
23pub struct NodeLetter {
24    pub nick: String,
25    pub from: String,
26    pub event_id: String,
27    pub body: String,
28    /// The node spawned this session itself: a raw terminal gets the short wake line typed by
29    /// the node (no terminal grant needed; the line carries no letter text).
30    pub own_spawn: bool,
31}
32
33/// A letter for a busy live session is retried this often, at most this many times (~30 min).
34const BUSY_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
35const BUSY_RETRY_MAX: u32 = 180;
36
37#[derive(Clone)]
38pub struct NodeWake {
39    shared: Arc<NodeShared>,
40    token: Arc<str>,
41    policy: Arc<crate::wake_pipeline::WakePolicy>,
42}
43
44struct Snapshot(BTreeMap<String, HostedTarget>);
45
46impl HostedSessions for Snapshot {
47    fn lookup(&self, nick: &str) -> Option<HostedTarget> {
48        self.0.get(nick).cloned()
49    }
50}
51
52fn kind_of(agent: &AgentId) -> Option<ProviderKind> {
53    ProviderKind::parse(crate::m4a_accounts::m4a_provider(agent.as_str())?)
54}
55
56impl NodeWake {
57    pub(super) fn new(shared: Arc<NodeShared>, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Self {
58        Self { shared, token: token.into(), policy }
59    }
60
61    /// Constant-time check of the caller's token.
62    pub fn authorized(&self, token: &str) -> bool {
63        let (a, b) = (self.token.as_bytes(), token.as_bytes());
64        a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
65    }
66
67    fn snapshot(&self) -> (Snapshot, BTreeMap<String, SessionAddress>) {
68        let live = self.shared.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).clone();
69        let bindings = self.shared.session_bindings.lock().unwrap_or_else(|p| p.into_inner());
70        let mut targets = BTreeMap::new();
71        let mut addresses = BTreeMap::new();
72        for (nick, (instance_id, agent)) in live {
73            let Some(provider) = kind_of(&agent) else { continue };
74            let binding = bindings.get(&instance_id);
75            if let Some(b) = binding {
76                addresses.insert(
77                    nick.clone(),
78                    SessionAddress { workspace_id: b.workspace_id.clone(), session: SessionKey { instance_id, generation: b.generation } },
79                );
80            }
81            let spawn_allowed = self.policy.spawn_profile(&nick).is_some();
82            targets.insert(nick.clone(), HostedTarget { nick, provider, live: binding.is_some(), spawn_allowed });
83        }
84        (Snapshot(targets), addresses)
85    }
86
87    async fn resume_with_wake(&self, nick: &str, address: &SessionAddress, prompt: String) -> Result<&'static str, String> {
88        let record_id = self
89            .shared
90            .session_bindings
91            .lock()
92            .unwrap_or_else(|p| p.into_inner())
93            .get(&address.session.instance_id)
94            .and_then(|b| b.record_id.clone())
95            .ok_or("exited session has no record to resume")?;
96        let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
97        let prompt_retry = prompt.clone();
98        match self.shared.resume_session_record(&record_id, size, Some(prompt)).await {
99            Ok(_) => {
100                tracing::info!(%nick, "letter resumed an exited session");
101                Ok("resumed")
102            }
103            Err(e) => {
104                tracing::warn!(%nick, code = ?e.code, "exited session not resumed");
105                // The record is still held (SessionRecordBusy: the old process is going away or a
106                // live session owns it): try the resume again later instead of losing the letter.
107                if format!("{:?}", e.code).contains("SessionRecordBusy") {
108                    self.queue_resume(nick.to_owned(), record_id, prompt_retry);
109                    return Ok("queued");
110                }
111                Err("exited session could not be resumed".into())
112            }
113        }
114    }
115
116    /// Retries resuming a held record with the wake prompt, every [`BUSY_RETRY`] up to
117    /// [`BUSY_RETRY_MAX`] tries.
118    fn queue_resume(&self, nick: String, record_id: super::SessionRecordId, prompt: String) {
119        tracing::info!(%nick, "letter queued: session record busy, resume retried later");
120        let this = self.clone();
121        tokio::spawn(async move {
122            for attempt in 1..=BUSY_RETRY_MAX {
123                tokio::time::sleep(BUSY_RETRY).await;
124                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
125                match this.shared.resume_session_record(&record_id, size, Some(prompt.clone())).await {
126                    Ok(_) => {
127                        tracing::info!(%nick, attempt, "queued letter resumed an exited session");
128                        return;
129                    }
130                    Err(e) if format!("{:?}", e.code).contains("SessionRecordBusy") => continue,
131                    Err(e) => {
132                        tracing::warn!(%nick, attempt, code = ?e.code, "queued resume failed (letter stays in the mailbox)");
133                        return;
134                    }
135                }
136            }
137            tracing::warn!(%nick, "queued resume not done in time (letter stays in the mailbox)");
138        });
139    }
140
141    /// Retries a semantic prompt into a live session that is busy with a turn, every
142    /// [`BUSY_RETRY`] up to [`BUSY_RETRY_MAX`] tries; stops when the session goes away.
143    fn queue_for_busy(&self, nick: String, address: SessionAddress, agent: AgentId, prompt: String) {
144        tracing::info!(%nick, "letter queued for a busy session");
145        let this = self.clone();
146        tokio::spawn(async move {
147            for attempt in 1..=BUSY_RETRY_MAX {
148                tokio::time::sleep(BUSY_RETRY).await;
149                if this.shared.validate_address(&address).is_err() {
150                    tracing::warn!(%nick, attempt, "queued letter dropped: session ended (letter stays in the mailbox)");
151                    return;
152                }
153                let action = InputAction::SubmitPrompt(PromptPayload { text: prompt.clone(), framing: prompt_framing(&agent) });
154                if this.shared.dispatch_input_bounded(&address, action).await.is_ok() {
155                    tracing::info!(%nick, attempt, "queued letter woke a live session");
156                    return;
157                }
158            }
159            tracing::warn!(%nick, "queued letter not delivered in time (letter stays in the mailbox)");
160        });
161    }
162
163    /// Wake signal for a session this node spawned (from its mailbox listener): no letter text.
164    pub async fn wake_spawned(&self, nick: String, from: String, event_id: String, body: String) -> Result<&'static str, String> {
165        self.wake(NodeLetter { nick, from, event_id, body, own_spawn: true }).await
166    }
167
168    /// Wakes the session that owns `letter.nick`. `Ok` names what happened; `Err` is safe to
169    /// log (it never carries the letter text).
170    pub async fn wake(&self, letter: NodeLetter) -> Result<&'static str, String> {
171        let (nick, from) = (letter.nick.clone(), letter.from.clone());
172        let r = self.wake_letter(letter).await;
173        if let Err(reason) = &r {
174            // The letter stays in the mailbox; the operator hears about it instead of nobody.
175            let (_, addresses) = self.snapshot();
176            if let Some(a) = addresses.get(&nick) {
177                let id = a.session.instance_id.0;
178                let provider = crate::auto_permit::acp_provider(id).unwrap_or("session");
179                crate::session_signals::emit(a.workspace_id.as_str(), id, provider, crate::session_signals::Signal::LetterStuck { hint: letter_stuck_hint(&nick, &from, reason) });
180            }
181        }
182        r
183    }
184
185    async fn wake_letter(&self, letter: NodeLetter) -> Result<&'static str, String> {
186        let (sessions, addresses) = self.snapshot();
187        let wake = WakeLetter { body: &letter.body, from_nick: &letter.from, event_id: &letter.event_id, room: None, reaction: None };
188        match plan_hosted_wake(&sessions, &letter.nick, &wake) {
189            HostedWake::Submit { nick, prompt } => {
190                let prompt = if self.policy.deliver_body { prompt } else { crate::wake_pipeline::wake_line(&letter.from, &letter.event_id) };
191                let address = addresses.get(&nick).cloned().ok_or("session vanished")?;
192                let wake_line = crate::wake_pipeline::wake_line(&letter.from, &letter.event_id);
193                let agent = match self.shared.validate_address(&address) {
194                    Ok(agent) => agent,
195                    Err(_) if letter.own_spawn => return self.resume_with_wake(&nick, &address, wake_line).await,
196                    Err(_) => return Err("session is not live".to_owned()),
197                };
198                let refused = |e: NodeFailure| {
199                    tracing::warn!(nick = %nick, code = ?e.code, "letter wake input refused");
200                    "session did not take the turn".to_owned()
201                };
202                // An ACP session takes prompts only (typing into it fails with "Acp does not
203                // support this input action"), even while its runtime is busy or limited and the
204                // semantic-prompt policy check does not pass.
205                let acp = crate::auto_permit::acp_provider(address.session.instance_id.0).is_some();
206                let shape = if acp || self.shared.require_session_runtime_policy(&address, ProviderRuntimeRequirement::SemanticPrompt).is_ok() {
207                    SessionShape::Semantic
208                } else {
209                    SessionShape::RawTerminal
210                };
211                let routed = match route(&self.policy, &nick, shape) {
212                    Route::Refuse(_) if letter.own_spawn && shape == SessionShape::RawTerminal => Route::TerminalTyping,
213                    // A session this node spawned in ACP takes the wake as a prompt turn.
214                    Route::Refuse(_) if letter.own_spawn && shape == SessionShape::Semantic => Route::Deliver,
215                    other => other,
216                };
217                // A spawned semantic session gets the letter itself (the CLI/ACP analogue of the app's chat
218                // widget); a raw terminal keeps the one-line notice (typed text must stay one line).
219                let prompt = match (letter.own_spawn, shape) {
220                    (true, SessionShape::Semantic) => crate::wake_pipeline::letter_prompt(&letter.from, &letter.event_id, &letter.body),
221                    (true, _) => crate::wake_pipeline::wake_line(&letter.from, &letter.event_id),
222                    _ => prompt,
223                };
224                match routed {
225                    Route::Deliver => {
226                        let queued = prompt.clone();
227                        let action = InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: prompt_framing(&agent) });
228                        match self.shared.dispatch_input_bounded(&address, action).await {
229                            // A session this node spawned whose process exited is resumed with
230                            // the wake as its first prompt (same record, same m4a account).
231                            Err(_) if letter.own_spawn && self.shared.validate_address(&address).is_err() => {
232                                return self.resume_with_wake(&nick, &address, wake_line).await
233                            }
234                            // Still live but did not take the prompt (mid-turn): keep the letter
235                            // and hand it over once the session is free, instead of resuming a
236                            // record the live session holds (SessionRecordBusy, letter lost).
237                            Err(_) if letter.own_spawn && shape == SessionShape::Semantic => {
238                                self.queue_for_busy(nick.clone(), address.clone(), agent.clone(), queued);
239                                return Ok("queued");
240                            }
241                            other => other.map_err(refused)?,
242                        };
243                    }
244                    Route::TerminalTyping => {
245                        // Terminal capability, granted explicitly for this nick: type the prompt
246                        // as one line, then Enter.
247                        let text = prompt.replace(['\n', '\r'], " ");
248                        match self.shared.dispatch_input_bounded(&address, InputAction::TerminalText(TerminalText { text })).await {
249                            // A session this node spawned whose process exited is resumed with
250                            // the wake as its first prompt (same record, same m4a account).
251                            Err(_) if letter.own_spawn && self.shared.validate_address(&address).is_err() => {
252                                return self.resume_with_wake(&nick, &address, wake_line).await
253                            }
254                            other => other.map_err(refused)?,
255                        };
256                        // TUIs (kimi) treat a fast Enter right after the text as part of a paste.
257                        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
258                        self.shared.dispatch_input_bounded(&address, InputAction::TerminalControl(TerminalControl::ControlM)).await.map_err(refused)?;
259                    }
260                    Route::Refuse(why) => {
261                        tracing::info!(nick = %nick, "letter not typed: no terminal grant");
262                        return Err(why);
263                    }
264                    Route::Routine(_) => return Err("unexpected routine route for a live session".into()),
265                    Route::Spawn(_) => return Err("unexpected spawn route for a live session".into()),
266                }
267                tracing::info!(nick = %nick, "letter woke a live session");
268                Ok("submitted")
269            }
270            HostedWake::Spawn { nick, .. } => match self.policy.spawn_profile(&nick).cloned() {
271                Some(profile) => {
272                    tokio::task::spawn_blocking(move || launch_spawn_profile(&profile))
273                        .await
274                        .map_err(|_| "spawn profile task failed".to_owned())?
275                        .map_err(|e| e)?;
276                    tracing::info!(nick = %nick, "letter woke an offline session via spawn profile");
277                    Ok("spawned")
278                }
279                None => Err("spawn on letter is not enabled on this node".into()),
280            },
281            HostedWake::Refuse(why) => match route(&self.policy, &letter.nick, SessionShape::None) {
282                Route::Spawn(profile) => {
283                    let nick = letter.nick.clone();
284                    tokio::task::spawn_blocking(move || launch_spawn_profile(&profile))
285                        .await
286                        .map_err(|_| "spawn profile task failed".to_owned())?
287                        .map_err(|e| e)?;
288                    tracing::info!(nick = %nick, "letter woke an offline session via spawn profile");
289                    Ok("spawned")
290                }
291                Route::Routine(target) => {
292                    let routine_letter = RoutineLetter {
293                        to: letter.nick.clone(),
294                        from: letter.from.clone(),
295                        event_id: letter.event_id.clone(),
296                        subject: None,
297                        room: None,
298                        body: letter.body.clone(),
299                        reply: None,
300                        note: None,
301                    };
302                    let nick = letter.nick.clone();
303                    match tokio::task::spawn_blocking(move || deliver_routine(&target, &routine_letter)).await {
304                        Ok(Ok(_)) => {
305                            tracing::info!(nick = %nick, "letter delivered to a routine");
306                            Ok("routine")
307                        }
308                        Ok(Err(e)) => Err(e),
309                        Err(_) => Err("routine delivery task failed".into()),
310                    }
311                }
312                _ => Err(why),
313            },
314        }
315    }
316}
317
318/// `nick=<n> from=<f>: <reason>` -- short, control characters dropped, no letter text.
319pub fn letter_stuck_hint(nick: &str, from: &str, reason: &str) -> String {
320    format!("nick={nick} from={from}: {reason}").chars().filter(|c| !c.is_control()).take(160).collect()
321}
322
323#[cfg(test)]
324mod letter_stuck_tests {
325    #[test]
326    fn hint_names_the_session_and_sender_only() {
327        let h = super::letter_stuck_hint("codex-21", "hatcheryweb", "exited session could not be resumed\n");
328        assert_eq!(h, "nick=codex-21 from=hatcheryweb: exited session could not be resumed");
329    }
330}