Skip to main content

bamboo_server/schedule_app/
manager.rs

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/// Resolved run configuration computed by the adapter layer.
36///
37/// The schedule crate delegates model/prompt/workspace resolution to the
38/// caller via [`ScheduleContext::resolve_run_config`] so that server-specific
39/// concerns (Config, filesystem prompt templates) stay out of the crate.
40#[derive(Clone)]
41pub struct ResolvedRunConfig {
42    /// Primary + auxiliary model/provider selection for the scheduled run.
43    /// The primary `model` is required; resolve it via `roster.model`.
44    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    /// Optional inbox to the account-wide change feed (durable multi-client sync).
62    pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
63    pub app_data_dir: Option<std::path::PathBuf>,
64    pub trigger_engine: DynTriggerEngine,
65    /// Adapter-provided callback that resolves model, system prompt, workspace path
66    /// and reasoning effort for a schedule run job.
67    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        // Worker: executes jobs sequentially (simple + predictable).
86        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        // Ticker: claims due schedules and enqueues jobs.
146        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    // Primary model is required for a schedule run; the roster stores it as
211    // `Option<String>`, so recover the owned String once for the checks/logging
212    // below (an absent primary is treated as the old empty-string skip).
213    let resolved_model = resolved.model_roster.model.clone().unwrap_or_default();
214
215    // If the adapter resolved an empty model, skip the run.
216    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    // Persist session and index entry.
246    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    // If no task message (or not configured to execute), we're done.
269    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    // Model is required by the provider trait; if resolution failed we'd have returned earlier.
301    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    // Insert runner status (for cancellation/status introspection).
311    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    // Run the agent loop in the background via the single canonical execution
327    // path (`spawn_session_execution`), the same one the HTTP execute handler
328    // and the child-completion coordinator use. Schedule-specific finalization
329    // (marking the run terminal, and writing a visible failure marker) is
330    // carried by the `on_complete` hook, which runs after the runner is
331    // finalized but before the session is persisted — so the marker is saved.
332    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                // Persist a visible failure marker so the user can open the
367                // scheduled session and understand why it produced no output.
368                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
432/// Build a [`ScheduleContext`] with server-specific config resolution.
433///
434/// Callers should prefer this over constructing `ScheduleContext` directly
435/// to ensure the `resolve_run_config` callback correctly reads Config and
436/// prompt defaults.
437pub 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    // Auxiliary models are global (config-derived), never session-bound.
494    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)), &registry);
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)), &registry);
645        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
646    }
647}