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        // Scheduled runs have no per-request override channel; the
599        // config-level default (issue #221) still applies.
600        run_budget: None,
601        runners: ctx.agent_runners.clone(),
602        sessions_cache: ctx.sessions_cache.clone(),
603        on_complete: Some(on_complete),
604        // Scheduled runs are root sessions — no parent to wake.
605        child_completion_handler: None,
606    });
607
608    Ok(ScheduleRunLifecycleResult::BackgroundExecutionInProgress)
609}
610
611/// Build a [`ScheduleContext`] with server-specific config resolution.
612///
613/// Callers should prefer this over constructing `ScheduleContext` directly
614/// to ensure the `resolve_run_config` callback correctly reads Config and
615/// prompt defaults.
616pub fn build_schedule_context(
617    base: ScheduleContext,
618    config: std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
619    provider_registry: Arc<bamboo_llm::ProviderRegistry>,
620) -> ScheduleContext {
621    ScheduleContext {
622        schedule_store: base.schedule_store,
623        agent: base.agent,
624        tools: base.tools,
625        sessions_cache: base.sessions_cache,
626        agent_runners: base.agent_runners,
627        session_event_senders: base.session_event_senders,
628        account_feed_inbox: base.account_feed_inbox,
629        app_data_dir: base.app_data_dir,
630        trigger_engine: base.trigger_engine,
631        persistence: base.persistence,
632        notification_relay: base.notification_relay,
633        resolve_run_config: std::sync::Arc::new(move |job: &ScheduleRunJob| {
634            resolve_run_config_from_config(job, &config, &provider_registry)
635        }),
636    }
637}
638
639fn resolve_run_config_from_config(
640    job: &ScheduleRunJob,
641    config: &std::sync::Arc<tokio::sync::RwLock<bamboo_llm::Config>>,
642    provider_registry: &Arc<bamboo_llm::ProviderRegistry>,
643) -> ResolvedRunConfig {
644    let config_snapshot = config.try_read().map(|g| g.clone()).unwrap_or_default();
645
646    let requested_model = job
647        .run_config
648        .model
649        .as_deref()
650        .map(str::trim)
651        .filter(|v| !v.is_empty())
652        .map(|v| v.to_string());
653
654    let model = if let Some(m) = requested_model {
655        m
656    } else {
657        bamboo_engine::model_config_helper::get_schedule_model_from_config(&config_snapshot)
658            .unwrap_or_default()
659    };
660
661    let provider_name = Some(config_snapshot.effective_default_provider().to_string());
662    let provider_type = provider_name.as_deref().and_then(|name| {
663        bamboo_engine::model_config_helper::resolve_provider_type(
664            &config_snapshot,
665            name,
666            provider_registry,
667        )
668    });
669
670    let capability_provider_name = provider_name
671        .as_deref()
672        .unwrap_or(config_snapshot.effective_default_provider());
673    // Auxiliary models are global (config-derived), never session-bound.
674    let areas = bamboo_engine::model_areas::resolve_global_area_models(
675        &config_snapshot,
676        capability_provider_name,
677        provider_registry,
678    );
679
680    let requested_reasoning_effort = job.run_config.reasoning_effort;
681    let reasoning_effort = requested_reasoning_effort.or(config_snapshot.get_reasoning_effort());
682
683    let global_default_prompt =
684        bamboo_engine::prompt_defaults::read_global_default_system_prompt_template();
685    let base_system_prompt = job
686        .run_config
687        .system_prompt
688        .as_deref()
689        .map(str::trim)
690        .filter(|v| !v.is_empty())
691        .unwrap_or(global_default_prompt.as_str());
692
693    let workspace_path = job
694        .run_config
695        .workspace_path
696        .as_deref()
697        .map(str::trim)
698        .filter(|v| !v.is_empty())
699        .map(ToString::to_string)
700        .or_else(|| {
701            config_snapshot
702                .get_default_work_area_path()
703                .map(|path| bamboo_config::paths::path_to_display_string(&path))
704        });
705
706    let enhance_prompt = job
707        .run_config
708        .enhance_prompt
709        .as_deref()
710        .map(str::trim)
711        .filter(|v| !v.is_empty());
712
713    let system_prompt = bamboo_engine::context::assemble_system_prompt(
714        base_system_prompt,
715        enhance_prompt,
716        workspace_path.as_deref(),
717    );
718
719    let model_roster =
720        bamboo_engine::ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
721
722    ResolvedRunConfig {
723        model_roster,
724        reasoning_effort,
725        gold_config: bamboo_engine::model_config_helper::resolve_gold_config(
726            &config_snapshot,
727            None,
728        ),
729        system_prompt,
730        base_system_prompt: base_system_prompt.to_string(),
731        workspace_path,
732    }
733}
734
735#[cfg(test)]
736mod build_context_tests {
737    use super::resolve_run_config_from_config;
738    use super::ScheduleRunJob;
739    use bamboo_config::DefaultsConfig;
740    use bamboo_config::{OpenAIConfig, ProviderConfigs};
741    use bamboo_domain::{ProviderModelRef, ScheduleRunConfig};
742    use bamboo_llm::{Config, ProviderRegistry};
743    use std::collections::HashMap;
744    use std::sync::Arc;
745    use tokio::sync::RwLock;
746
747    fn test_job() -> ScheduleRunJob {
748        ScheduleRunJob {
749            run_id: "run-1".to_string(),
750            schedule_id: "schedule-1".to_string(),
751            schedule_name: "nightly".to_string(),
752            run_config: ScheduleRunConfig::default(),
753            scheduled_for: chrono::Utc::now(),
754            claimed_at: chrono::Utc::now(),
755            was_catch_up: false,
756        }
757    }
758
759    #[test]
760    fn resolve_run_config_from_config_prefers_fast_model() {
761        let config = Config {
762            provider: "openai".to_string(),
763            defaults: None,
764            features: bamboo_config::FeatureFlags {
765                provider_model_ref: false,
766                ..Default::default()
767            },
768            providers: ProviderConfigs {
769                openai: Some(OpenAIConfig {
770                    api_key: "test".to_string(),
771                    api_key_from_env: false,
772                    api_key_encrypted: None,
773                    base_url: None,
774                    model: Some("gpt-4o".to_string()),
775                    fast_model: Some("gpt-4o-mini".to_string()),
776                    vision_model: None,
777                    reasoning_effort: None,
778                    responses_only_models: vec![],
779                    request_overrides: None,
780                    extra: Default::default(),
781                }),
782                ..ProviderConfigs::default()
783            },
784            ..Config::default()
785        };
786
787        let registry = Arc::new(ProviderRegistry::new(
788            Default::default(),
789            "openai".to_string(),
790        ));
791        let resolved =
792            resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), &registry);
793        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-4o-mini"));
794    }
795
796    #[test]
797    fn resolve_run_config_from_config_falls_back_to_default_model_when_fast_missing() {
798        let config = Config {
799            provider: "openai".to_string(),
800            defaults: Some(DefaultsConfig {
801                chat: ProviderModelRef::new("openai", "gpt-chat"),
802                fast: None,
803                task_summary: None,
804                vision: None,
805                memory_background: None,
806                planning: None,
807                search: None,
808                code_review: None,
809                sub_agent: None,
810                subagent_models: HashMap::new(),
811            }),
812            features: bamboo_config::FeatureFlags {
813                provider_model_ref: true,
814                ..Default::default()
815            },
816            providers: ProviderConfigs::default(),
817            ..Config::default()
818        };
819
820        let registry = Arc::new(ProviderRegistry::new(
821            Default::default(),
822            "openai".to_string(),
823        ));
824        let resolved =
825            resolve_run_config_from_config(&test_job(), &Arc::new(RwLock::new(config)), &registry);
826        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
827    }
828}
829
830#[cfg(test)]
831mod notify_outcome_tests {
832    use super::*;
833    use crate::app_state::session_events::NotificationRelayDeps;
834    use crate::app_state::watchers::SessionWatchers;
835    use std::collections::HashMap;
836    use std::time::Duration;
837    use tokio::sync::RwLock as TokioRwLock;
838
839    fn relay_deps() -> (NotificationRelayDeps, tempfile::TempDir) {
840        let dir = tempfile::tempdir().unwrap();
841        let notification_service = Arc::new(bamboo_notification::NotificationService::new(
842            dir.path().join("prefs.json"),
843        ));
844        let deps = NotificationRelayDeps {
845            notification_service,
846            session_event_senders: Arc::new(TokioRwLock::new(HashMap::new())),
847            session_watchers: SessionWatchers::new(),
848            config: Arc::new(TokioRwLock::new(bamboo_llm::Config::default())),
849        };
850        (deps, dir)
851    }
852
853    #[test]
854    fn truncate_chars_appends_ellipsis_only_when_cut() {
855        assert_eq!(truncate_chars("short", 10), "short");
856        let truncated = truncate_chars(&"x".repeat(300), SCHEDULE_NOTIFY_BODY_MAX);
857        assert_eq!(truncated.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
858        assert!(truncated.ends_with('…'));
859    }
860
861    #[test]
862    fn schedule_run_title_names_the_schedule_and_outcome() {
863        assert_eq!(
864            schedule_run_title("nightly", true),
865            "Schedule 'nightly' completed"
866        );
867        assert_eq!(
868            schedule_run_title("nightly", false),
869            "Schedule 'nightly' failed"
870        );
871    }
872
873    #[test]
874    fn final_assistant_excerpt_finds_the_last_non_empty_assistant_message() {
875        let messages = vec![
876            Message::user("hi"),
877            Message::assistant("first reply", None),
878            Message::assistant("   ", None), // blank — skipped
879            Message::assistant("final reply", None),
880        ];
881        assert_eq!(
882            final_assistant_excerpt(&messages).as_deref(),
883            Some("final reply")
884        );
885    }
886
887    #[test]
888    fn final_assistant_excerpt_truncates_long_content() {
889        let long = "x".repeat(300);
890        let messages = vec![Message::assistant(long, None)];
891        let excerpt = final_assistant_excerpt(&messages).unwrap();
892        assert_eq!(excerpt.chars().count(), SCHEDULE_NOTIFY_BODY_MAX + 1);
893        assert!(excerpt.ends_with('…'));
894    }
895
896    #[test]
897    fn final_assistant_excerpt_none_when_no_assistant_message() {
898        let messages = vec![Message::user("hi")];
899        assert!(final_assistant_excerpt(&messages).is_none());
900    }
901
902    /// The manager-level no-double-fire guarantee, exercised through
903    /// [`notify_schedule_run_outcome`] itself (not just the underlying
904    /// `NotificationService` primitive it wraps): the generic relay path
905    /// (raw `AgentEvent::Complete`, classified via
906    /// `NotificationService::notify`) firing FIRST must dedup away a
907    /// subsequent schedule-level enrichment call for the same session — the
908    /// scenario `ensure_notification_relay` (spawned before
909    /// `spawn_session_execution` in `run_schedule_job`) races against this
910    /// hook.
911    #[tokio::test]
912    async fn notify_schedule_run_outcome_is_deduped_by_a_prior_generic_complete() {
913        let (deps, _dir) = relay_deps();
914        let (tx, mut rx) = broadcast::channel(16);
915        deps.session_event_senders
916            .write()
917            .await
918            .insert("sess-1".to_string(), tx.clone());
919
920        // Simulate the always-on relay having already classified the raw
921        // AgentEvent::Complete for this session (inserts the shared dedup
922        // key into the service's window).
923        let relay_fired = deps.notification_service.notify(
924            "sess-1",
925            &AgentEvent::Complete {
926                usage: bamboo_agent_core::TokenUsage {
927                    prompt_tokens: 1,
928                    completion_tokens: 1,
929                    total_tokens: 2,
930                },
931            },
932        );
933        assert!(relay_fired.is_some());
934
935        notify_schedule_run_outcome(
936            &deps,
937            "sess-1",
938            true,
939            "Schedule 'nightly' completed".to_string(),
940            "All done.".to_string(),
941        )
942        .await;
943
944        // The manager-level call was deduped — it must not have broadcast a
945        // second notification onto the session channel.
946        let outcome = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
947        assert!(
948            outcome.is_err(),
949            "deduped schedule-level notification must not broadcast a second event"
950        );
951    }
952
953    /// Same guarantee, exercised in the opposite call order: the
954    /// schedule-level enrichment fires first and wins; a subsequent generic
955    /// relay classification for the same raw event is what gets deduped.
956    #[tokio::test]
957    async fn notify_schedule_run_outcome_first_dedups_a_later_generic_complete() {
958        let (deps, _dir) = relay_deps();
959
960        notify_schedule_run_outcome(
961            &deps,
962            "sess-2",
963            true,
964            "Schedule 'nightly' completed".to_string(),
965            "All done.".to_string(),
966        )
967        .await;
968
969        let relay_fired = deps.notification_service.notify(
970            "sess-2",
971            &AgentEvent::Complete {
972                usage: bamboo_agent_core::TokenUsage {
973                    prompt_tokens: 1,
974                    completion_tokens: 1,
975                    total_tokens: 2,
976                },
977            },
978        );
979        assert!(
980            relay_fired.is_none(),
981            "the later generic classification must be deduped away"
982        );
983    }
984}