1use std::collections::HashMap;
29use std::sync::{Arc, LazyLock, Mutex, PoisonError};
30use std::time::Duration;
31
32use regex::Regex;
33use talon_container::{RunOutcome, RunSpec};
34use talon_core::protocol::{ContainerInput, ContainerOutput, OutputStatus};
35use talon_core::provider::{effective_vllm, merge_container_config, provider_fingerprint};
36use talon_core::types::{AvailableGroup, RegisteredGroup, ScheduledTask};
37use talon_router::AgentOutputHandler;
38use talon_scheduler::RunningContainer;
39use tokio::sync::mpsc;
40use tokio::time::Instant;
41
42use super::Orchestrator;
43use crate::agent::{AgentOutcome, StartHook};
44use crate::output_limits::{
45 Admission, OUTPUT_CHANNEL_CAPACITY, OutputGuard, cap_text, sanitize_session_id,
46};
47
48const ABORT_STOP_TIMEOUT: Duration = Duration::from_secs(30);
51
52type ContainerSlot = Arc<Mutex<Option<Arc<dyn RunningContainer>>>>;
54
55pub const VLLM_NOT_CONFIGURED: &str = "vLLM provider is not configured: set TALAHON_VLLM_BASE_URL and \
57 TALAHON_VLLM_MODEL in .env (run `talahon doctor`)";
58pub const GATEWAY_NOT_RUNNING: &str =
60 "LLM gateway is not running, so containers cannot reach vLLM (run `talahon doctor`)";
61
62static STALE_SESSION: LazyLock<Regex> = LazyLock::new(|| {
65 #[allow(clippy::expect_used)]
67 Regex::new(r"(?i)no conversation found|ENOENT.*\.jsonl|session.*not found")
68 .expect("static stale-session regex")
69});
70
71pub fn is_stale_session_error(error: &str) -> bool {
73 STALE_SESSION.is_match(error)
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct RunOptions {
79 pub scheduled_task: bool,
81 pub script: Option<String>,
83 pub resume_session: bool,
85 pub manage_session: bool,
88}
89
90impl RunOptions {
91 pub fn message() -> Self {
93 Self {
94 scheduled_task: false,
95 script: None,
96 resume_session: true,
97 manage_session: true,
98 }
99 }
100
101 pub fn task(script: Option<String>, resume_group_session: bool) -> Self {
103 Self {
104 scheduled_task: true,
105 script: script.filter(|s| !s.trim().is_empty()),
106 resume_session: resume_group_session,
107 manage_session: false,
108 }
109 }
110}
111
112impl Orchestrator {
113 pub fn available_groups(&self) -> Vec<AvailableGroup> {
115 let groups = self.groups_snapshot();
116 match self.db.get_all_chats() {
117 Ok(chats) => talon_router::available_groups(&chats, &groups),
118 Err(e) => {
119 tracing::warn!(error = %e, "cannot list chats for available_groups");
120 Vec::new()
121 }
122 }
123 }
124
125 pub(crate) fn snapshot_tasks(&self, query: &TaskQuery) -> Vec<ScheduledTask> {
129 let loaded = match query {
130 TaskQuery::All => self.db.get_all_tasks(),
131 TaskQuery::ForJids(jids) => self.db.get_tasks_for_jids(jids),
132 };
133 match loaded {
134 Ok(tasks) => tasks,
135 Err(e) => {
136 tracing::warn!(error = %e, "cannot list tasks for snapshot");
137 Vec::new()
138 }
139 }
140 }
141
142 pub(crate) fn write_lane_tasks_snapshot_for(
148 &self,
149 ipc_key: &str,
150 groups: &HashMap<String, RegisteredGroup>,
151 extra_jid: Option<&str>,
152 ) {
153 let (mut jids, is_main) = lane_namespace(groups, ipc_key);
155 if let Some(jid) = extra_jid
156 && !jids.iter().any(|j| j == jid)
157 {
158 jids.push(jid.to_string());
159 }
160 let tasks = self.snapshot_tasks(&snapshot_task_query(is_main, &jids));
161 if let Err(e) = talon_container::write_lane_tasks_snapshot(
162 &self.config.data_dir,
163 ipc_key,
164 is_main,
165 &jids,
166 &tasks,
167 ) {
168 tracing::warn!(ipc_key, error = %e, "failed to write tasks snapshot");
169 }
170 }
171
172 fn write_snapshots(&self, group: &RegisteredGroup, chat_jid: &str) {
174 let groups = self.groups_snapshot();
175 let extra = (groups.get(chat_jid).map(RegisteredGroup::ipc_key) != Some(group.ipc_key()))
176 .then_some(chat_jid);
177 self.write_lane_tasks_snapshot_for(group.ipc_key(), &groups, extra);
178 let available = self.available_groups();
179 if let Err(e) = talon_container::write_groups_snapshot(
180 &self.config.data_dir,
181 &group.folder,
182 group.is_main,
183 &available,
184 Some(group.ipc_key()),
185 ) {
186 tracing::warn!(group = %group.name, error = %e, "failed to write groups snapshot");
187 }
188 }
189
190 async fn abort_run(
193 &self,
194 group: &RegisteredGroup,
195 chat_jid: &str,
196 slot: &ContainerSlot,
197 reason: &str,
198 guard: &OutputGuard,
199 ) {
200 tracing::error!(
201 group = %group.name,
202 chat_jid,
203 frames = guard.frames(),
204 result_bytes = guard.result_bytes(),
205 %reason,
206 "agent output limit exceeded; stopping the container"
207 );
208 self.audit.record(talon_core::audit::event(
209 "container.output_limit",
210 &group.folder,
211 Some(chat_jid),
212 "warn",
213 serde_json::json!({
214 "reason": reason,
215 "frames": guard.frames(),
216 "resultBytes": guard.result_bytes(),
217 }),
218 ));
219 let container = slot.lock().unwrap_or_else(PoisonError::into_inner).take();
220 match container {
221 Some(container) => {
222 match tokio::time::timeout(ABORT_STOP_TIMEOUT, container.stop()).await {
223 Ok(Ok(())) => {}
224 Ok(Err(e)) => {
225 tracing::warn!(container = container.name(), error = %e, "cannot stop container")
226 }
227 Err(_) => {
228 tracing::warn!(container = container.name(), "container stop timed out")
229 }
230 }
231 }
232 None => tracing::warn!(chat_jid, "aborted run has no registered container"),
233 }
234 }
235
236 fn resolve_session(
238 &self,
239 group: &RegisteredGroup,
240 fingerprint: &str,
241 opts: &RunOptions,
242 ) -> Option<String> {
243 if !opts.resume_session || !group.should_persist_session() {
244 return None;
245 }
246 let key = group.session_key();
247 let session_id = self.session_for_key(key)?;
248 match self.db.get_session_provider(key) {
249 Ok(Some(stored)) if stored != fingerprint => {
250 tracing::warn!(
251 group = %group.name,
252 session_provider = %stored,
253 provider = %fingerprint,
254 "Provider changed since session was created — starting a fresh session"
255 );
256 if opts.manage_session {
257 self.clear_session_for(group);
258 }
259 None
260 }
261 Ok(_) => Some(session_id),
262 Err(e) => {
263 tracing::warn!(group = %group.name, error = %e, "cannot read session provider; starting fresh");
264 None
265 }
266 }
267 }
268
269 pub async fn run_agent(
272 &self,
273 group: &RegisteredGroup,
274 prompt: &str,
275 chat_jid: &str,
276 opts: RunOptions,
277 sink: &mut dyn AgentOutputHandler,
278 ) -> AgentOutcome {
279 let merged = merge_container_config(
280 &self.config.provider_defaults,
281 group.container_config.as_ref(),
282 );
283 let Some(vllm) = effective_vllm(&merged).cloned() else {
284 tracing::error!(group = %group.name, "{VLLM_NOT_CONFIGURED}");
285 return AgentOutcome::not_started(VLLM_NOT_CONFIGURED);
286 };
287 let Some(llm) = self.llm.clone() else {
288 tracing::error!(group = %group.name, "{GATEWAY_NOT_RUNNING}");
289 return AgentOutcome::not_started(GATEWAY_NOT_RUNNING);
290 };
291 let fingerprint = provider_fingerprint(&merged);
292 let session_id = self.resolve_session(group, &fingerprint, &opts);
293 let session_epoch = self.session_epoch(group.session_key());
294
295 self.write_snapshots(group, chat_jid);
296 match talon_scheduler::ipc_input::clear_inbox(&self.config.data_dir, group.ipc_key()) {
300 Ok(0) => {}
301 Ok(n) => tracing::info!(group = %group.name, removed = n, "cleared leftover IPC input"),
302 Err(e) => tracing::warn!(group = %group.name, error = %e, "cannot clear the IPC inbox"),
303 }
304
305 let ttl = self
306 .launcher
307 .hard_timeout(&merged)
308 .saturating_add(self.config.idle_timeout)
309 .saturating_add(self.settings.token_ttl_margin);
310 let (token, endpoint) = llm.issue(group.ipc_key(), ttl, &vllm);
314 let input = ContainerInput {
315 prompt: prompt.to_string(),
316 session_id: session_id.clone(),
317 group_folder: group.folder.clone(),
318 chat_jid: chat_jid.to_string(),
319 is_main: group.is_main,
320 is_scheduled_task: opts.scheduled_task,
321 assistant_name: Some(self.config.assistant_name.clone()),
322 script: opts.script.clone(),
323 llm: Some(endpoint),
324 ephemeral: group.ephemeral_session,
325 timezone: Some(self.config.timezone_name.clone()),
326 };
327 let spec = RunSpec::new(group.clone(), input).with_config(merged);
328
329 if !opts.scheduled_task {
330 let mut st = self.lock();
331 st.token_deadlines
332 .insert(chat_jid.to_string(), Instant::now() + ttl);
333 st.run_tokens
334 .insert(chat_jid.to_string(), (token.clone(), ttl));
335 }
336 let slot: ContainerSlot = Arc::new(Mutex::new(None));
337 let hook: StartHook = {
338 let queue = self.queue.clone();
339 let jid = chat_jid.to_string();
340 let ipc_key = group.ipc_key().to_string();
341 let slot = slot.clone();
342 Box::new(move |container| {
343 *slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(container.clone());
344 queue.register_process(&jid, container, &ipc_key);
345 })
346 };
347
348 let limits = self.settings.output_limits;
349 let (tx, rx) = mpsc::channel(OUTPUT_CHANNEL_CAPACITY);
350 let launch = self.launcher.launch(spec, tx, hook);
351 let consume = async {
352 let mut rx = rx;
353 let mut guard = OutputGuard::new(limits);
354 let mut last_result: Option<String> = None;
355 let mut frame_error: Option<String> = None;
356 let mut aborted: Option<String> = None;
357 while let Some(output) = rx.recv().await {
358 let output = match guard.admit(output) {
359 Admission::Deliver(output) => output,
360 Admission::Abort(reason) => {
361 aborted = Some(reason);
362 break;
363 }
364 };
365 if opts.manage_session
366 && let Some(id) = output.new_session_id.as_deref().filter(|s| !s.is_empty())
367 {
368 self.persist_run_session(
369 group,
370 id,
371 &fingerprint,
372 output.usage.as_ref(),
373 session_epoch,
374 );
375 }
376 if let Some(result) = output.result.as_ref().filter(|r| !r.is_empty()) {
377 last_result = Some(result.clone());
378 }
379 if output.status == OutputStatus::Error {
380 frame_error.clone_from(&output.error);
381 }
382 sink.on_output(&output).await;
383 }
384 drop(rx);
387 if let Some(reason) = &aborted {
388 self.abort_run(group, chat_jid, &slot, reason, &guard).await;
389 sink.on_output(&ContainerOutput::error(reason.clone()))
390 .await;
391 }
392 (last_result, frame_error, aborted)
393 };
394 let (mut result, (streamed_result, frame_error, aborted)) = tokio::join!(launch, consume);
395 if let Some(reason) = aborted {
396 result.status = OutputStatus::Error;
397 result.error = Some(reason);
398 }
399 result.new_session_id = sanitize_session_id(result.new_session_id.take());
402 result.error = result
403 .error
404 .as_deref()
405 .map(|e| cap_text(e, limits.max_error_bytes));
406 result.last_result = result
407 .last_result
408 .as_deref()
409 .map(|r| cap_text(r, limits.max_result_bytes));
410
411 llm.revoke(&token);
412 if !opts.scheduled_task {
413 let mut st = self.lock();
414 st.token_deadlines.remove(chat_jid);
415 st.run_tokens.remove(chat_jid);
416 }
417
418 if opts.manage_session {
419 if let Some(id) = result
422 .new_session_id
423 .as_deref()
424 .filter(|id| self.session_for_key(group.session_key()).as_deref() != Some(*id))
425 {
426 self.persist_run_session(
427 group,
428 id,
429 &fingerprint,
430 result.usage.as_ref(),
431 session_epoch,
432 );
433 }
434 }
435
436 if result.status == OutputStatus::Error {
437 let error = result
438 .error
439 .clone()
440 .unwrap_or_else(|| "Unknown error".to_string());
441 let stale = is_stale_session_error(&error)
445 || frame_error.as_deref().is_some_and(is_stale_session_error);
446 if opts.manage_session && session_id.is_some() && stale {
447 tracing::warn!(
448 group = %group.name,
449 error = %error,
450 "Stale session detected — clearing for next retry"
451 );
452 self.clear_session_for(group);
453 }
454 tracing::error!(group = %group.name, error = %error, "Container agent error");
455 }
456
457 AgentOutcome {
458 status: result.status,
459 error: result.error.clone(),
460 last_result: streamed_result.or_else(|| result.last_result.clone()),
461 new_session_id: result.new_session_id.clone(),
462 usage: result.usage,
463 launched: !matches!(
464 result.outcome,
465 RunOutcome::SetupFailed | RunOutcome::SpawnFailed
466 ),
467 }
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
473pub enum TaskQuery {
474 All,
476 ForJids(Vec<String>),
479}
480
481pub fn snapshot_task_query(is_main: bool, jids: &[String]) -> TaskQuery {
486 if is_main {
487 TaskQuery::All
488 } else {
489 TaskQuery::ForJids(jids.to_vec())
490 }
491}
492
493pub fn lane_namespace(
498 groups: &HashMap<String, RegisteredGroup>,
499 ipc_key: &str,
500) -> (Vec<String>, bool) {
501 let mut jids: Vec<String> = Vec::new();
502 let mut all_main = true;
503 for (jid, group) in groups {
504 if group.ipc_key() == ipc_key {
505 all_main &= group.is_main;
506 jids.push(jid.clone());
507 }
508 }
509 jids.sort();
510 let is_main = all_main && !jids.is_empty();
511 (jids, is_main)
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn stale_session_patterns_match_ts() {
520 assert!(is_stale_session_error(
521 "No conversation found with session ID abc"
522 ));
523 assert!(is_stale_session_error(
524 "ENOENT: no such file or directory, open '/home/agent/.talon/sessions/x.jsonl'"
525 ));
526 assert!(is_stale_session_error("session 1234 not found"));
527 assert!(is_stale_session_error("Session Not Found"));
528 assert!(!is_stale_session_error("Container exited with code 1"));
529 assert!(!is_stale_session_error("not found: model"));
530 }
531
532 fn lane(folder: &str, ipc_key: Option<&str>, is_main: bool) -> RegisteredGroup {
533 RegisteredGroup {
534 name: folder.into(),
535 folder: folder.into(),
536 trigger: "@Talahon".into(),
537 added_at: "2026-01-01T00:00:00.000Z".into(),
538 container_config: None,
539 requires_trigger: None,
540 is_main,
541 session_key: None,
542 ipc_key: ipc_key.map(str::to_string),
543 ephemeral_session: false,
544 trusted_session_commands: false,
545 }
546 }
547
548 #[test]
549 fn lane_jids_are_the_registered_jids_of_one_namespace() {
550 let mut groups = HashMap::new();
551 groups.insert("tg:main".to_string(), lane("main", None, true));
552 groups.insert("http:copilot".to_string(), lane("copilot", None, false));
553 groups.insert(
554 "http:copilot:u:alice".to_string(),
555 lane("copilot", Some("copilot-u-alice"), false),
556 );
557 groups.insert(
558 "http:copilot:u:bob".to_string(),
559 lane("copilot", Some("copilot-u-bob"), false),
560 );
561 groups.insert(
562 "http:copilot:investigate".to_string(),
563 lane("copilot", Some("copilot-investigate"), false),
564 );
565 groups.insert(
566 "http:copilot:investigate:2".to_string(),
567 lane("copilot", Some("copilot-investigate"), false),
568 );
569
570 assert_eq!(
571 lane_namespace(&groups, "copilot-u-alice"),
572 (vec!["http:copilot:u:alice".to_string()], false)
573 );
574 assert_eq!(
577 lane_namespace(&groups, "copilot"),
578 (vec!["http:copilot".to_string()], false)
579 );
580 assert_eq!(
581 lane_namespace(&groups, "copilot-investigate"),
582 (
583 vec![
584 "http:copilot:investigate".to_string(),
585 "http:copilot:investigate:2".to_string()
586 ],
587 false
588 )
589 );
590 assert_eq!(
591 lane_namespace(&groups, "main"),
592 (vec!["tg:main".to_string()], true)
593 );
594 assert_eq!(lane_namespace(&groups, "nobody"), (Vec::new(), false));
595
596 groups.insert("tg:intruder".to_string(), lane("main", None, false));
598 let (jids, is_main) = lane_namespace(&groups, "main");
599 assert_eq!(jids.len(), 2);
600 assert!(!is_main);
601 }
602
603 #[test]
604 fn non_main_snapshots_query_only_their_own_lanes() {
605 let jids = vec!["http:copilot:u:alice".to_string()];
606 assert_eq!(snapshot_task_query(true, &jids), TaskQuery::All);
607 assert_eq!(
608 snapshot_task_query(false, &jids),
609 TaskQuery::ForJids(jids.clone())
610 );
611 assert_eq!(
612 snapshot_task_query(false, &[]),
613 TaskQuery::ForJids(Vec::new())
614 );
615 }
616
617 #[test]
618 fn run_options() {
619 let m = RunOptions::message();
620 assert!(m.resume_session && m.manage_session && !m.scheduled_task);
621 let t = RunOptions::task(Some(" ".into()), true);
622 assert!(t.scheduled_task && t.resume_session && !t.manage_session);
623 assert_eq!(t.script, None);
624 }
625}