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    // #73: a scheduled run has no interactive human approver — mark the root so
246    // its sub-agents (which inherit the flag) decide gated actions with the
247    // off-loop model-reviewer locally instead of escalating to an absent human,
248    // which would 300s-deny.
249    session
250        .agent_runtime_state
251        .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
252        .no_human_approver = true;
253
254    // Persist session and index entry.
255    ctx.persistence
256        .merge_save_runtime(&mut session)
257        .await
258        .map_err(|e| format!("failed to save scheduled session: {e}"))?;
259    if let Err(error) = ctx
260        .schedule_store
261        .bind_run_session(&job.schedule_id, &job.run_id, &session_id)
262        .await
263    {
264        tracing::warn!(
265            "failed to bind session {} to schedule run {} / {}: {}",
266            session_id,
267            job.schedule_id,
268            job.run_id,
269            error
270        );
271    }
272    ctx.sessions_cache.insert(
273        session_id.clone(),
274        Arc::new(parking_lot::RwLock::new(session.clone())),
275    );
276
277    // If no task message (or not configured to execute), we're done.
278    let should_execute = job.run_config.auto_execute
279        && session
280            .messages
281            .last()
282            .map(|m| matches!(m.role, Role::User))
283            .unwrap_or(false);
284
285    tracing::info!(
286        "[schedule:{}] created session {} (auto_execute={}, model={}, model_source={}, reasoning_effort={}, reasoning_source={})",
287        job.schedule_id,
288        session_id,
289        job.run_config.auto_execute,
290        resolved_model,
291        if requested_model.is_some() {
292            "schedule.run_config.model"
293        } else {
294            "resolved"
295        },
296        resolved.reasoning_effort.map(|value| value.as_str()).unwrap_or("none"),
297        if requested_reasoning_effort.is_some() {
298            "schedule.run_config.reasoning_effort"
299        } else {
300            "resolved"
301        }
302    );
303    if !should_execute {
304        return Ok(ScheduleRunLifecycleResult::Terminal(
305            ScheduleRunStatus::Success,
306        ));
307    }
308
309    // Model is required by the provider trait; if resolution failed we'd have returned earlier.
310    if resolved_model.trim().is_empty() {
311        let msg = "resolved model is empty".to_string();
312        session.add_message(Message::assistant(format!("❌ {msg}"), None));
313        let _ = ctx.persistence.merge_save_runtime(&mut session).await;
314        return Err(msg);
315    }
316
317    let session_tx = get_or_create_event_sender(&ctx.session_event_senders, &session_id).await;
318
319    // Insert runner status (for cancellation/status introspection).
320    let Some(RunnerReservation { cancel_token, .. }) = try_reserve_runner(
321        &ctx.agent_runners,
322        &ctx.session_event_senders,
323        &session_id,
324        &session_tx,
325    )
326    .await
327    else {
328        return Ok(ScheduleRunLifecycleResult::Terminal(
329            ScheduleRunStatus::Skipped,
330        ));
331    };
332
333    let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
334        session_id.clone(),
335        session_tx.clone(),
336        ctx.agent_runners.clone(),
337        ctx.account_feed_inbox.clone(),
338    );
339
340    // Run the agent loop in the background via the single canonical execution
341    // path (`spawn_session_execution`), the same one the HTTP execute handler
342    // and the child-completion coordinator use. Schedule-specific finalization
343    // (marking the run terminal, and writing a visible failure marker) is
344    // carried by the `on_complete` hook, which runs after the runner is
345    // finalized but before the session is persisted — so the marker is saved.
346    let aux_fast_model = resolved.model_roster.fast_model();
347    let aux_fast_provider = resolved.model_roster.fast_model_provider();
348    let aux_background_model = resolved.model_roster.background_model();
349    let aux_background_provider = resolved.model_roster.background_model_provider();
350    let aux_summarization_model = resolved.model_roster.summarization_model();
351    let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
352    let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
353        fast_model_name: aux_fast_model.clone(),
354        fast_model_provider: aux_fast_provider.clone(),
355        background_model_name: aux_background_model.clone(),
356        planning_model_name: None,
357        search_model_name: None,
358        summarization_model_name: aux_summarization_model.clone(),
359        background_model_provider: aux_background_provider.clone(),
360        summarization_model_provider: aux_summarization_provider.clone(),
361    });
362
363    let schedule_store = ctx.schedule_store.clone();
364    let schedule_id_for_state = job.schedule_id.clone();
365    let run_id_for_state = job.run_id.clone();
366    let log_session_id = session_id.clone();
367
368    let on_complete: SessionCompletionHook = Box::new(move |outcome, session| {
369        Box::pin(async move {
370            let terminal_status = if outcome.success {
371                tracing::info!(
372                    "[schedule:{}][run:{}][session:{}] scheduled run completed",
373                    schedule_id_for_state,
374                    run_id_for_state,
375                    log_session_id
376                );
377                ScheduleRunStatus::Success
378            } else {
379                let detail = outcome.error.as_deref().unwrap_or("unknown error");
380                // Persist a visible failure marker so the user can open the
381                // scheduled session and understand why it produced no output.
382                session.add_message(Message::assistant(
383                    format!("❌ Scheduled run failed: {detail}"),
384                    None,
385                ));
386                tracing::warn!(
387                    "[schedule:{}][run:{}][session:{}] scheduled run failed: {}",
388                    schedule_id_for_state,
389                    run_id_for_state,
390                    log_session_id,
391                    detail
392                );
393                if outcome.cancelled {
394                    ScheduleRunStatus::Cancelled
395                } else {
396                    ScheduleRunStatus::Failed
397                }
398            };
399
400            if let Err(error) = schedule_store
401                .mark_run_terminal(
402                    &schedule_id_for_state,
403                    &run_id_for_state,
404                    terminal_status,
405                    None,
406                )
407                .await
408            {
409                tracing::warn!(
410                    "failed to mark schedule run terminal state for {} / {}: {}",
411                    schedule_id_for_state,
412                    run_id_for_state,
413                    error
414                );
415            }
416        })
417    });
418
419    spawn_session_execution(SessionExecutionArgs {
420        agent: ctx.agent.clone(),
421        session_id,
422        session,
423        tools_override: Some(ctx.tools.clone()),
424        provider_override: None,
425        model_roster: resolved.model_roster.clone(),
426        reasoning_effort: resolved.reasoning_effort,
427        reasoning_effort_source: "schedule".to_string(),
428        auxiliary_model_resolver: Some(auxiliary_model_resolver),
429        // Scheduled runs use the per-run disabled snapshot (#136 lives on the
430        // interactive agent path; a scheduled task is a discrete run).
431        disabled_filter_resolver: None,
432        disabled_tools: None,
433        disabled_skill_ids: None,
434        selected_skill_ids: None,
435        selected_skill_mode: None,
436        cancel_token,
437        mpsc_tx,
438        image_fallback: None,
439        gold_config: resolved.gold_config.clone(),
440        // Guardian review is not wired into the schedule path for now.
441        guardian_config: None,
442        guardian_spawner: None,
443        // No bash self-resume hook on the schedule path: the end-of-turn bash
444        // suspend gate is therefore inert here (it requires a wired hook).
445        // Because the loop can't resume a backgrounded shell, the Bash tool's
446        // auto path detects this (can_async_resume == false, derived from
447        // hook+persistence) and stays purely synchronous — a long command on
448        // the default path blocks to its timeout rather than promoting to an
449        // orphaned background shell whose output this loop could never await
450        // (issue #84, phase 2d). An explicitly backgrounded shell
451        // (`run_in_background: true`) still runs detached and stays readable via
452        // BashOutput; no strand can occur because the gate refuses to suspend
453        // without the hook.
454        bash_resume_hook: None,
455        // Hook-less loop: no suspend/resume machinery, so stay push-free too
456        // (consistent with `can_async_resume: false` on this path).
457        bash_completion_sink: None,
458        app_data_dir: ctx.app_data_dir.clone(),
459        runners: ctx.agent_runners.clone(),
460        sessions_cache: ctx.sessions_cache.clone(),
461        on_complete: Some(on_complete),
462    });
463
464    Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
465}
466
467/// Build a [`ScheduleContext`] with server-specific config resolution.
468///
469/// Callers should prefer this over constructing `ScheduleContext` directly
470/// to ensure the `resolve_run_config` callback correctly reads Config and
471/// prompt defaults.
472pub fn build_schedule_context(
473    base: ScheduleContext,
474    config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
475    provider_registry: Arc<bamboo_llm::ProviderRegistry>,
476) -> ScheduleContext {
477    ScheduleContext {
478        schedule_store: base.schedule_store,
479        agent: base.agent,
480        tools: base.tools,
481        sessions_cache: base.sessions_cache,
482        agent_runners: base.agent_runners,
483        session_event_senders: base.session_event_senders,
484        account_feed_inbox: base.account_feed_inbox,
485        app_data_dir: base.app_data_dir,
486        trigger_engine: base.trigger_engine,
487        persistence: base.persistence,
488        resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
489            resolve_run_config_from_config(job, &config, &provider_registry)
490        }),
491    }
492}
493
494fn resolve_run_config_from_config(
495    job: &ScheduleRunJob,
496    config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
497    provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
498) -> ResolvedRunConfig {
499    let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
500
501    let requested_model = job
502        .run_config
503        .model
504        .as_deref()
505        .map(str::trim)
506        .filter(|v| !v.is_empty())
507        .map(|v| v.to_string());
508
509    let model = if let Some(m) = requested_model {
510        m
511    } else {
512        bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
513            .unwrap_or_default()
514    };
515
516    let provider_name = Some(config_snapshot.effective_default_provider().to_string());
517    let provider_type = provider_name.as_deref().and_then(|name| {
518        bamboo_engine::model_config_helper::resolve_provider_type(
519            &config_snapshot,
520            name,
521            provider_registry,
522        )
523    });
524
525    let capability_provider_name = provider_name
526        .as_deref()
527        .unwrap_or(config_snapshot.effective_default_provider());
528    // Auxiliary models are global (config-derived), never session-bound.
529    let areas = bamboo_engine::model_areas::resolve_global_area_models(
530        &config_snapshot,
531        capability_provider_name,
532        provider_registry,
533    );
534
535    let requested_reasoning_effort = job.run_config.reasoning_effort;
536    let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
537
538    let global_default_prompt =
539        bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
540    let base_system_prompt = job
541        .run_config
542        .system_prompt
543        .as_deref()
544        .map(str::trim)
545        .filter(|v| !v.is_empty())
546        .unwrap_or(global_default_prompt.as_str());
547
548    let workspace_path = job
549        .run_config
550        .workspace_path
551        .as_deref()
552        .map(str::trim)
553        .filter(|v| !v.is_empty())
554        .map(ToString::to_string)
555        .or_else(|| {
556            config_snapshot
557                .get_default_work_area_path()
558                .map(|path| bamboo_config::paths::path_to_display_string(&path))
559        });
560
561    let enhance_prompt = job
562        .run_config
563        .enhance_prompt
564        .as_deref()
565        .map(str::trim)
566        .filter(|v| !v.is_empty());
567
568    let system_prompt = bamboo_engine::context::assemble_system_prompt(
569        base_system_prompt,
570        enhance_prompt,
571        workspace_path.as_deref(),
572    );
573
574    let model_roster =
575        bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
576
577    ResolvedRunConfig {
578        model_roster,
579        reasoning_effort,
580        gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
581            &config_snapshot,
582            None,
583        ),
584        system_prompt,
585        base_system_prompt: base_system_prompt.to_string(),
586        workspace_path,
587    }
588}
589
590#[cfg(test)]
591mod build_context_tests {
592    use super::resolve_run_config_from_config;
593    use super::ScheduleRunJob;
594    use bamboo_config::DefaultsConfig;
595    use bamboo_config::{OpenAIConfig, ProviderConfigs};
596    use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
597    use bamboo_llm::{Config, ProviderRegistry};
598    use std::collections::HashMap;
599    use std::sync::Arc;
600    use tokio::sync::RwLock;
601
602    fn test_job() -> ScheduleRunJob {
603        ScheduleRunJob {
604            run_id: "run-1".to_string(),
605            schedule_id: "schedule-1".to_string(),
606            schedule_name: "nightly".to_string(),
607            run_config: ScheduleRunConfig::default(),
608            scheduled_for: chrono::Utc::now(),
609            claimed_at: chrono::Utc::now(),
610            was_catch_up: false,
611        }
612    }
613
614    #[test]
615    fn resolve_run_config_from_config_prefers_fast_model() {
616        let config = Config {
617            provider: "openai".to_string(),
618            defaults: None,
619            features: bamboo_config::FeatureFlags {
620                provider_model_ref: false,
621                ..Default::default()
622            },
623            providers: ProviderConfigs {
624                openai: Some(OpenAIConfig {
625                    api_key: "test".to_string(),
626                    api_key_from_env: false,
627                    api_key_encrypted: None,
628                    base_url: None,
629                    model: Some("gpt-4o".to_string()),
630                    fast_model: Some("gpt-4o-mini".to_string()),
631                    vision_model: None,
632                    reasoning_effort: None,
633                    responses_only_models: vec![],
634                    request_overrides: None,
635                    extra: Default::default(),
636                }),
637                ..ProviderConfigs::default()
638            },
639            ..Config::default()
640        };
641
642        let registry = Arc::new(ProviderRegistry::new(
643            Default::default(),
644            "openai".to_string(),
645        ));
646        let resolved =
647            resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), &registry);
648        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
649    }
650
651    #[test]
652    fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
653        let config = Config {
654            provider: "openai".to_string(),
655            defaults: Some(DefaultsConfig {
656                chat: ProviderModelRef::new("openai", "gpt-chat"),
657                fast: None,
658                task_summary: None,
659                vision: None,
660                memory_background: None,
661                planning: None,
662                search: None,
663                code_review: None,
664                sub_agent: None,
665                subagent_models: HashMap::new(),
666            }),
667            features: bamboo_config::FeatureFlags {
668                provider_model_ref: true,
669                ..Default::default()
670            },
671            providers: ProviderConfigs::default(),
672            ..Config::default()
673        };
674
675        let registry = Arc::new(ProviderRegistry::new(
676            Default::default(),
677            "openai".to_string(),
678        ));
679        let resolved =
680            resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), &registry);
681        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
682    }
683}