1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use chrono::Utc;
6use tokio::sync::{broadcast, mpsc, RwLock};
7
8use bamboo_agent_core::tools::ToolExecutor;
9use bamboo_agent_core::{AgentEvent, Message, Role};
10use bamboo_domain::reasoning::ReasoningEffort;
11use bamboo_engine::config::GoldConfig;
12use bamboo_engine::execution::{
13 create_event_forwarder, get_or_create_event_sender, spawn_session_execution,
14 try_reserve_runner, AgentRunner, RunnerReservation, SessionCompletionHook,
15 SessionExecutionArgs,
16};
17use bamboo_engine::{AuxiliaryModelConfig, ModelRoster};
18use bamboo_storage::LockedSessionStore;
19
20use super::store::{ClaimedScheduleRun, ScheduleStore};
21use super::trigger_engine::DynTriggerEngine;
22use bamboo_domain::{ScheduleRunConfig, ScheduleRunStatus};
23
24#[derive(Debug, Clone)]
25pub struct ScheduleRunJob {
26 pub run_id: String,
27 pub schedule_id: String,
28 pub schedule_name: String,
29 pub run_config: ScheduleRunConfig,
30 pub scheduled_for: chrono::DateTime<chrono::Utc>,
31 pub claimed_at: chrono::DateTime<chrono::Utc>,
32 pub was_catch_up: bool,
33}
34
35#[derive(Clone)]
41pub struct ResolvedRunConfig {
42 pub model_roster: ModelRoster,
45 pub reasoning_effort: Option<ReasoningEffort>,
46 pub gold_config: Option<GoldConfig>,
47 pub system_prompt: String,
48 pub base_system_prompt: String,
49 pub workspace_path: Option<String>,
50}
51
52#[derive(Clone)]
53pub struct ScheduleContext {
54 pub schedule_store: Arc<ScheduleStore>,
55 pub agent: Arc<bamboo_engine::Agent>,
56 pub persistence: Arc<LockedSessionStore>,
57 pub tools: Arc<dyn ToolExecutor>,
58 pub sessions_cache: bamboo_engine::SessionCache,
59 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
60 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
61 pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
63 pub app_data_dir: Option<std::path::PathBuf>,
64 pub trigger_engine: DynTriggerEngine,
65 pub resolve_run_config: Arc<dyn Fn(&ScheduleRunJob) -> ResolvedRunConfig + Send + Sync>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum ScheduleRunLifecycleResult {
72 Terminal(ScheduleRunStatus),
73 BackgroundExecutionInProgress,
74}
75
76#[derive(Clone)]
77pub struct ScheduleManager {
78 tx: mpsc::Sender<ScheduleRunJob>,
79}
80
81impl ScheduleManager {
82 pub fn new(ctx: ScheduleContext) -> Self {
83 let (tx, mut rx) = mpsc::channel::<ScheduleRunJob>(128);
84
85 tokio::spawn({
87 let ctx = ctx.clone();
88 async move {
89 while let Some(job) = rx.recv().await {
90 if let Err(error) = ctx
91 .schedule_store
92 .mark_run_started(&job.schedule_id, &job.run_id)
93 .await
94 {
95 tracing::warn!(
96 "failed to mark schedule run started for {} / {}: {}",
97 job.schedule_id,
98 job.run_id,
99 error
100 );
101 }
102 let schedule_id = job.schedule_id.clone();
103 let run_id = job.run_id.clone();
104 match run_schedule_job(ctx.clone(), job).await {
105 Ok(ScheduleRunLifecycleResult::Terminal(status)) => {
106 if let Err(error) = ctx
107 .schedule_store
108 .mark_run_terminal(&schedule_id, &run_id, status, None)
109 .await
110 {
111 tracing::warn!(
112 "failed to mark schedule run terminal state for {} / {}: {}",
113 schedule_id,
114 run_id,
115 error
116 );
117 }
118 }
119 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress) => {}
120 Err(e) => {
121 tracing::warn!("schedule job failed: {e}");
122 if let Err(error) = ctx
123 .schedule_store
124 .mark_run_terminal(
125 &schedule_id,
126 &run_id,
127 ScheduleRunStatus::Failed,
128 Some(e.clone()),
129 )
130 .await
131 {
132 tracing::warn!(
133 "failed to mark schedule run failed state for {} / {}: {}",
134 schedule_id,
135 run_id,
136 error
137 );
138 }
139 }
140 }
141 }
142 }
143 });
144
145 tokio::spawn({
147 let tx = tx.clone();
148 let store = ctx.schedule_store.clone();
149 let trigger_engine = ctx.trigger_engine.clone();
150 async move {
151 let mut ticker = tokio::time::interval(Duration::from_secs(15));
152 loop {
153 ticker.tick().await;
154 let now = Utc::now();
155 let claimed: Vec<ClaimedScheduleRun> = match store
156 .claim_due_runs_with_engine(now, trigger_engine.as_ref())
157 .await
158 {
159 Ok(v) => v,
160 Err(e) => {
161 tracing::warn!("claim_due_runs failed: {e}");
162 continue;
163 }
164 };
165 for c in claimed {
166 let schedule_id = c.schedule_id.clone();
167 let run_id = c.run_id.clone();
168 if tx
169 .send(ScheduleRunJob {
170 run_id: c.run_id,
171 schedule_id: c.schedule_id,
172 schedule_name: c.schedule_name,
173 run_config: c.run_config,
174 scheduled_for: c.scheduled_for,
175 claimed_at: c.claimed_at,
176 was_catch_up: c.was_catch_up,
177 })
178 .await
179 .is_err()
180 {
181 let _ = store
182 .mark_run_dequeued_without_start(
183 &schedule_id,
184 &run_id,
185 Some("schedule manager is not running".to_string()),
186 )
187 .await;
188 }
189 }
190 }
191 }
192 });
193
194 Self { tx }
195 }
196
197 pub async fn enqueue_run_now(&self, job: ScheduleRunJob) -> Result<(), String> {
198 self.tx
199 .send(job)
200 .await
201 .map_err(|_| "schedule manager is not running".to_string())
202 }
203}
204
205async fn run_schedule_job(
206 ctx: ScheduleContext,
207 job: ScheduleRunJob,
208) -> Result<ScheduleRunLifecycleResult, String> {
209 let resolved = (ctx.resolve_run_config)(&job);
210 let resolved_model = resolved.model_roster.model.clone().unwrap_or_default();
214
215 if resolved_model.trim().is_empty() {
217 tracing::warn!(
218 "[schedule:{}] skipping run: resolved model is empty",
219 job.schedule_id
220 );
221 return Ok(ScheduleRunLifecycleResult::Terminal(
222 ScheduleRunStatus::Skipped,
223 ));
224 }
225
226 let requested_model = job
227 .run_config
228 .model
229 .as_deref()
230 .map(str::trim)
231 .filter(|v| !v.is_empty())
232 .map(|v| v.to_string());
233 let requested_reasoning_effort = job.run_config.reasoning_effort;
234
235 let mut session = super::session_factory::create_schedule_session(
236 &job,
237 &resolved_model,
238 &resolved.system_prompt,
239 &resolved.base_system_prompt,
240 resolved.workspace_path.as_deref(),
241 resolved.reasoning_effort,
242 );
243 let session_id = session.id.clone();
244
245 ctx.persistence
247 .merge_save_runtime(&mut session)
248 .await
249 .map_err(|e| format!("failed to save scheduled session: {e}"))?;
250 if let Err(error) = ctx
251 .schedule_store
252 .bind_run_session(&job.schedule_id, &job.run_id, &session_id)
253 .await
254 {
255 tracing::warn!(
256 "failed to bind session {} to schedule run {} / {}: {}",
257 session_id,
258 job.schedule_id,
259 job.run_id,
260 error
261 );
262 }
263 ctx.sessions_cache.insert(
264 session_id.clone(),
265 Arc::new(parking_lot::RwLock::new(session.clone())),
266 );
267
268 let should_execute = job.run_config.auto_execute
270 && session
271 .messages
272 .last()
273 .map(|m| matches!(m.role, Role::User))
274 .unwrap_or(false);
275
276 tracing::info!(
277 "[schedule:{}] created session {} (auto_execute={}, model={}, model_source={}, reasoning_effort={}, reasoning_source={})",
278 job.schedule_id,
279 session_id,
280 job.run_config.auto_execute,
281 resolved_model,
282 if requested_model.is_some() {
283 "schedule.run_config.model"
284 } else {
285 "resolved"
286 },
287 resolved.reasoning_effort.map(|value| value.as_str()).unwrap_or("none"),
288 if requested_reasoning_effort.is_some() {
289 "schedule.run_config.reasoning_effort"
290 } else {
291 "resolved"
292 }
293 );
294 if !should_execute {
295 return Ok(ScheduleRunLifecycleResult::Terminal(
296 ScheduleRunStatus::Success,
297 ));
298 }
299
300 if resolved_model.trim().is_empty() {
302 let msg = "resolved model is empty".to_string();
303 session.add_message(Message::assistant(format!("❌ {msg}"), None));
304 let _ = ctx.persistence.merge_save_runtime(&mut session).await;
305 return Err(msg);
306 }
307
308 let session_tx = get_or_create_event_sender(&ctx.session_event_senders, &session_id).await;
309
310 let Some(RunnerReservation { cancel_token, .. }) =
312 try_reserve_runner(&ctx.agent_runners, &session_id, &session_tx).await
313 else {
314 return Ok(ScheduleRunLifecycleResult::Terminal(
315 ScheduleRunStatus::Skipped,
316 ));
317 };
318
319 let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
320 session_id.clone(),
321 session_tx.clone(),
322 ctx.agent_runners.clone(),
323 ctx.account_feed_inbox.clone(),
324 );
325
326 let aux_fast_model = resolved.model_roster.fast_model();
333 let aux_fast_provider = resolved.model_roster.fast_model_provider();
334 let aux_background_model = resolved.model_roster.background_model();
335 let aux_background_provider = resolved.model_roster.background_model_provider();
336 let aux_summarization_model = resolved.model_roster.summarization_model();
337 let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
338 let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
339 fast_model_name: aux_fast_model.clone(),
340 fast_model_provider: aux_fast_provider.clone(),
341 background_model_name: aux_background_model.clone(),
342 planning_model_name: None,
343 search_model_name: None,
344 summarization_model_name: aux_summarization_model.clone(),
345 background_model_provider: aux_background_provider.clone(),
346 summarization_model_provider: aux_summarization_provider.clone(),
347 });
348
349 let schedule_store = ctx.schedule_store.clone();
350 let schedule_id_for_state = job.schedule_id.clone();
351 let run_id_for_state = job.run_id.clone();
352 let log_session_id = session_id.clone();
353
354 let on_complete: SessionCompletionHook = Box::new(move |outcome, session| {
355 Box::pin(async move {
356 let terminal_status = if outcome.success {
357 tracing::info!(
358 "[schedule:{}][run:{}][session:{}] scheduled run completed",
359 schedule_id_for_state,
360 run_id_for_state,
361 log_session_id
362 );
363 ScheduleRunStatus::Success
364 } else {
365 let detail = outcome.error.as_deref().unwrap_or("unknown error");
366 session.add_message(Message::assistant(
369 format!("❌ Scheduled run failed: {detail}"),
370 None,
371 ));
372 tracing::warn!(
373 "[schedule:{}][run:{}][session:{}] scheduled run failed: {}",
374 schedule_id_for_state,
375 run_id_for_state,
376 log_session_id,
377 detail
378 );
379 if outcome.cancelled {
380 ScheduleRunStatus::Cancelled
381 } else {
382 ScheduleRunStatus::Failed
383 }
384 };
385
386 if let Err(error) = schedule_store
387 .mark_run_terminal(
388 &schedule_id_for_state,
389 &run_id_for_state,
390 terminal_status,
391 None,
392 )
393 .await
394 {
395 tracing::warn!(
396 "failed to mark schedule run terminal state for {} / {}: {}",
397 schedule_id_for_state,
398 run_id_for_state,
399 error
400 );
401 }
402 })
403 });
404
405 spawn_session_execution(SessionExecutionArgs {
406 agent: ctx.agent.clone(),
407 session_id,
408 session,
409 tools_override: Some(ctx.tools.clone()),
410 provider_override: None,
411 model_roster: resolved.model_roster.clone(),
412 reasoning_effort: resolved.reasoning_effort,
413 reasoning_effort_source: "schedule".to_string(),
414 auxiliary_model_resolver: Some(auxiliary_model_resolver),
415 disabled_tools: None,
416 disabled_skill_ids: None,
417 selected_skill_ids: None,
418 selected_skill_mode: None,
419 cancel_token,
420 mpsc_tx,
421 image_fallback: None,
422 gold_config: resolved.gold_config.clone(),
423 app_data_dir: ctx.app_data_dir.clone(),
424 runners: ctx.agent_runners.clone(),
425 sessions_cache: ctx.sessions_cache.clone(),
426 on_complete: Some(on_complete),
427 });
428
429 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
430}
431
432pub fn build_schedule_context(
438 base: ScheduleContext,
439 config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
440 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
441) -> ScheduleContext {
442 ScheduleContext {
443 schedule_store: base.schedule_store,
444 agent: base.agent,
445 tools: base.tools,
446 sessions_cache: base.sessions_cache,
447 agent_runners: base.agent_runners,
448 session_event_senders: base.session_event_senders,
449 account_feed_inbox: base.account_feed_inbox,
450 app_data_dir: base.app_data_dir,
451 trigger_engine: base.trigger_engine,
452 persistence: base.persistence,
453 resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
454 resolve_run_config_from_config(job, &config, &provider_registry)
455 }),
456 }
457}
458
459fn resolve_run_config_from_config(
460 job: &ScheduleRunJob,
461 config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
462 provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
463) -> ResolvedRunConfig {
464 let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
465
466 let requested_model = job
467 .run_config
468 .model
469 .as_deref()
470 .map(str::trim)
471 .filter(|v| !v.is_empty())
472 .map(|v| v.to_string());
473
474 let model = if let Some(m) = requested_model {
475 m
476 } else {
477 bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
478 .unwrap_or_default()
479 };
480
481 let provider_name = Some(config_snapshot.effective_default_provider().to_string());
482 let provider_type = provider_name.as_deref().and_then(|name| {
483 bamboo_engine::model_config_helper::resolve_provider_type(
484 &config_snapshot,
485 name,
486 provider_registry,
487 )
488 });
489
490 let capability_provider_name = provider_name
491 .as_deref()
492 .unwrap_or(config_snapshot.effective_default_provider());
493 let areas = bamboo_engine::model_areas::resolve_global_area_models(
495 &config_snapshot,
496 capability_provider_name,
497 provider_registry,
498 );
499
500 let requested_reasoning_effort = job.run_config.reasoning_effort;
501 let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
502
503 let global_default_prompt =
504 bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
505 let base_system_prompt = job
506 .run_config
507 .system_prompt
508 .as_deref()
509 .map(str::trim)
510 .filter(|v| !v.is_empty())
511 .unwrap_or(global_default_prompt.as_str());
512
513 let workspace_path = job
514 .run_config
515 .workspace_path
516 .as_deref()
517 .map(str::trim)
518 .filter(|v| !v.is_empty())
519 .map(ToString::to_string)
520 .or_else(|| {
521 config_snapshot
522 .get_default_work_area_path()
523 .map(|path| bamboo_config::paths::path_to_display_string(&path))
524 });
525
526 let enhance_prompt = job
527 .run_config
528 .enhance_prompt
529 .as_deref()
530 .map(str::trim)
531 .filter(|v| !v.is_empty());
532
533 let system_prompt = bamboo_engine::context::assemble_system_prompt(
534 base_system_prompt,
535 enhance_prompt,
536 workspace_path.as_deref(),
537 );
538
539 let model_roster =
540 bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
541
542 ResolvedRunConfig {
543 model_roster,
544 reasoning_effort,
545 gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
546 &config_snapshot,
547 None,
548 ),
549 system_prompt,
550 base_system_prompt: base_system_prompt.to_string(),
551 workspace_path,
552 }
553}
554
555#[cfg(test)]
556mod build_context_tests {
557 use super::resolve_run_config_from_config;
558 use super::ScheduleRunJob;
559 use bamboo_config::DefaultsConfig;
560 use bamboo_config::{OpenAIConfig, ProviderConfigs};
561 use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
562 use bamboo_llm::{Config, ProviderRegistry};
563 use std::collections::HashMap;
564 use std::sync::Arc;
565 use tokio::sync::RwLock;
566
567 fn test_job() -> ScheduleRunJob {
568 ScheduleRunJob {
569 run_id: "run-1".to_string(),
570 schedule_id: "schedule-1".to_string(),
571 schedule_name: "nightly".to_string(),
572 run_config: ScheduleRunConfig::default(),
573 scheduled_for: chrono::Utc::now(),
574 claimed_at: chrono::Utc::now(),
575 was_catch_up: false,
576 }
577 }
578
579 #[test]
580 fn resolve_run_config_from_config_prefers_fast_model() {
581 let config = Config {
582 provider: "openai".to_string(),
583 defaults: None,
584 features: bamboo_config::FeatureFlags {
585 provider_model_ref: false,
586 ..Default::default()
587 },
588 providers: ProviderConfigs {
589 openai: Some(OpenAIConfig {
590 api_key: "test".to_string(),
591 api_key_encrypted: None,
592 base_url: None,
593 model: Some("gpt-4o".to_string()),
594 fast_model: Some("gpt-4o-mini".to_string()),
595 vision_model: None,
596 reasoning_effort: None,
597 responses_only_models: vec![],
598 request_overrides: None,
599 extra: Default::default(),
600 }),
601 ..ProviderConfigs::default()
602 },
603 ..Config::default()
604 };
605
606 let registry = Arc::new(ProviderRegistry::new(
607 Default::default(),
608 "openai".to_string(),
609 ));
610 let resolved =
611 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
612 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
613 }
614
615 #[test]
616 fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
617 let config = Config {
618 provider: "openai".to_string(),
619 defaults: Some(DefaultsConfig {
620 chat: ProviderModelRef::new("openai", "gpt-chat"),
621 fast: None,
622 task_summary: None,
623 vision: None,
624 memory_background: None,
625 planning: None,
626 search: None,
627 code_review: None,
628 sub_agent: None,
629 subagent_models: HashMap::new(),
630 }),
631 features: bamboo_config::FeatureFlags {
632 provider_model_ref: true,
633 ..Default::default()
634 },
635 providers: ProviderConfigs::default(),
636 ..Config::default()
637 };
638
639 let registry = Arc::new(ProviderRegistry::new(
640 Default::default(),
641 "openai".to_string(),
642 ));
643 let resolved =
644 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
645 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
646 }
647}