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    /// Dependencies to start the always-on notification relay (see
66    /// `crate::app_state::session_events::ensure_notification_relay`).
67    /// Scheduled runs previously never classified events into notifications
68    /// at all — nothing spawned a relay for a session no SSE/WS client had
69    /// ever subscribed to, which is the common case for a headless run.
70    pub notification_relay: crate::app_state::session_events::NotificationRelayDeps,
71    /// Adapter-provided callback that resolves model, system prompt, workspace path
72    /// and reasoning effort for a schedule run job.
73    pub resolve_run_config: Arc<dyn Fn(&ScheduleRunJob) -> ResolvedRunConfig + Send + Sync>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum ScheduleRunLifecycleResult {
78    Terminal(ScheduleRunStatus),
79    BackgroundExecutionInProgress,
80}
81
82#[derive(Clone)]
83pub struct ScheduleManager {
84    tx: mpsc::Sender<ScheduleRunJob>,
85}
86
87impl ScheduleManager {
88    pub fn new(ctx: ScheduleContext) -> Self {
89        let (tx, mut rx) = mpsc::channel::<ScheduleRunJob>(128);
90
91        // Worker: executes jobs sequentially (simple + predictable).
92        tokio::spawn({
93            let ctx = ctx.clone();
94            async move {
95                while let Some(job) = rx.recv().await {
96                    if let Err(error) = ctx
97                        .schedule_store
98                        .mark_run_started(&job.schedule_id, &job.run_id)
99                        .await
100                    {
101                        tracing::warn!(
102                            "failed to mark schedule run started for {} / {}: {}",
103                            job.schedule_id,
104                            job.run_id,
105                            error
106                        );
107                    }
108                    let schedule_id = job.schedule_id.clone();
109                    let run_id = job.run_id.clone();
110                    match run_schedule_job(ctx.clone(), job).await {
111                        Ok(ScheduleRunLifecycleResult::Terminal(status)) => {
112                            if let Err(error) = ctx
113                                .schedule_store
114                                .mark_run_terminal(&schedule_id, &run_id, status, None)
115                                .await
116                            {
117                                tracing::warn!(
118                                    "failed to mark schedule run terminal state for {} / {}: {}",
119                                    schedule_id,
120                                    run_id,
121                                    error
122                                );
123                            }
124                        }
125                        Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress) => {}
126                        Err(e) => {
127                            tracing::warn!("schedule job failed: {e}");
128                            if let Err(error) = ctx
129                                .schedule_store
130                                .mark_run_terminal(
131                                    &schedule_id,
132                                    &run_id,
133                                    ScheduleRunStatus::Failed,
134                                    Some(e.clone()),
135                                )
136                                .await
137                            {
138                                tracing::warn!(
139                                    "failed to mark schedule run failed state for {} / {}: {}",
140                                    schedule_id,
141                                    run_id,
142                                    error
143                                );
144                            }
145                        }
146                    }
147                }
148            }
149        });
150
151        // Ticker: claims due schedules and enqueues jobs.
152        tokio::spawn({
153            let tx = tx.clone();
154            let store = ctx.schedule_store.clone();
155            let trigger_engine = ctx.trigger_engine.clone();
156            async move {
157                let mut ticker = tokio::time::interval(Duration::from_secs(15));
158                loop {
159                    ticker.tick().await;
160                    let now = Utc::now();
161                    let claimed: Vec<ClaimedScheduleRun> = match store
162                        .claim_due_runs_with_engine(now, trigger_engine.as_ref())
163                        .await
164                    {
165                        Ok(v) => v,
166                        Err(e) => {
167                            tracing::warn!("claim_due_runs failed: {e}");
168                            continue;
169                        }
170                    };
171                    for c in claimed {
172                        let schedule_id = c.schedule_id.clone();
173                        let run_id = c.run_id.clone();
174                        if tx
175                            .send(ScheduleRunJob {
176                                run_id: c.run_id,
177                                schedule_id: c.schedule_id,
178                                schedule_name: c.schedule_name,
179                                run_config: c.run_config,
180                                scheduled_for: c.scheduled_for,
181                                claimed_at: c.claimed_at,
182                                was_catch_up: c.was_catch_up,
183                            })
184                            .await
185                            .is_err()
186                        {
187                            let _ = store
188                                .mark_run_dequeued_without_start(
189                                    &schedule_id,
190                                    &run_id,
191                                    Some("schedule manager is not running".to_string()),
192                                )
193                                .await;
194                        }
195                    }
196                }
197            }
198        });
199
200        Self { tx }
201    }
202
203    pub async fn enqueue_run_now(&self, job: ScheduleRunJob) -> Result<(), String> {
204        self.tx
205            .send(job)
206            .await
207            .map_err(|_| "schedule manager is not running".to_string())
208    }
209}
210
211/// Maximum length, in characters, of the final-assistant-message excerpt
212/// used as a schedule-completion notification body (mirrors
213/// `bamboo_notification::policy`'s `RUN_FAILED_BODY_MAX`, which isn't
214/// exported for reuse here).
215const SCHEDULE_NOTIFY_BODY_MAX: usize = 200;
216
217/// Unicode-safe truncation to at most `max` chars, appending an ellipsis when
218/// cut.
219fn truncate_chars(text: &str, max: usize) -> String {
220    if text.chars().count() <= max {
221        return text.to_string();
222    }
223    let mut out: String = text.chars().take(max).collect();
224    out.push('…');
225    out
226}
227
228/// Builds the "Schedule '<name>' completed|failed" notification title.
229fn schedule_run_title(schedule_name: &str, success: bool) -> String {
230    if success {
231        format!("Schedule '{schedule_name}' completed")
232    } else {
233        format!("Schedule '{schedule_name}' failed")
234    }
235}
236
237/// Excerpts the most recent non-empty assistant message from `messages`
238/// (walking back from the end), truncated to [`SCHEDULE_NOTIFY_BODY_MAX`]
239/// chars. This is "cheaply reachable" because the session is already in
240/// memory at the point the completion hook runs — no extra fetch or compute.
241/// Returns `None` when there is no such message, so the caller can fall back
242/// to a run-status string.
243fn final_assistant_excerpt(messages: &[Message]) -> Option<String> {
244    messages
245        .iter()
246        .rev()
247        .find(|m| matches!(m.role, Role::Assistant) && !m.content.trim().is_empty())
248        .map(|m| truncate_chars(m.content.trim(), SCHEDULE_NOTIFY_BODY_MAX))
249}
250
251/// Emits a schedule-specific completion/failure notification, enriching the
252/// generic `run_completed`/`run_failed` notification the always-on relay
253/// (`ensure_notification_relay`, wired in [`run_schedule_job`] below) already
254/// produces from the raw `AgentEvent::Complete`/`Error` this run's agent loop
255/// emits.
256///
257/// No-double-fire design: both sources mint through
258/// [`bamboo_notification::NotificationService::notify_schedule_run`] /
259/// `notify`, which share the SAME dedup key
260/// (`bamboo_notification::policy::classify_schedule_run`'s doc comment has
261/// the full rationale) within the service's 30s dedup window — so whichever
262/// of the two actually reaches the service first "wins" the user-visible
263/// copy and the second is silently coalesced. The run can therefore never
264/// double-notify its owner; this call is always safe to make unconditionally
265/// alongside the relay.
266async fn notify_schedule_run_outcome(
267    relay: &crate::app_state::session_events::NotificationRelayDeps,
268    session_id: &str,
269    success: bool,
270    title: String,
271    body: String,
272) {
273    let Some(notification) = relay
274        .notification_service
275        .notify_schedule_run(session_id, success, title, body)
276    else {
277        // Deduped away by the generic relay-classified notification (or
278        // notifications/this category are disabled) — nothing to deliver.
279        return;
280    };
281
282    // Build the sink payload before `notification` is moved into the
283    // broadcast send below (mirrors `ensure_notification_relay`).
284    let sink_notification = crate::notify_sinks::SinkNotification::from_event(&notification);
285
286    let tx = relay
287        .session_event_senders
288        .read()
289        .await
290        .get(session_id)
291        .cloned();
292    if let Some(tx) = tx {
293        let _ = tx.send(notification);
294    }
295
296    if let Some(sink_notification) = sink_notification {
297        let has_watcher = relay.session_watchers.has_watcher(session_id);
298        let config_snapshot = relay.config.read().await.clone();
299        crate::AppState::dispatch_to_sinks(&config_snapshot, has_watcher, &sink_notification);
300    }
301}
302
303async fn run_schedule_job(
304    ctx: ScheduleContext,
305    job: ScheduleRunJob,
306) -> Result<ScheduleRunLifecycleResult, String> {
307    let resolved = (ctx.resolve_run_config)(&job);
308    // Primary model is required for a schedule run; the roster stores it as
309    // `Option<String>`, so recover the owned String once for the checks/logging
310    // below (an absent primary is treated as the old empty-string skip).
311    let resolved_model = resolved.model_roster.model.clone().unwrap_or_default();
312
313    // If the adapter resolved an empty model, skip the run.
314    if resolved_model.trim().is_empty() {
315        tracing::warn!(
316            "[schedule:{}] skipping run: resolved model is empty",
317            job.schedule_id
318        );
319        return Ok(ScheduleRunLifecycleResult::Terminal(
320            ScheduleRunStatus::Skipped,
321        ));
322    }
323
324    let requested_model = job
325        .run_config
326        .model
327        .as_deref()
328        .map(str::trim)
329        .filter(|v| !v.is_empty())
330        .map(|v| v.to_string());
331    let requested_reasoning_effort = job.run_config.reasoning_effort;
332
333    let mut session = super::session_factory::create_schedule_session(
334        &job,
335        &resolved_model,
336        &resolved.system_prompt,
337        &resolved.base_system_prompt,
338        resolved.workspace_path.as_deref(),
339        resolved.reasoning_effort,
340    );
341    let session_id = session.id.clone();
342
343    // #73: a scheduled run has no interactive human approver — mark the root so
344    // its sub-agents (which inherit the flag) decide gated actions with the
345    // off-loop model-reviewer locally instead of escalating to an absent human,
346    // which would 300s-deny.
347    session
348        .agent_runtime_state
349        .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
350        .no_human_approver = true;
351
352    // Persist session and index entry.
353    ctx.persistence
354        .merge_save_runtime(&mut session)
355        .await
356        .map_err(|e| format!("failed to save scheduled session: {e}"))?;
357    if let Err(error) = ctx
358        .schedule_store
359        .bind_run_session(&job.schedule_id, &job.run_id, &session_id)
360        .await
361    {
362        tracing::warn!(
363            "failed to bind session {} to schedule run {} / {}: {}",
364            session_id,
365            job.schedule_id,
366            job.run_id,
367            error
368        );
369    }
370    ctx.sessions_cache.insert(
371        session_id.clone(),
372        Arc::new(parking_lot::RwLock::new(session.clone())),
373    );
374
375    // If no task message (or not configured to execute), we're done.
376    let should_execute = job.run_config.auto_execute
377        && session
378            .messages
379            .last()
380            .map(|m| matches!(m.role, Role::User))
381            .unwrap_or(false);
382
383    tracing::info!(
384        "[schedule:{}] created session {} (auto_execute={}, model={}, model_source={}, reasoning_effort={}, reasoning_source={})",
385        job.schedule_id,
386        session_id,
387        job.run_config.auto_execute,
388        resolved_model,
389        if requested_model.is_some() {
390            "schedule.run_config.model"
391        } else {
392            "resolved"
393        },
394        resolved.reasoning_effort.map(|value| value.as_str()).unwrap_or("none"),
395        if requested_reasoning_effort.is_some() {
396            "schedule.run_config.reasoning_effort"
397        } else {
398            "resolved"
399        }
400    );
401    if !should_execute {
402        return Ok(ScheduleRunLifecycleResult::Terminal(
403            ScheduleRunStatus::Success,
404        ));
405    }
406
407    // Model is required by the provider trait; if resolution failed we'd have returned earlier.
408    if resolved_model.trim().is_empty() {
409        let msg = "resolved model is empty".to_string();
410        session.add_message(Message::assistant(format!("❌ {msg}"), None));
411        let _ = ctx.persistence.merge_save_runtime(&mut session).await;
412        return Err(msg);
413    }
414
415    let session_tx = get_or_create_event_sender(&ctx.session_event_senders, &session_id).await;
416
417    // Always-on relay (the critical gap this closes): a scheduled/headless
418    // run has no SSE/WS client subscribed at start — often ever — so nothing
419    // used to spawn a notification relay for it, and approval/clarification/
420    // context/completion events for scheduled sessions never classified into
421    // notifications. Idempotent (`try_begin_relay`), so this harmlessly races
422    // a client that later opens the session's live stream.
423    crate::app_state::session_events::ensure_notification_relay(
424        &ctx.notification_relay,
425        &session_id,
426        session_tx.clone(),
427    );
428
429    // Insert runner status (for cancellation/status introspection).
430    let Some(RunnerReservation { cancel_token, .. }) = try_reserve_runner(
431        &ctx.agent_runners,
432        &ctx.session_event_senders,
433        &session_id,
434        &session_tx,
435    )
436    .await
437    else {
438        return Ok(ScheduleRunLifecycleResult::Terminal(
439            ScheduleRunStatus::Skipped,
440        ));
441    };
442
443    let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
444        session_id.clone(),
445        session_tx.clone(),
446        ctx.agent_runners.clone(),
447        ctx.account_feed_inbox.clone(),
448    );
449
450    // Run the agent loop in the background via the single canonical execution
451    // path (`spawn_session_execution`), the same one the HTTP execute handler
452    // and the child-completion coordinator use. Schedule-specific finalization
453    // (marking the run terminal, and writing a visible failure marker) is
454    // carried by the `on_complete` hook, which runs after the runner is
455    // finalized but before the session is persisted — so the marker is saved.
456    let aux_fast_model = resolved.model_roster.fast_model();
457    let aux_fast_provider = resolved.model_roster.fast_model_provider();
458    let aux_background_model = resolved.model_roster.background_model();
459    let aux_background_provider = resolved.model_roster.background_model_provider();
460    let aux_summarization_model = resolved.model_roster.summarization_model();
461    let aux_summarization_provider = resolved.model_roster.summarization_model_provider();
462    let auxiliary_model_resolver = Arc::new(move || AuxiliaryModelConfig {
463        fast_model_name: aux_fast_model.clone(),
464        fast_model_provider: aux_fast_provider.clone(),
465        background_model_name: aux_background_model.clone(),
466        planning_model_name: None,
467        search_model_name: None,
468        summarization_model_name: aux_summarization_model.clone(),
469        background_model_provider: aux_background_provider.clone(),
470        summarization_model_provider: aux_summarization_provider.clone(),
471    });
472
473    let schedule_store = ctx.schedule_store.clone();
474    let schedule_id_for_state = job.schedule_id.clone();
475    let run_id_for_state = job.run_id.clone();
476    let log_session_id = session_id.clone();
477    let schedule_name_for_notify = job.schedule_name.clone();
478    let notification_relay_for_hook = ctx.notification_relay.clone();
479
480    let on_complete: SessionCompletionHook = Box::new(move |outcome, session| {
481        Box::pin(async move {
482            let terminal_status = if outcome.success {
483                tracing::info!(
484                    "[schedule:{}][run:{}][session:{}] scheduled run completed",
485                    schedule_id_for_state,
486                    run_id_for_state,
487                    log_session_id
488                );
489                ScheduleRunStatus::Success
490            } else {
491                let detail = outcome.error.as_deref().unwrap_or("unknown error");
492                // Persist a visible failure marker so the user can open the
493                // scheduled session and understand why it produced no output.
494                session.add_message(Message::assistant(
495                    format!("❌ Scheduled run failed: {detail}"),
496                    None,
497                ));
498                tracing::warn!(
499                    "[schedule:{}][run:{}][session:{}] scheduled run failed: {}",
500                    schedule_id_for_state,
501                    run_id_for_state,
502                    log_session_id,
503                    detail
504                );
505                if outcome.cancelled {
506                    ScheduleRunStatus::Cancelled
507                } else {
508                    ScheduleRunStatus::Failed
509                }
510            };
511
512            // Owner notification, enriched with the schedule's name and the
513            // final assistant message (see `notify_schedule_run_outcome`'s
514            // doc comment for why this can never double-fire alongside the
515            // always-on relay's generic classification of the same run's
516            // raw `AgentEvent::Complete`/`Error`). Placed AFTER the failure
517            // marker above is appended to `session.messages`, so a failed
518            // run's body is that marker's text via `final_assistant_excerpt`.
519            let notify_title = schedule_run_title(&schedule_name_for_notify, outcome.success);
520            let notify_body = final_assistant_excerpt(&session.messages).unwrap_or_else(|| {
521                if outcome.success {
522                    "Run completed.".to_string()
523                } else {
524                    format!(
525                        "Run failed: {}",
526                        outcome.error.as_deref().unwrap_or("unknown error")
527                    )
528                }
529            });
530            notify_schedule_run_outcome(
531                &notification_relay_for_hook,
532                &log_session_id,
533                outcome.success,
534                notify_title,
535                notify_body,
536            )
537            .await;
538
539            if let Err(error) = schedule_store
540                .mark_run_terminal(
541                    &schedule_id_for_state,
542                    &run_id_for_state,
543                    terminal_status,
544                    None,
545                )
546                .await
547            {
548                tracing::warn!(
549                    "failed to mark schedule run terminal state for {} / {}: {}",
550                    schedule_id_for_state,
551                    run_id_for_state,
552                    error
553                );
554            }
555        })
556    });
557
558    spawn_session_execution(SessionExecutionArgs {
559        agent: ctx.agent.clone(),
560        session_id,
561        session,
562        tools_override: Some(ctx.tools.clone()),
563        provider_override: None,
564        model_roster: resolved.model_roster.clone(),
565        reasoning_effort: resolved.reasoning_effort,
566        reasoning_effort_source: "schedule".to_string(),
567        auxiliary_model_resolver: Some(auxiliary_model_resolver),
568        // Scheduled runs use the per-run disabled snapshot (#136 lives on the
569        // interactive agent path; a scheduled task is a discrete run).
570        disabled_filter_resolver: None,
571        disabled_tools: None,
572        disabled_skill_ids: None,
573        selected_skill_ids: None,
574        selected_skill_mode: None,
575        cancel_token,
576        mpsc_tx,
577        image_fallback: None,
578        gold_config: resolved.gold_config.clone(),
579        // Guardian review is not wired into the schedule path for now.
580        guardian_config: None,
581        guardian_spawner: None,
582        // No bash self-resume hook on the schedule path: the end-of-turn bash
583        // suspend gate is therefore inert here (it requires a wired hook).
584        // Because the loop can't resume a backgrounded shell, the Bash tool's
585        // auto path detects this (can_async_resume == false, derived from
586        // hook+persistence) and stays purely synchronous — a long command on
587        // the default path blocks to its timeout rather than promoting to an
588        // orphaned background shell whose output this loop could never await
589        // (issue #84, phase 2d). An explicitly backgrounded shell
590        // (`run_in_background: true`) still runs detached and stays readable via
591        // BashOutput; no strand can occur because the gate refuses to suspend
592        // without the hook.
593        bash_resume_hook: None,
594        // Hook-less loop: no suspend/resume machinery, so stay push-free too
595        // (consistent with `can_async_resume: false` on this path).
596        bash_completion_sink: None,
597        app_data_dir: ctx.app_data_dir.clone(),
598        runners: ctx.agent_runners.clone(),
599        sessions_cache: ctx.sessions_cache.clone(),
600        on_complete: Some(on_complete),
601    });
602
603    Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
604}
605
606/// Build a [`ScheduleContext`] with server-specific config resolution.
607///
608/// Callers should prefer this over constructing `ScheduleContext` directly
609/// to ensure the `resolve_run_config` callback correctly reads Config and
610/// prompt defaults.
611pub fn build_schedule_context(
612    base: ScheduleContext,
613    config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
614    provider_registry: Arc<bamboo_llm::ProviderRegistry>,
615) -> ScheduleContext {
616    ScheduleContext {
617        schedule_store: base.schedule_store,
618        agent: base.agent,
619        tools: base.tools,
620        sessions_cache: base.sessions_cache,
621        agent_runners: base.agent_runners,
622        session_event_senders: base.session_event_senders,
623        account_feed_inbox: base.account_feed_inbox,
624        app_data_dir: base.app_data_dir,
625        trigger_engine: base.trigger_engine,
626        persistence: base.persistence,
627        notification_relay: base.notification_relay,
628        resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
629            resolve_run_config_from_config(job, &config, &provider_registry)
630        }),
631    }
632}
633
634fn resolve_run_config_from_config(
635    job: &ScheduleRunJob,
636    config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
637    provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
638) -> ResolvedRunConfig {
639    let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
640
641    let requested_model = job
642        .run_config
643        .model
644        .as_deref()
645        .map(str::trim)
646        .filter(|v| !v.is_empty())
647        .map(|v| v.to_string());
648
649    let model = if let Some(m) = requested_model {
650        m
651    } else {
652        bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
653            .unwrap_or_default()
654    };
655
656    let provider_name = Some(config_snapshot.effective_default_provider().to_string());
657    let provider_type = provider_name.as_deref().and_then(|name| {
658        bamboo_engine::model_config_helper::resolve_provider_type(
659            &config_snapshot,
660            name,
661            provider_registry,
662        )
663    });
664
665    let capability_provider_name = provider_name
666        .as_deref()
667        .unwrap_or(config_snapshot.effective_default_provider());
668    // Auxiliary models are global (config-derived), never session-bound.
669    let areas = bamboo_engine::model_areas::resolve_global_area_models(
670        &config_snapshot,
671        capability_provider_name,
672        provider_registry,
673    );
674
675    let requested_reasoning_effort = job.run_config.reasoning_effort;
676    let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
677
678    let global_default_prompt =
679        bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
680    let base_system_prompt = job
681        .run_config
682        .system_prompt
683        .as_deref()
684        .map(str::trim)
685        .filter(|v| !v.is_empty())
686        .unwrap_or(global_default_prompt.as_str());
687
688    let workspace_path = job
689        .run_config
690        .workspace_path
691        .as_deref()
692        .map(str::trim)
693        .filter(|v| !v.is_empty())
694        .map(ToString::to_string)
695        .or_else(|| {
696            config_snapshot
697                .get_default_work_area_path()
698                .map(|path| bamboo_config::paths::path_to_display_string(&path))
699        });
700
701    let enhance_prompt = job
702        .run_config
703        .enhance_prompt
704        .as_deref()
705        .map(str::trim)
706        .filter(|v| !v.is_empty());
707
708    let system_prompt = bamboo_engine::context::assemble_system_prompt(
709        base_system_prompt,
710        enhance_prompt,
711        workspace_path.as_deref(),
712    );
713
714    let model_roster =
715        bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
716
717    ResolvedRunConfig {
718        model_roster,
719        reasoning_effort,
720        gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
721            &config_snapshot,
722            None,
723        ),
724        system_prompt,
725        base_system_prompt: base_system_prompt.to_string(),
726        workspace_path,
727    }
728}
729
730#[cfg(test)]
731mod build_context_tests {
732    use super::resolve_run_config_from_config;
733    use super::ScheduleRunJob;
734    use bamboo_config::DefaultsConfig;
735    use bamboo_config::{OpenAIConfig, ProviderConfigs};
736    use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
737    use bamboo_llm::{Config, ProviderRegistry};
738    use std::collections::HashMap;
739    use std::sync::Arc;
740    use tokio::sync::RwLock;
741
742    fn test_job() -> ScheduleRunJob {
743        ScheduleRunJob {
744            run_id: "run-1".to_string(),
745            schedule_id: "schedule-1".to_string(),
746            schedule_name: "nightly".to_string(),
747            run_config: ScheduleRunConfig::default(),
748            scheduled_for: chrono::Utc::now(),
749            claimed_at: chrono::Utc::now(),
750            was_catch_up: false,
751        }
752    }
753
754    #[test]
755    fn resolve_run_config_from_config_prefers_fast_model() {
756        let config = Config {
757            provider: "openai".to_string(),
758            defaults: None,
759            features: bamboo_config::FeatureFlags {
760                provider_model_ref: false,
761                ..Default::default()
762            },
763            providers: ProviderConfigs {
764                openai: Some(OpenAIConfig {
765                    api_key: "test".to_string(),
766                    api_key_from_env: false,
767                    api_key_encrypted: None,
768                    base_url: None,
769                    model: Some("gpt-4o".to_string()),
770                    fast_model: Some("gpt-4o-mini".to_string()),
771                    vision_model: None,
772                    reasoning_effort: None,
773                    responses_only_models: vec![],
774                    request_overrides: None,
775                    extra: Default::default(),
776                }),
777                ..ProviderConfigs::default()
778            },
779            ..Config::default()
780        };
781
782        let registry = Arc::new(ProviderRegistry::new(
783            Default::default(),
784            "openai".to_string(),
785        ));
786        let resolved =
787            resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), &registry);
788        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
789    }
790
791    #[test]
792    fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
793        let config = Config {
794            provider: "openai".to_string(),
795            defaults: Some(DefaultsConfig {
796                chat: ProviderModelRef::new("openai", "gpt-chat"),
797                fast: None,
798                task_summary: None,
799                vision: None,
800                memory_background: None,
801                planning: None,
802                search: None,
803                code_review: None,
804                sub_agent: None,
805                subagent_models: HashMap::new(),
806            }),
807            features: bamboo_config::FeatureFlags {
808                provider_model_ref: true,
809                ..Default::default()
810            },
811            providers: ProviderConfigs::default(),
812            ..Config::default()
813        };
814
815        let registry = Arc::new(ProviderRegistry::new(
816            Default::default(),
817            "openai".to_string(),
818        ));
819        let resolved =
820            resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), &registry);
821        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
822    }
823}
824
825#[cfg(test)]
826mod notify_outcome_tests {
827    use super::*;
828    use crate::app_state::session_events::NotificationRelayDeps;
829    use crate::app_state::watchers::SessionWatchers;
830    use std::collections::HashMap;
831    use std::time::Duration;
832    use tokio::sync::RwLock as TokioRwLock;
833
834    fn relay_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
835        let dir = tempfile::tempdir().unwrap();
836        let notification_service = Arc::new(bamboo_notification::NotificationService::new(
837            dir.path().join("prefs.json"),
838        ));
839        let deps = NotificationRelayDeps {
840            notification_service,
841            session_event_senders: Arc::new(TokioRwLock::new(HashMap::new())),
842            session_watchers: SessionWatchers::new(),
843            config: Arc::new(TokioRwLock::new(bamboo_llm::Config::default())),
844        };
845        (deps, dir)
846    }
847
848    #[test]
849    fn truncate_chars_appends_ellipsis_only_when_cut() {
850        assert_eq!(truncate_chars("short", 10), "short");
851        let truncated = truncate_chars(&"x".repeat(300), SCHEDULE_NOTIFY_BODY_MAX);
852        assert_eq!(truncated.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
853        assert!(truncated.ends_with('…'));
854    }
855
856    #[test]
857    fn schedule_run_title_names_the_schedule_and_outcome() {
858        assert_eq!(
859            schedule_run_title("nightly", true),
860            "Schedule 'nightly' completed"
861        );
862        assert_eq!(
863            schedule_run_title("nightly", false),
864            "Schedule 'nightly' failed"
865        );
866    }
867
868    #[test]
869    fn final_assistant_excerpt_finds_the_last_non_empty_assistant_message() {
870        let messages = vec![
871            Message::user("hi"),
872            Message::assistant("first reply", None),
873            Message::assistant("   ", None), // blank — skipped
874            Message::assistant("final reply", None),
875        ];
876        assert_eq!(
877            final_assistant_excerpt(&messages).as_deref(),
878            Some("final reply")
879        );
880    }
881
882    #[test]
883    fn final_assistant_excerpt_truncates_long_content() {
884        let long = "x".repeat(300);
885        let messages = vec![Message::assistant(long, None)];
886        let excerpt = final_assistant_excerpt(&messages).unwrap();
887        assert_eq!(excerpt.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
888        assert!(excerpt.ends_with('…'));
889    }
890
891    #[test]
892    fn final_assistant_excerpt_none_when_no_assistant_message() {
893        let messages = vec![Message::user("hi")];
894        assert!(final_assistant_excerpt(&messages).is_none());
895    }
896
897    /// The manager-level no-double-fire guarantee, exercised through
898    /// [`notify_schedule_run_outcome`] itself (not just the underlying
899    /// `NotificationService` primitive it wraps): the generic relay path
900    /// (raw `AgentEvent::Complete`, classified via
901    /// `NotificationService::notify`) firing FIRST must dedup away a
902    /// subsequent schedule-level enrichment call for the same session — the
903    /// scenario `ensure_notification_relay` (spawned before
904    /// `spawn_session_execution` in `run_schedule_job`) races against this
905    /// hook.
906    #[tokio::test]
907    async fn notify_schedule_run_outcome_is_deduped_by_a_prior_generic_complete() {
908        let (deps, _dir) = relay_deps();
909        let (tx, mut rx) = broadcast::channel(16);
910        deps.session_event_senders
911            .write()
912            .await
913            .insert("sess-1".to_string(), tx.clone());
914
915        // Simulate the always-on relay having already classified the raw
916        // AgentEvent::Complete for this session (inserts the shared dedup
917        // key into the service's window).
918        let relay_fired = deps.notification_service.notify(
919            "sess-1",
920            &AgentEvent::Complete {
921                usage: bamboo_agent_core::TokenUsage {
922                    prompt_tokens: 1,
923                    completion_tokens: 1,
924                    total_tokens: 2,
925                },
926            },
927        );
928        assert!(relay_fired.is_some());
929
930        notify_schedule_run_outcome(
931            &deps,
932            "sess-1",
933            true,
934            "Schedule 'nightly' completed".to_string(),
935            "All done.".to_string(),
936        )
937        .await;
938
939        // The manager-level call was deduped — it must not have broadcast a
940        // second notification onto the session channel.
941        let outcome = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
942        assert!(
943            outcome.is_err(),
944            "deduped schedule-level notification must not broadcast a second event"
945        );
946    }
947
948    /// Same guarantee, exercised in the opposite call order: the
949    /// schedule-level enrichment fires first and wins; a subsequent generic
950    /// relay classification for the same raw event is what gets deduped.
951    #[tokio::test]
952    async fn notify_schedule_run_outcome_first_dedups_a_later_generic_complete() {
953        let (deps, _dir) = relay_deps();
954
955        notify_schedule_run_outcome(
956            &deps,
957            "sess-2",
958            true,
959            "Schedule 'nightly' completed".to_string(),
960            "All done.".to_string(),
961        )
962        .await;
963
964        let relay_fired = deps.notification_service.notify(
965            "sess-2",
966            &AgentEvent::Complete {
967                usage: bamboo_agent_core::TokenUsage {
968                    prompt_tokens: 1,
969                    completion_tokens: 1,
970                    total_tokens: 2,
971                },
972            },
973        );
974        assert!(
975            relay_fired.is_none(),
976            "the later generic classification must be deduped away"
977        );
978    }
979}