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, reserve_session_execution,
14 spawn_session_execution, AgentRunner, SessionCompletionHook, SessionExecutionArgs,
15 SessionExecutionReserveOutcome,
16};
17use bamboo_engine::{AuxiliaryModelConfig, ModelRoster};
18use bamboo_storage::LockedSessionStore;
19
20use crate::permission_audit::record_bamboo_runtime_permission_metadata;
21
22use super::store::{ClaimedScheduleRun, ScheduleStore};
23use super::trigger_engine::DynTriggerEngine;
24use bamboo_domain::{ScheduleRunConfig, ScheduleRunStatus};
25
26#[derive(Debug, Clone)]
27pub struct ScheduleRunJob {
28 pub run_id: String,
29 pub schedule_id: String,
30 pub schedule_name: String,
31 pub run_config: ScheduleRunConfig,
32 pub scheduled_for: chrono::DateTime<chrono::Utc>,
33 pub claimed_at: chrono::DateTime<chrono::Utc>,
34 pub was_catch_up: bool,
35}
36
37#[derive(Clone)]
43pub struct ResolvedRunConfig {
44 pub model_roster: ModelRoster,
47 pub reasoning_effort: Option<ReasoningEffort>,
48 pub gold_config: Option<GoldConfig>,
49 pub system_prompt: String,
50 pub base_system_prompt: String,
51 pub workspace_path: Option<String>,
52 pub lifecycle_hooks: bamboo_config::LifecycleHooksConfig,
53}
54
55#[derive(Clone)]
56pub struct ScheduleContext {
57 pub schedule_store: Arc<ScheduleStore>,
58 pub agent: Arc<bamboo_engine::Agent>,
59 pub persistence: Arc<LockedSessionStore>,
60 pub tools: Arc<dyn ToolExecutor>,
61 pub permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
62 pub sessions_cache: bamboo_engine::SessionCache,
63 pub agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
64 pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
65 pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
67 pub app_data_dir: Option<std::path::PathBuf>,
68 pub trigger_engine: DynTriggerEngine,
69 pub project_store: Arc<bamboo_projects::ProjectStore>,
71 pub workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
73 pub notification_relay: crate::app_state::session_events::NotificationRelayDeps,
79 pub resolve_run_config: Arc<dyn Fn(&ScheduleRunJob) -> ResolvedRunConfig + Send + Sync>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum ScheduleRunLifecycleResult {
86 Terminal(ScheduleRunStatus),
87 BackgroundExecutionInProgress,
88}
89
90#[derive(Clone)]
91pub struct ScheduleManager {
92 tx: mpsc::Sender<ScheduleRunJob>,
93}
94
95impl ScheduleManager {
96 pub fn new(ctx: ScheduleContext) -> Self {
97 let (tx, mut rx) = mpsc::channel::<ScheduleRunJob>(128);
98
99 tokio::spawn({
101 let ctx = ctx.clone();
102 async move {
103 while let Some(job) = rx.recv().await {
104 if let Err(error) = ctx
105 .schedule_store
106 .mark_run_started(&job.schedule_id, &job.run_id)
107 .await
108 {
109 tracing::warn!(
110 "failed to mark schedule run started for {} / {}: {}",
111 job.schedule_id,
112 job.run_id,
113 error
114 );
115 }
116 let schedule_id = job.schedule_id.clone();
117 let run_id = job.run_id.clone();
118 match run_schedule_job(ctx.clone(), job).await {
119 Ok(ScheduleRunLifecycleResult::Terminal(status)) => {
120 if let Err(error) = ctx
121 .schedule_store
122 .mark_run_terminal(&schedule_id, &run_id, status, None)
123 .await
124 {
125 tracing::warn!(
126 "failed to mark schedule run terminal state for {} / {}: {}",
127 schedule_id,
128 run_id,
129 error
130 );
131 }
132 }
133 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress) => {}
134 Err(e) => {
135 tracing::warn!("schedule job failed: {e}");
136 if let Err(error) = ctx
137 .schedule_store
138 .mark_run_terminal(
139 &schedule_id,
140 &run_id,
141 ScheduleRunStatus::Failed,
142 Some(e.clone()),
143 )
144 .await
145 {
146 tracing::warn!(
147 "failed to mark schedule run failed state for {} / {}: {}",
148 schedule_id,
149 run_id,
150 error
151 );
152 }
153 }
154 }
155 }
156 }
157 });
158
159 tokio::spawn({
161 let tx = tx.clone();
162 let store = ctx.schedule_store.clone();
163 let trigger_engine = ctx.trigger_engine.clone();
164 async move {
165 let mut ticker = tokio::time::interval(Duration::from_secs(15));
166 loop {
167 ticker.tick().await;
168 let now = Utc::now();
169 let claimed: Vec<ClaimedScheduleRun> = match store
170 .claim_due_runs_with_engine(now, trigger_engine.as_ref())
171 .await
172 {
173 Ok(v) => v,
174 Err(e) => {
175 tracing::warn!("claim_due_runs failed: {e}");
176 continue;
177 }
178 };
179 for c in claimed {
180 let schedule_id = c.schedule_id.clone();
181 let run_id = c.run_id.clone();
182 if tx
183 .send(ScheduleRunJob {
184 run_id: c.run_id,
185 schedule_id: c.schedule_id,
186 schedule_name: c.schedule_name,
187 run_config: c.run_config,
188 scheduled_for: c.scheduled_for,
189 claimed_at: c.claimed_at,
190 was_catch_up: c.was_catch_up,
191 })
192 .await
193 .is_err()
194 {
195 let _ = store
196 .mark_run_dequeued_without_start(
197 &schedule_id,
198 &run_id,
199 Some("schedule manager is not running".to_string()),
200 )
201 .await;
202 }
203 }
204 }
205 }
206 });
207
208 Self { tx }
209 }
210
211 pub async fn enqueue_run_now(&self, job: ScheduleRunJob) -> Result<(), String> {
212 self.tx
213 .send(job)
214 .await
215 .map_err(|_| "schedule manager is not running".to_string())
216 }
217}
218
219const SCHEDULE_NOTIFY_BODY_MAX: usize = 200;
224
225fn truncate_chars(text: &str, max: usize) -> String {
228 if text.chars().count() <= max {
229 return text.to_string();
230 }
231 let mut out: String = text.chars().take(max).collect();
232 out.push('…');
233 out
234}
235
236fn schedule_run_title(schedule_name: &str, success: bool) -> String {
238 if success {
239 format!("Schedule '{schedule_name}' completed")
240 } else {
241 format!("Schedule '{schedule_name}' failed")
242 }
243}
244
245fn final_assistant_excerpt(messages: &[Message]) -> Option<String> {
252 messages
253 .iter()
254 .rev()
255 .find(|m| matches!(m.role, Role::Assistant) && !m.content.trim().is_empty())
256 .map(|m| truncate_chars(m.content.trim(), SCHEDULE_NOTIFY_BODY_MAX))
257}
258
259async fn notify_schedule_run_outcome(
275 relay: &crate::app_state::session_events::NotificationRelayDeps,
276 session_id: &str,
277 success: bool,
278 title: String,
279 body: String,
280) {
281 let Some(notification) = relay
282 .notification_service
283 .notify_schedule_run(session_id, success, title, body)
284 else {
285 return;
288 };
289
290 let sink_notification = crate::notify_sinks::SinkNotification::from_event(¬ification);
293
294 let tx = relay
295 .session_event_senders
296 .read()
297 .await
298 .get(session_id)
299 .cloned();
300 if let Some(tx) = tx {
301 let _ = tx.send(notification);
302 }
303
304 if let Some(sink_notification) = sink_notification {
305 let has_watcher = relay.session_watchers.has_watcher(session_id);
306 let config_snapshot = relay.config.read().await.clone();
307 crate::AppState::dispatch_to_sinks(&config_snapshot, has_watcher, &sink_notification);
308 }
309}
310
311async fn run_schedule_job(
312 ctx: ScheduleContext,
313 job: ScheduleRunJob,
314) -> Result<ScheduleRunLifecycleResult, String> {
315 validate_schedule_project_at_fire(&ctx.project_store, &job.run_config)?;
316 let mut resolved = (ctx.resolve_run_config)(&job);
317 let explicit_workspace = job
318 .run_config
319 .workspace_path
320 .as_deref()
321 .map(str::trim)
322 .filter(|workspace| !workspace.is_empty());
323 let requested_workspace = explicit_workspace.or_else(|| {
324 job.run_config
325 .project_id
326 .is_none()
327 .then_some(resolved.workspace_path.as_deref())
328 .flatten()
329 });
330 let final_workspace = crate::project_context::validate_workspace_assignment_with_resolver(
331 &ctx.project_store,
332 job.run_config.project_id.as_ref(),
333 requested_workspace,
334 &ctx.workspace_resolver,
335 )
336 .map_err(|error| format!("validate schedule workspace at execution time: {error}"))?;
337 resolved.workspace_path = final_workspace
338 .as_deref()
339 .map(bamboo_config::paths::path_to_display_string);
340 let binding_status = match (
341 job.run_config.project_id.as_ref(),
342 final_workspace.as_deref(),
343 ) {
344 (Some(project_id), Some(workspace)) => {
345 let workspace = bamboo_config::paths::path_to_display_string(workspace);
346 if ctx
347 .project_store
348 .find_workspace_owner_for_path(&workspace)
349 .map_err(|error| format!("resolve schedule workspace owner: {error}"))?
350 .is_some_and(|owner| owner.id == *project_id)
351 {
352 bamboo_engine::project_context::WorkspaceBindingStatus::Registered
353 } else {
354 bamboo_engine::project_context::WorkspaceBindingStatus::Unregistered
355 }
356 }
357 _ => bamboo_engine::project_context::WorkspaceBindingStatus::Unregistered,
358 };
359 let workspace_source = job.run_config.project_id.as_ref().map(|_| {
360 if explicit_workspace.is_some() {
361 bamboo_engine::project_context::WorkspaceSource::Explicit
362 } else {
363 bamboo_engine::project_context::WorkspaceSource::ProjectDefault
364 }
365 });
366 resolved.system_prompt =
367 bamboo_engine::runtime::context::upsert_workspace_prompt_context_with_source(
368 &resolved.system_prompt,
369 resolved.workspace_path.as_deref(),
370 binding_status,
371 workspace_source,
372 );
373 let resolved_model = resolved.model_roster.model.clone().unwrap_or_default();
377
378 if resolved_model.trim().is_empty() {
380 tracing::warn!(
381 "[schedule:{}] skipping run: resolved model is empty",
382 job.schedule_id
383 );
384 return Ok(ScheduleRunLifecycleResult::Terminal(
385 ScheduleRunStatus::Skipped,
386 ));
387 }
388
389 let requested_model = job
390 .run_config
391 .model
392 .as_deref()
393 .map(str::trim)
394 .filter(|v| !v.is_empty())
395 .map(|v| v.to_string());
396 let requested_reasoning_effort = job.run_config.reasoning_effort;
397
398 let mut session = super::session_factory::create_schedule_session(
399 &job,
400 &resolved_model,
401 &resolved.system_prompt,
402 &resolved.base_system_prompt,
403 resolved.workspace_path.as_deref(),
404 resolved.reasoning_effort,
405 &ctx.workspace_resolver,
406 );
407 let session_id = session.id.clone();
408 if let Some(config) = ctx.permission_config.as_ref() {
409 if let Some(workspace) = session.workspace.as_ref() {
410 config.register_session_workspace(session_id.clone(), workspace.clone());
411 }
412 record_bamboo_runtime_permission_metadata(&mut session, config.as_ref())
413 .map_err(|error| error.to_string())?;
414 }
415
416 session
421 .agent_runtime_state
422 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
423 .no_human_approver = true;
424
425 let mut prompt_hook_block = None;
426 if let Some(user_index) = session
427 .messages
428 .iter()
429 .rposition(|message| matches!(message.role, Role::User))
430 {
431 let raw_prompt = session.messages[user_index].content.clone();
432 match crate::lifecycle_hooks::apply_user_prompt_submit_hooks(
433 &resolved.lifecycle_hooks,
434 ctx.app_data_dir.clone(),
435 &mut session,
436 &raw_prompt,
437 )
438 .await
439 {
440 Ok(prompt) => session.messages[user_index].content = prompt,
441 Err(reason) => {
442 session.messages.remove(user_index);
443 session.set_last_run_status("error");
444 session.set_last_run_error(reason.clone());
445 prompt_hook_block = Some(reason);
446 }
447 }
448 }
449
450 ctx.persistence
452 .merge_save_runtime(&mut session)
453 .await
454 .map_err(|e| format!("failed to save scheduled session: {e}"))?;
455 if let Err(error) = ctx
456 .schedule_store
457 .bind_run_session(&job.schedule_id, &job.run_id, &session_id)
458 .await
459 {
460 tracing::warn!(
461 "failed to bind session {} to schedule run {} / {}: {}",
462 session_id,
463 job.schedule_id,
464 job.run_id,
465 error
466 );
467 }
468 ctx.sessions_cache.insert(
469 session_id.clone(),
470 Arc::new(parking_lot::RwLock::new(session.clone())),
471 );
472
473 if let Some(reason) = prompt_hook_block {
474 return Err(format!(
475 "UserPromptSubmit hook blocked scheduled run: {reason}"
476 ));
477 }
478
479 let should_execute = job.run_config.auto_execute
481 && session
482 .messages
483 .last()
484 .map(|m| matches!(m.role, Role::User))
485 .unwrap_or(false);
486
487 tracing::info!(
488 "[schedule:{}] created session {} (auto_execute={}, model={}, model_source={}, reasoning_effort={}, reasoning_source={})",
489 job.schedule_id,
490 session_id,
491 job.run_config.auto_execute,
492 resolved_model,
493 if requested_model.is_some() {
494 "schedule.run_config.model"
495 } else {
496 "resolved"
497 },
498 resolved.reasoning_effort.map(|value| value.as_str()).unwrap_or("none"),
499 if requested_reasoning_effort.is_some() {
500 "schedule.run_config.reasoning_effort"
501 } else {
502 "resolved"
503 }
504 );
505 if !should_execute {
506 return Ok(ScheduleRunLifecycleResult::Terminal(
507 ScheduleRunStatus::Success,
508 ));
509 }
510
511 if resolved_model.trim().is_empty() {
513 let msg = "resolved model is empty".to_string();
514 session.add_message(Message::assistant(format!("❌ {msg}"), None));
515 let _ = ctx.persistence.merge_save_runtime(&mut session).await;
516 return Err(msg);
517 }
518
519 let session_tx = get_or_create_event_sender(&ctx.session_event_senders, &session_id).await;
520
521 let execution_reservation = match reserve_session_execution(
524 &ctx.agent,
525 &ctx.agent_runners,
526 &ctx.session_event_senders,
527 &session_id,
528 &session_tx,
529 )
530 .await
531 {
532 SessionExecutionReserveOutcome::Reserved(reservation) => reservation,
533 SessionExecutionReserveOutcome::AlreadyRunning { .. } => {
534 return Ok(ScheduleRunLifecycleResult::Terminal(
535 ScheduleRunStatus::Skipped,
536 ));
537 }
538 };
539
540 crate::app_state::session_events::ensure_notification_relay(
547 &ctx.notification_relay,
548 &session_id,
549 session_tx.clone(),
550 );
551
552 let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
553 session_id.clone(),
554 session_tx.clone(),
555 ctx.agent_runners.clone(),
556 ctx.account_feed_inbox.clone(),
557 );
558
559 let aux_fast_model = resolved.model_roster.fast_model();
566 let aux_fast_provider = resolved.model_roster.fast_model_provider();
567 let aux_background_model = resolved.model_roster.background_model();
568 let aux_background_provider = resolved.model_roster.background_model_provider();
569 let aux_summarization_model = resolved.model_roster.summarization_model();
570 let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
571 let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
572 fast_model_name: aux_fast_model.clone(),
573 fast_model_provider: aux_fast_provider.clone(),
574 background_model_name: aux_background_model.clone(),
575 planning_model_name: None,
576 search_model_name: None,
577 summarization_model_name: aux_summarization_model.clone(),
578 background_model_provider: aux_background_provider.clone(),
579 summarization_model_provider: aux_summarization_provider.clone(),
580 });
581
582 let schedule_store = ctx.schedule_store.clone();
583 let schedule_id_for_state = job.schedule_id.clone();
584 let run_id_for_state = job.run_id.clone();
585 let log_session_id = session_id.clone();
586 let schedule_name_for_notify = job.schedule_name.clone();
587 let notification_relay_for_hook = ctx.notification_relay.clone();
588
589 let on_complete: SessionCompletionHook = Box::new(move |outcome, session| {
590 Box::pin(async move {
591 let terminal_status = if outcome.success {
592 tracing::info!(
593 "[schedule:{}][run:{}][session:{}] scheduled run completed",
594 schedule_id_for_state,
595 run_id_for_state,
596 log_session_id
597 );
598 ScheduleRunStatus::Success
599 } else {
600 let detail = outcome.error.as_deref().unwrap_or("unknown error");
601 session.add_message(Message::assistant(
604 format!("❌ Scheduled run failed: {detail}"),
605 None,
606 ));
607 tracing::warn!(
608 "[schedule:{}][run:{}][session:{}] scheduled run failed: {}",
609 schedule_id_for_state,
610 run_id_for_state,
611 log_session_id,
612 detail
613 );
614 if outcome.cancelled {
615 ScheduleRunStatus::Cancelled
616 } else {
617 ScheduleRunStatus::Failed
618 }
619 };
620
621 let notify_title = schedule_run_title(&schedule_name_for_notify, outcome.success);
629 let notify_body = final_assistant_excerpt(&session.messages).unwrap_or_else(|| {
630 if outcome.success {
631 "Run completed.".to_string()
632 } else {
633 format!(
634 "Run failed: {}",
635 outcome.error.as_deref().unwrap_or("unknown error")
636 )
637 }
638 });
639 notify_schedule_run_outcome(
640 ¬ification_relay_for_hook,
641 &log_session_id,
642 outcome.success,
643 notify_title,
644 notify_body,
645 )
646 .await;
647
648 if let Err(error) = schedule_store
649 .mark_run_terminal(
650 &schedule_id_for_state,
651 &run_id_for_state,
652 terminal_status,
653 None,
654 )
655 .await
656 {
657 tracing::warn!(
658 "failed to mark schedule run terminal state for {} / {}: {}",
659 schedule_id_for_state,
660 run_id_for_state,
661 error
662 );
663 }
664 })
665 });
666
667 spawn_session_execution(SessionExecutionArgs {
668 agent: ctx.agent.clone(),
669 session_id,
670 session,
671 execution_reservation,
672 tools_override: Some(ctx.tools.clone()),
673 provider_override: None,
674 model_roster: resolved.model_roster.clone(),
675 reasoning_effort: resolved.reasoning_effort,
676 reasoning_effort_source: "schedule".to_string(),
677 auxiliary_model_resolver: Some(auxiliary_model_resolver),
678 disabled_filter_resolver: None,
681 disabled_tools: None,
682 disabled_skill_ids: None,
683 selected_skill_ids: None,
684 selected_skill_mode: None,
685 mpsc_tx,
686 image_fallback: None,
687 gold_config: resolved.gold_config.clone(),
688 guardian_config: None,
690 guardian_spawner: None,
691 bash_resume_hook: None,
703 bash_completion_sink: None,
706 app_data_dir: ctx.app_data_dir.clone(),
707 run_budget: None,
710 runners: ctx.agent_runners.clone(),
711 sessions_cache: ctx.sessions_cache.clone(),
712 on_complete: Some(on_complete),
713 child_completion_handler: None,
715 });
716
717 Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
718}
719
720fn validate_schedule_project_at_fire(
721 store: &bamboo_projects::ProjectStore,
722 run_config: &ScheduleRunConfig,
723) -> Result<(), String> {
724 let Some(project_id) = run_config.project_id.as_ref() else {
725 return Ok(());
726 };
727 match store.get(project_id) {
728 Ok(project) if project.status == bamboo_domain::ProjectStatus::Active => Ok(()),
729 Ok(_) => Err(format!(
730 "schedule Project is archived at execution time: {project_id}"
731 )),
732 Err(error) => Err(format!(
733 "schedule Project is unavailable at execution time ({project_id}): {error}"
734 )),
735 }
736}
737
738pub fn build_schedule_context(
744 base: ScheduleContext,
745 config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
746 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
747) -> ScheduleContext {
748 ScheduleContext {
749 schedule_store: base.schedule_store,
750 agent: base.agent,
751 tools: base.tools,
752 permission_config: base.permission_config,
753 sessions_cache: base.sessions_cache,
754 agent_runners: base.agent_runners,
755 session_event_senders: base.session_event_senders,
756 account_feed_inbox: base.account_feed_inbox,
757 app_data_dir: base.app_data_dir,
758 trigger_engine: base.trigger_engine,
759 project_store: base.project_store,
760 workspace_resolver: base.workspace_resolver,
761 persistence: base.persistence,
762 notification_relay: base.notification_relay,
763 resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
764 resolve_run_config_from_config(job, &config, &provider_registry)
765 }),
766 }
767}
768
769fn resolve_run_config_from_config(
770 job: &ScheduleRunJob,
771 config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
772 provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
773) -> ResolvedRunConfig {
774 let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
775
776 let requested_model = job
777 .run_config
778 .model
779 .as_deref()
780 .map(str::trim)
781 .filter(|v| !v.is_empty())
782 .map(|v| v.to_string());
783
784 let model = if let Some(m) = requested_model {
785 m
786 } else {
787 bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
788 .unwrap_or_default()
789 };
790
791 let provider_name = Some(config_snapshot.effective_default_provider().to_string());
792 let provider_type = provider_name.as_deref().and_then(|name| {
793 bamboo_engine::model_config_helper::resolve_provider_type(
794 &config_snapshot,
795 name,
796 provider_registry,
797 )
798 });
799
800 let capability_provider_name = provider_name
801 .as_deref()
802 .unwrap_or(config_snapshot.effective_default_provider());
803 let areas = bamboo_engine::model_areas::resolve_global_area_models(
805 &config_snapshot,
806 capability_provider_name,
807 provider_registry,
808 );
809
810 let requested_reasoning_effort = job.run_config.reasoning_effort;
811 let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
812
813 let global_default_prompt =
814 bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
815 let base_system_prompt = job
816 .run_config
817 .system_prompt
818 .as_deref()
819 .map(str::trim)
820 .filter(|v| !v.is_empty())
821 .unwrap_or(global_default_prompt.as_str());
822
823 let workspace_path = job
824 .run_config
825 .workspace_path
826 .as_deref()
827 .map(str::trim)
828 .filter(|v| !v.is_empty())
829 .map(ToString::to_string)
830 .or_else(|| {
831 config_snapshot
832 .get_default_work_area_path()
833 .map(|path| bamboo_config::paths::path_to_display_string(&path))
834 });
835
836 let enhance_prompt = job
837 .run_config
838 .enhance_prompt
839 .as_deref()
840 .map(str::trim)
841 .filter(|v| !v.is_empty());
842
843 let system_prompt = bamboo_engine::context::assemble_system_prompt(
844 base_system_prompt,
845 enhance_prompt,
846 workspace_path.as_deref(),
847 );
848
849 let model_roster =
850 bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
851
852 ResolvedRunConfig {
853 model_roster,
854 reasoning_effort,
855 gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
856 &config_snapshot,
857 None,
858 ),
859 system_prompt,
860 base_system_prompt: base_system_prompt.to_string(),
861 workspace_path,
862 lifecycle_hooks: config_snapshot.lifecycle_hooks.clone(),
863 }
864}
865
866#[cfg(test)]
867mod build_context_tests {
868 use super::ScheduleRunJob;
869 use super::{resolve_run_config_from_config, validate_schedule_project_at_fire};
870 use bamboo_config::DefaultsConfig;
871 use bamboo_config::{OpenAIConfig, ProviderConfigs};
872 use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
873 use bamboo_llm::{Config, ProviderRegistry};
874 use std::collections::HashMap;
875 use std::sync::Arc;
876 use tokio::sync::RwLock;
877
878 macro_rules! test_config {
879 (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
880 (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
881 (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
882 (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
883 ($($field:ident: $value:expr),* $(,)?) => {{
884 let mut config = Config::default();
885 $(test_config!(@assign config, $field, $value);)*
886 config
887 }};
888 }
889 fn test_job() -> ScheduleRunJob {
890 ScheduleRunJob {
891 run_id: "run-1".to_string(),
892 schedule_id: "schedule-1".to_string(),
893 schedule_name: "nightly".to_string(),
894 run_config: ScheduleRunConfig::default(),
895 scheduled_for: chrono::Utc::now(),
896 claimed_at: chrono::Utc::now(),
897 was_catch_up: false,
898 }
899 }
900
901 #[test]
902 fn archived_project_is_rejected_when_persisted_schedule_fires() {
903 let dir = tempfile::tempdir().unwrap();
904 let store = bamboo_projects::ProjectStore::open(dir.path()).unwrap();
905 let project = store.create("Scheduled", None).unwrap();
906 let run_config = ScheduleRunConfig {
907 project_id: Some(project.id.clone()),
908 ..ScheduleRunConfig::default()
909 };
910 assert!(validate_schedule_project_at_fire(&store, &run_config).is_ok());
911 store.archive(&project.id, project.revision).unwrap();
912 let error = validate_schedule_project_at_fire(&store, &run_config).unwrap_err();
913 assert!(error.contains("archived at execution time"));
914 }
915
916 #[test]
917 fn resolve_run_config_from_config_prefers_fast_model() {
918 let config = test_config! {
919 provider: "openai".to_string(),
920 defaults: None,
921 features: bamboo_config::FeatureFlags {
922 provider_model_ref: false,
923 ..Default::default()
924 },
925 providers: ProviderConfigs {
926 openai: Some(OpenAIConfig {
927 api_key: "test".to_string(),
928 api_key_from_env: false,
929 api_key_encrypted: None,
930 credential_ref: None,
931 base_url: None,
932 model: Some("gpt-4o".to_string()),
933 fast_model: Some("gpt-4o-mini".to_string()),
934 vision_model: None,
935 reasoning_effort: None,
936 responses_only_models: vec![],
937 request_overrides: None,
938 extra: Default::default(),
939 }),
940 ..ProviderConfigs::default()
941 },
942 };
943
944 let registry = Arc::new(ProviderRegistry::new(
945 Default::default(),
946 "openai".to_string(),
947 ));
948 let resolved =
949 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
950 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
951 }
952
953 #[test]
954 fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
955 let config = test_config! {
956 provider: "openai".to_string(),
957 defaults: Some(DefaultsConfig {
958 chat: ProviderModelRef::new("openai", "gpt-chat"),
959 fast: None,
960 task_summary: None,
961 vision: None,
962 memory_background: None,
963 planning: None,
964 search: None,
965 code_review: None,
966 sub_agent: None,
967 subagent_models: HashMap::new(),
968 }),
969 features: bamboo_config::FeatureFlags {
970 provider_model_ref: true,
971 ..Default::default()
972 },
973 providers: ProviderConfigs::default(),
974 };
975
976 let registry = Arc::new(ProviderRegistry::new(
977 Default::default(),
978 "openai".to_string(),
979 ));
980 let resolved =
981 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
982 assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
983 }
984
985 #[test]
986 fn resolve_run_config_from_config_snapshots_lifecycle_hooks_for_scheduled_runs() {
987 let lifecycle_hooks = bamboo_config::LifecycleHooksConfig {
988 enabled: true,
989 session_start: vec![bamboo_config::LifecycleHookGroup {
990 enabled: true,
991 matcher: None,
992 hooks: vec![bamboo_config::LifecycleHookHandler::command(
993 "printf schedule-start",
994 bamboo_config::DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
995 )],
996 }],
997 ..Default::default()
998 };
999 let config = test_config! {
1000 lifecycle_hooks: lifecycle_hooks.clone(),
1001 };
1002 let registry = Arc::new(ProviderRegistry::new(
1003 Default::default(),
1004 "openai".to_string(),
1005 ));
1006
1007 let resolved =
1008 resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), ®istry);
1009
1010 assert_eq!(resolved.lifecycle_hooks, lifecycle_hooks);
1011 }
1012}
1013
1014#[cfg(test)]
1015mod notify_outcome_tests {
1016 use super::*;
1017 use crate::app_state::session_events::NotificationRelayDeps;
1018 use crate::app_state::watchers::SessionWatchers;
1019 use std::collections::HashMap;
1020 use std::time::Duration;
1021 use tokio::sync::RwLock as TokioRwLock;
1022
1023 fn relay_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
1024 let dir = tempfile::tempdir().unwrap();
1025 let notification_service = Arc::new(bamboo_notification::NotificationService::new(
1026 dir.path().join("prefs.json"),
1027 ));
1028 let deps = NotificationRelayDeps {
1029 notification_service,
1030 session_event_senders: Arc::new(TokioRwLock::new(HashMap::new())),
1031 session_watchers: SessionWatchers::new(),
1032 config: Arc::new(TokioRwLock::new(bamboo_llm::Config::default())),
1033 };
1034 (deps, dir)
1035 }
1036
1037 #[test]
1038 fn truncate_chars_appends_ellipsis_only_when_cut() {
1039 assert_eq!(truncate_chars("short", 10), "short");
1040 let truncated = truncate_chars(&"x".repeat(300), SCHEDULE_NOTIFY_BODY_MAX);
1041 assert_eq!(truncated.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
1042 assert!(truncated.ends_with('…'));
1043 }
1044
1045 #[test]
1046 fn schedule_run_title_names_the_schedule_and_outcome() {
1047 assert_eq!(
1048 schedule_run_title("nightly", true),
1049 "Schedule 'nightly' completed"
1050 );
1051 assert_eq!(
1052 schedule_run_title("nightly", false),
1053 "Schedule 'nightly' failed"
1054 );
1055 }
1056
1057 #[test]
1058 fn final_assistant_excerpt_finds_the_last_non_empty_assistant_message() {
1059 let messages = vec![
1060 Message::user("hi"),
1061 Message::assistant("first reply", None),
1062 Message::assistant(" ", None), Message::assistant("final reply", None),
1064 ];
1065 assert_eq!(
1066 final_assistant_excerpt(&messages).as_deref(),
1067 Some("final reply")
1068 );
1069 }
1070
1071 #[test]
1072 fn final_assistant_excerpt_truncates_long_content() {
1073 let long = "x".repeat(300);
1074 let messages = vec![Message::assistant(long, None)];
1075 let excerpt = final_assistant_excerpt(&messages).unwrap();
1076 assert_eq!(excerpt.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
1077 assert!(excerpt.ends_with('…'));
1078 }
1079
1080 #[test]
1081 fn final_assistant_excerpt_none_when_no_assistant_message() {
1082 let messages = vec![Message::user("hi")];
1083 assert!(final_assistant_excerpt(&messages).is_none());
1084 }
1085
1086 #[tokio::test]
1096 async fn notify_schedule_run_outcome_is_deduped_by_a_prior_generic_complete() {
1097 let (deps, _dir) = relay_deps();
1098 let (tx, mut rx) = broadcast::channel(16);
1099 deps.session_event_senders
1100 .write()
1101 .await
1102 .insert("sess-1".to_string(), tx.clone());
1103
1104 let relay_fired = deps.notification_service.notify(
1108 "sess-1",
1109 &AgentEvent::Complete {
1110 usage: bamboo_agent_core::TokenUsage {
1111 prompt_tokens: 1,
1112 completion_tokens: 1,
1113 total_tokens: 2,
1114 },
1115 },
1116 );
1117 assert!(relay_fired.is_some());
1118
1119 notify_schedule_run_outcome(
1120 &deps,
1121 "sess-1",
1122 true,
1123 "Schedule 'nightly' completed".to_string(),
1124 "All done.".to_string(),
1125 )
1126 .await;
1127
1128 let outcome = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
1131 assert!(
1132 outcome.is_err(),
1133 "deduped schedule-level notification must not broadcast a second event"
1134 );
1135 }
1136
1137 #[tokio::test]
1141 async fn notify_schedule_run_outcome_first_dedups_a_later_generic_complete() {
1142 let (deps, _dir) = relay_deps();
1143
1144 notify_schedule_run_outcome(
1145 &deps,
1146 "sess-2",
1147 true,
1148 "Schedule 'nightly' completed".to_string(),
1149 "All done.".to_string(),
1150 )
1151 .await;
1152
1153 let relay_fired = deps.notification_service.notify(
1154 "sess-2",
1155 &AgentEvent::Complete {
1156 usage: bamboo_agent_core::TokenUsage {
1157 prompt_tokens: 1,
1158 completion_tokens: 1,
1159 total_tokens: 2,
1160 },
1161 },
1162 );
1163 assert!(
1164 relay_fired.is_none(),
1165 "the later generic classification must be deduped away"
1166 );
1167 }
1168}