1use 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
22pub struct NodeLetter {
24 pub nick: String,
25 pub from: String,
26 pub event_id: String,
27 pub body: String,
28 pub own_spawn: bool,
31}
32
33const 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 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 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 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 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 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 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 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 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 Route::Refuse(_) if letter.own_spawn && shape == SessionShape::Semantic => Route::Deliver,
215 other => other,
216 };
217 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 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 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 let text = prompt.replace(['\n', '\r'], " ");
248 match self.shared.dispatch_input_bounded(&address, InputAction::TerminalText(TerminalText { text })).await {
249 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 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
318pub 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}