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, 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/// Resolved run configuration computed by the adapter layer.
38///
39/// The schedule crate delegates model/prompt/workspace resolution to the
40/// caller via [`ScheduleContext::resolve_run_config`] so that server-specific
41/// concerns (Config, filesystem prompt templates) stay out of the crate.
42#[derive(Clone)]
43pub struct ResolvedRunConfig {
44    /// Primary + auxiliary model/provider selection for the scheduled run.
45    /// The primary `model` is required; resolve it via `roster.model`.
46    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    /// Optional inbox to the account-wide change feed (durable multi-client sync).
66    pub account_feed_inbox: Option<bamboo_engine::execution::AccountFeedInbox>,
67    pub app_data_dir: Option<std::path::PathBuf>,
68    pub trigger_engine: DynTriggerEngine,
69    /// Authoritative Project registry, rechecked when each persisted job fires.
70    pub project_store: Arc<bamboo_projects::ProjectStore>,
71    /// AppState-owned workspace policy used by every schedule preflight/fire.
72    pub workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
73    /// Dependencies to start the always-on notification relay (see
74    /// `crate::app_state::session_events::ensure_notification_relay`).
75    /// Scheduled runs previously never classified events into notifications
76    /// at all — nothing spawned a relay for a session no SSE/WS client had
77    /// ever subscribed to, which is the common case for a headless run.
78    pub notification_relay: crate::app_state::session_events::NotificationRelayDeps,
79    /// Adapter-provided callback that resolves model, system prompt, workspace path
80    /// and reasoning effort for a schedule run job.
81    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        // Worker: executes jobs sequentially (simple + predictable).
100        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        // Ticker: claims due schedules and enqueues jobs.
160        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
219/// Maximum length, in characters, of the final-assistant-message excerpt
220/// used as a schedule-completion notification body (mirrors
221/// `bamboo_notification::policy`'s `RUN_FAILED_BODY_MAX`, which isn't
222/// exported for reuse here).
223const SCHEDULE_NOTIFY_BODY_MAX: usize = 200;
224
225/// Unicode-safe truncation to at most `max` chars, appending an ellipsis when
226/// cut.
227fn 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
236/// Builds the "Schedule '<name>' completed|failed" notification title.
237fn 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
245/// Excerpts the most recent non-empty assistant message from `messages`
246/// (walking back from the end), truncated to [`SCHEDULE_NOTIFY_BODY_MAX`]
247/// chars. This is "cheaply reachable" because the session is already in
248/// memory at the point the completion hook runs — no extra fetch or compute.
249/// Returns `None` when there is no such message, so the caller can fall back
250/// to a run-status string.
251fn 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
259/// Emits a schedule-specific completion/failure notification, enriching the
260/// generic `run_completed`/`run_failed` notification the always-on relay
261/// (`ensure_notification_relay`, wired in [`run_schedule_job`] below) already
262/// produces from the raw `AgentEvent::Complete`/`Error` this run's agent loop
263/// emits.
264///
265/// No-double-fire design: both sources mint through
266/// [`bamboo_notification::NotificationService::notify_schedule_run`] /
267/// `notify`, which share the SAME dedup key
268/// (`bamboo_notification::policy::classify_schedule_run`'s doc comment has
269/// the full rationale) within the service's 30s dedup window — so whichever
270/// of the two actually reaches the service first "wins" the user-visible
271/// copy and the second is silently coalesced. The run can therefore never
272/// double-notify its owner; this call is always safe to make unconditionally
273/// alongside the relay.
274async 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        // Deduped away by the generic relay-classified notification (or
286        // notifications/this category are disabled) — nothing to deliver.
287        return;
288    };
289
290    // Build the sink payload before `notification` is moved into the
291    // broadcast send below (mirrors `ensure_notification_relay`).
292    let sink_notification = crate::notify_sinks::SinkNotification::from_event(&notification);
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    // Primary model is required for a schedule run; the roster stores it as
374    // `Option<String>`, so recover the owned String once for the checks/logging
375    // below (an absent primary is treated as the old empty-string skip).
376    let resolved_model = resolved.model_roster.model.clone().unwrap_or_default();
377
378    // If the adapter resolved an empty model, skip the run.
379    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    // #73: a scheduled run has no interactive human approver — mark the root so
417    // its sub-agents (which inherit the flag) decide gated actions with the
418    // off-loop model-reviewer locally instead of escalating to an absent human,
419    // which would 300s-deny.
420    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    // Persist session and index entry.
451    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    // If no task message (or not configured to execute), we're done.
480    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    // Model is required by the provider trait; if resolution failed we'd have returned earlier.
512    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    // Reserve the shared runner and router before publishing any relay or
522    // execution-specific state.
523    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    // Always-on relay (the critical gap this closes): a scheduled/headless
541    // run has no SSE/WS client subscribed at start — often ever — so nothing
542    // used to spawn a notification relay for it, and approval/clarification/
543    // context/completion events for scheduled sessions never classified into
544    // notifications. Idempotent (`try_begin_relay`), so this harmlessly races
545    // a client that later opens the session's live stream.
546    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    // Run the agent loop in the background via the single canonical execution
560    // path (`spawn_session_execution`), the same one the HTTP execute handler
561    // and the child-completion coordinator use. Schedule-specific finalization
562    // (marking the run terminal, and writing a visible failure marker) is
563    // carried by the `on_complete` hook, which runs after the runner is
564    // finalized but before the session is persisted — so the marker is saved.
565    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                // Persist a visible failure marker so the user can open the
602                // scheduled session and understand why it produced no output.
603                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            // Owner notification, enriched with the schedule's name and the
622            // final assistant message (see `notify_schedule_run_outcome`'s
623            // doc comment for why this can never double-fire alongside the
624            // always-on relay's generic classification of the same run's
625            // raw `AgentEvent::Complete`/`Error`). Placed AFTER the failure
626            // marker above is appended to `session.messages`, so a failed
627            // run's body is that marker's text via `final_assistant_excerpt`.
628            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                &notification_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        // Scheduled runs use the per-run disabled snapshot (#136 lives on the
679        // interactive agent path; a scheduled task is a discrete run).
680        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 review is not wired into the schedule path for now.
689        guardian_config: None,
690        guardian_spawner: None,
691        // No bash self-resume hook on the schedule path: the end-of-turn bash
692        // suspend gate is therefore inert here (it requires a wired hook).
693        // Because the loop can't resume a backgrounded shell, the Bash tool's
694        // auto path detects this (can_async_resume == false, derived from
695        // hook+persistence) and stays purely synchronous — a long command on
696        // the default path blocks to its timeout rather than promoting to an
697        // orphaned background shell whose output this loop could never await
698        // (issue #84, phase 2d). An explicitly backgrounded shell
699        // (`run_in_background: true`) still runs detached and stays readable via
700        // BashOutput; no strand can occur because the gate refuses to suspend
701        // without the hook.
702        bash_resume_hook: None,
703        // Hook-less loop: no suspend/resume machinery, so stay push-free too
704        // (consistent with `can_async_resume: false` on this path).
705        bash_completion_sink: None,
706        app_data_dir: ctx.app_data_dir.clone(),
707        // Scheduled runs have no per-request override channel; the
708        // config-level default (issue #221) still applies.
709        run_budget: None,
710        runners: ctx.agent_runners.clone(),
711        sessions_cache: ctx.sessions_cache.clone(),
712        on_complete: Some(on_complete),
713        // Scheduled runs are root sessions — no parent to wake.
714        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
738/// Build a [`ScheduleContext`] with server-specific config resolution.
739///
740/// Callers should prefer this over constructing `ScheduleContext` directly
741/// to ensure the `resolve_run_config` callback correctly reads Config and
742/// prompt defaults.
743pub 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    // Auxiliary models are global (config-derived), never session-bound.
804    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)), &registry);
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)), &registry);
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)), &registry);
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), // blank — skipped
1063            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    /// The manager-level no-double-fire guarantee, exercised through
1087    /// [`notify_schedule_run_outcome`] itself (not just the underlying
1088    /// `NotificationService` primitive it wraps): the generic relay path
1089    /// (raw `AgentEvent::Complete`, classified via
1090    /// `NotificationService::notify`) firing FIRST must dedup away a
1091    /// subsequent schedule-level enrichment call for the same session — the
1092    /// scenario `ensure_notification_relay` (spawned before
1093    /// `spawn_session_execution` in `run_schedule_job`) races against this
1094    /// hook.
1095    #[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        // Simulate the always-on relay having already classified the raw
1105        // AgentEvent::Complete for this session (inserts the shared dedup
1106        // key into the service's window).
1107        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        // The manager-level call was deduped — it must not have broadcast a
1129        // second notification onto the session channel.
1130        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    /// Same guarantee, exercised in the opposite call order: the
1138    /// schedule-level enrichment fires first and wins; a subsequent generic
1139    /// relay classification for the same raw event is what gets deduped.
1140    #[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}